pax_global_header 0000666 0000000 0000000 00000000064 15070247161 0014515 g ustar 00root root 0000000 0000000 52 comment=c411f9192079dfacdb39b15032575ce28434bfc4
Bluetooth-Devices-habluetooth-21accde/ 0000775 0000000 0000000 00000000000 15070247161 0020122 5 ustar 00root root 0000000 0000000 Bluetooth-Devices-habluetooth-21accde/.all-contributorsrc 0000664 0000000 0000000 00000000462 15070247161 0023755 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-21accde/.copier-answers.yml 0000664 0000000 0000000 00000001053 15070247161 0023663 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-21accde/.editorconfig 0000664 0000000 0000000 00000000444 15070247161 0022601 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-21accde/.github/ 0000775 0000000 0000000 00000000000 15070247161 0021462 5 ustar 00root root 0000000 0000000 Bluetooth-Devices-habluetooth-21accde/.github/ISSUE_TEMPLATE/ 0000775 0000000 0000000 00000000000 15070247161 0023645 5 ustar 00root root 0000000 0000000 Bluetooth-Devices-habluetooth-21accde/.github/ISSUE_TEMPLATE/1-bug_report.md 0000664 0000000 0000000 00000000422 15070247161 0026473 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-21accde/.github/ISSUE_TEMPLATE/2-feature-request.md 0000664 0000000 0000000 00000000672 15070247161 0027454 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-21accde/.github/dependabot.yml 0000664 0000000 0000000 00000001344 15070247161 0024314 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-21accde/.github/labels.toml 0000664 0000000 0000000 00000003515 15070247161 0023625 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-21accde/.github/workflows/ 0000775 0000000 0000000 00000000000 15070247161 0023517 5 ustar 00root root 0000000 0000000 Bluetooth-Devices-habluetooth-21accde/.github/workflows/ci.yml 0000664 0000000 0000000 00000021332 15070247161 0024636 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
jobs:
lint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v4
- uses: actions/setup-python@e797f83bcb11b83ae66e0230d6156d7c80228e7c # v5
with:
python-version: 3.13
- uses: pre-commit/action@2c7b3805fd2a0fd8c1884dcaebf91fc102a13ecd # v3.0.1
# Make sure commit messages follow the conventional commits convention:
# https://www.conventionalcommits.org
commitlint:
name: Lint Commit Messages
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v4
with:
fetch-depth: 0
- uses: wagoid/commitlint-github-action@b948419dd99f3fd78a6548d48f94e3df7f6bf3ed # v6.2.1
test:
strategy:
fail-fast: false
matrix:
python-version:
- "3.11"
- "3.12"
- "3.13"
- "3.14"
- "3.14t"
os:
- ubuntu-latest
- macOS-latest
- windows-latest
extension:
- "skip_cython"
- "use_cython"
runs-on: ${{ matrix.os }}
steps:
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v4
- name: Install poetry
run: pipx install poetry
- name: Set up Python
uses: actions/setup-python@e797f83bcb11b83ae66e0230d6156d7c80228e7c # v5
with:
python-version: ${{ matrix.python-version }}
cache: "poetry"
allow-prereleases: true
- name: Install Dependencies
run: |
if [ "${{ matrix.extension }}" = "skip_cython" ]; then
SKIP_CYTHON=1 poetry install --only=main,dev
else
REQUIRE_CYTHON=1 poetry install --only=main,dev
fi
shell: bash
- name: Test with Pytest
run: poetry run pytest --cov-report=xml
shell: bash
- name: Upload coverage to Codecov
uses: codecov/codecov-action@5a1091511ad55cbe89839c7260b706298ca349f7 # v5
with:
token: ${{ secrets.CODECOV_TOKEN }}
benchmark:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v4
- name: Install poetry
run: pipx install poetry
- name: Setup Python 3.13
uses: actions/setup-python@e797f83bcb11b83ae66e0230d6156d7c80228e7c # v5
with:
python-version: 3.13
cache: "poetry"
- name: Install Dependencies
run: |
REQUIRE_CYTHON=1 poetry install --only=main,dev
shell: bash
- name: Run benchmarks
uses: CodSpeedHQ/action@653fdc30e6c40ffd9739e40c8a0576f4f4523ca1 # v3
with:
token: ${{ secrets.CODSPEED_TOKEN }}
run: poetry run pytest --no-cov -vvvvv --codspeed
mode: instrumentation
release:
needs:
- test
- lint
- commitlint
runs-on: ubuntu-latest
environment: release
concurrency: release
permissions:
id-token: write
contents: write
outputs:
released: ${{ steps.release.outputs.released }}
newest_release_tag: ${{ steps.release.outputs.tag }}
steps:
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v4
with:
fetch-depth: 0
ref: ${{ github.head_ref || github.ref_name }}
# Do a dry run of PSR
- name: Test release
uses: python-semantic-release/python-semantic-release@4d4cb0ab842247caea1963132c242c62aab1e4d5 # v10.4.1
if: github.ref_name != 'main'
with:
no_operation_mode: true
# On main branch: actual PSR + upload to PyPI & GitHub
- name: Release
uses: python-semantic-release/python-semantic-release@4d4cb0ab842247caea1963132c242c62aab1e4d5 # v10.4.1
id: release
if: github.ref_name == 'main'
with:
github_token: ${{ secrets.GITHUB_TOKEN }}
- name: Publish package distributions to PyPI
uses: pypa/gh-action-pypi-publish@ed0c53931b1dc9bd32cbe73a98c7f6766f8a527e # release/v1
if: steps.release.outputs.released == 'true'
- name: Publish package distributions to GitHub Releases
uses: python-semantic-release/upload-to-gh-release@0a92b5d7ebfc15a84f9801ebd1bf706343d43711 # main
if: steps.release.outputs.released == 'true'
with:
github_token: ${{ secrets.GITHUB_TOKEN }}
build_wheels:
needs: [release]
if: needs.release.outputs.released == 'true'
name: Wheels for ${{ matrix.os }} (${{ matrix.musl == 'musllinux' && 'musllinux' || 'manylinux' }}) ${{ matrix.qemu }} ${{ matrix.pyver }}
runs-on: ${{ matrix.os }}
strategy:
matrix:
os:
[
windows-latest,
ubuntu-24.04-arm,
ubuntu-latest,
macos-13,
macos-latest,
]
qemu: [""]
musl: [""]
pyver: [""]
include:
- os: ubuntu-latest
musl: "musllinux"
- os: ubuntu-24.04-arm
musl: "musllinux"
# qemu is slow, make a single
# runner per Python version
- os: ubuntu-latest
qemu: armv7l
musl: "musllinux"
pyver: cp311
- os: ubuntu-latest
qemu: armv7l
musl: "musllinux"
pyver: cp312
- os: ubuntu-latest
qemu: armv7l
musl: "musllinux"
pyver: cp313
- os: ubuntu-latest
qemu: armv7l
musl: "musllinux"
pyver: cp314
- os: ubuntu-latest
qemu: armv7l
musl: "musllinux"
pyver: cp314t
# qemu is slow, make a single
# runner per Python version
- os: ubuntu-latest
qemu: armv7l
musl: ""
pyver: cp311
- os: ubuntu-latest
qemu: armv7l
musl: ""
pyver: cp312
- os: ubuntu-latest
qemu: armv7l
musl: ""
pyver: cp313
- os: ubuntu-latest
qemu: armv7l
musl: ""
pyver: cp314
- os: ubuntu-latest
qemu: armv7l
musl: ""
pyver: cp314t
steps:
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v4
with:
ref: ${{ needs.release.outputs.newest_release_tag }}
fetch-depth: 0
# Used to host cibuildwheel
- name: Set up Python
uses: actions/setup-python@e797f83bcb11b83ae66e0230d6156d7c80228e7c # v5
with:
python-version: "3.12"
- name: Set up QEMU
if: ${{ matrix.qemu }}
uses: docker/setup-qemu-action@29109295f81e9208d7d86ff1c6c12d2833863392 # v3
with:
platforms: all
# This should be temporary
# xref https://github.com/docker/setup-qemu-action/issues/188
# xref https://github.com/tonistiigi/binfmt/issues/215
image: tonistiigi/binfmt:qemu-v8.1.5
id: qemu
- name: Prepare emulation
if: ${{ matrix.qemu }}
run: |
if [[ -n "${{ matrix.qemu }}" ]]; then
# Build emulated architectures only if QEMU is set,
# use default "auto" otherwise
echo "CIBW_ARCHS_LINUX=${{ matrix.qemu }}" >> $GITHUB_ENV
fi
- name: Limit to a specific Python version on slow QEMU
if: ${{ matrix.pyver }}
run: |
if [[ -n "${{ matrix.pyver }}" ]]; then
echo "CIBW_BUILD=${{ matrix.pyver }}*" >> $GITHUB_ENV
fi
- name: Build wheels
uses: pypa/cibuildwheel@7c619efba910c04005a835b110b057fc28fd6e93 # v3.2.0
env:
CIBW_SKIP: cp36-* cp37-* cp38-* cp39-* cp310-* pp* ${{ matrix.musl == 'musllinux' && '*manylinux*' || '*musllinux*' }}
REQUIRE_CYTHON: 1
- uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
with:
name: wheels-${{ matrix.os }}-${{ matrix.musl }}-${{ matrix.pyver }}-${{ matrix.qemu }}
path: ./wheelhouse/*.whl
upload_pypi:
needs: [build_wheels]
runs-on: ubuntu-latest
environment: release
permissions:
id-token: write # IMPORTANT: this permission is mandatory for trusted publishing
steps:
- uses: actions/download-artifact@634f93cb2916e3fdff6788551b99b062d0335ce0 # v4
with:
# unpacks default artifact into dist/
# if `name: artifact` is omitted, the action will create extra parent dir
path: dist
pattern: wheels-*
merge-multiple: true
- uses: pypa/gh-action-pypi-publish@ed0c53931b1dc9bd32cbe73a98c7f6766f8a527e # v1.13.0
Bluetooth-Devices-habluetooth-21accde/.github/workflows/hacktoberfest.yml 0000664 0000000 0000000 00000000534 15070247161 0027070 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.3.0
with:
github_token: ${{ secrets.GH_PAT }}
Bluetooth-Devices-habluetooth-21accde/.github/workflows/issue-manager.yml 0000664 0000000 0000000 00000001340 15070247161 0027000 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.5.1
with:
token: ${{ secrets.GITHUB_TOKEN }}
config: >
{
"answered": {
"message": "Assuming the original issue was solved, it will be automatically closed now."
},
"waiting": {
"message": "Automatically closing. To re-open, please provide the additional information requested."
}
}
Bluetooth-Devices-habluetooth-21accde/.github/workflows/poetry-upgrade.yml 0000664 0000000 0000000 00000000337 15070247161 0027214 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-21accde/.gitignore 0000664 0000000 0000000 00000004114 15070247161 0022112 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-21accde/.gitpod.yml 0000664 0000000 0000000 00000000306 15070247161 0022210 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-21accde/.idea/ 0000775 0000000 0000000 00000000000 15070247161 0021102 5 ustar 00root root 0000000 0000000 Bluetooth-Devices-habluetooth-21accde/.idea/habluetooth.iml 0000664 0000000 0000000 00000000515 15070247161 0024124 0 ustar 00root root 0000000 0000000
Bluetooth-Devices-habluetooth-21accde/.idea/watcherTasks.xml 0000664 0000000 0000000 00000005253 15070247161 0024274 0 ustar 00root root 0000000 0000000
Bluetooth-Devices-habluetooth-21accde/.idea/workspace.xml 0000664 0000000 0000000 00000002736 15070247161 0023632 0 ustar 00root root 0000000 0000000
Bluetooth-Devices-habluetooth-21accde/.pre-commit-config.yaml 0000664 0000000 0000000 00000003070 15070247161 0024403 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/commitizen-tools/commitizen
rev: v4.9.1
hooks:
- id: commitizen
stages: [commit-msg]
- repo: https://github.com/pre-commit/pre-commit-hooks
rev: v6.0.0
hooks:
- id: debug-statements
- id: check-builtin-literals
- id: check-case-conflict
- id: check-docstring-first
- id: check-json
- id: check-toml
- id: check-xml
- id: check-yaml
- id: detect-private-key
- id: end-of-file-fixer
- id: trailing-whitespace
- repo: https://github.com/python-poetry/poetry
rev: 2.2.1
hooks:
- id: poetry-check
- repo: https://github.com/pre-commit/mirrors-prettier
rev: v4.0.0-alpha.8
hooks:
- id: prettier
args: ["--tab-width", "2"]
- repo: https://github.com/astral-sh/ruff-pre-commit
rev: v0.13.2
hooks:
- id: ruff
args: [--fix, --exit-non-zero-on-fix]
- repo: https://github.com/psf/black
rev: 25.9.0
hooks:
- id: black
- repo: https://github.com/codespell-project/codespell
rev: v2.4.1
hooks:
- id: codespell
- repo: https://github.com/pre-commit/mirrors-mypy
rev: v1.18.2
hooks:
- id: mypy
additional_dependencies: []
Bluetooth-Devices-habluetooth-21accde/.readthedocs.yml 0000664 0000000 0000000 00000001051 15070247161 0023205 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-21accde/CHANGELOG.md 0000664 0000000 0000000 00000064635 15070247161 0021751 0 ustar 00root root 0000000 0000000 # Changelog
## v5.7.0 (2025-10-04)
### Features
- Python 3.14 support ([`3ef7243`](https://github.com/Bluetooth-Devices/habluetooth/commit/3ef7243b2fe996970a3bd18a279a550f10233ede))
## v5.6.4 (2025-09-13)
### Bug fixes
- Workaround kernel abi inconsistency in bluetooth mgmt socket send behavior ([`affc097`](https://github.com/Bluetooth-Devices/habluetooth/commit/affc0971edf310bfd6f5a9f880fa488b4ed5a215))
### Unknown
## v5.6.3 (2025-09-13)
### Bug fixes
- High cpu usage by replacing async context manager with setup/cleanup pattern to avoid cython bug ([`8aa021a`](https://github.com/Bluetooth-Devices/habluetooth/commit/8aa021aee970fc01ccdb3d7f3242eee7fc3802cf))
## v5.6.2 (2025-09-09)
### Bug fixes
- Resolve crash when compiled with cython 3.1 ([`bac6dcf`](https://github.com/Bluetooth-Devices/habluetooth/commit/bac6dcffaba3940ff7331739866801729eee2032))
## v5.6.1 (2025-09-09)
### Bug fixes
- Rebuild wheels ([`bcc77be`](https://github.com/Bluetooth-Devices/habluetooth/commit/bcc77be98b6d3da33b8d74f1dda899e9823aa652))
## v5.6.0 (2025-09-08)
### Features
- Callback on scanner start success ([`782b717`](https://github.com/Bluetooth-Devices/habluetooth/commit/782b717044beff9758774c54cecbe3af9520e78b))
## v5.5.1 (2025-09-08)
### Bug fixes
- Handle case where two scanners have the exact same rssi ([`f51f700`](https://github.com/Bluetooth-Devices/habluetooth/commit/f51f700bdf41d508072c9ba1109a4bf7c7e5c57f))
## v5.5.0 (2025-09-08)
### Features
- Log slots when connecting ([`a3881c4`](https://github.com/Bluetooth-Devices/habluetooth/commit/a3881c4cccac52d668518dc8632104688dbf2788))
## v5.4.0 (2025-09-08)
### Features
- Consider connection slots when selecting connection path ([`fb938fc`](https://github.com/Bluetooth-Devices/habluetooth/commit/fb938fc608fd9f846c3eedd70ff245d5b299b93c))
## v5.3.1 (2025-09-06)
### Bug fixes
- Detect missing net_admin/net_raw capabilities and fallback to bluez-only mode ([`1cf17c0`](https://github.com/Bluetooth-Devices/habluetooth/commit/1cf17c094fc2bce65e703d230259f3c2c66720e7))
## v5.3.0 (2025-08-31)
### Features
- Include scanner type in details ([`0b558f1`](https://github.com/Bluetooth-Devices/habluetooth/commit/0b558f155e650b26a1b8c996d7292f62906cc3b4))
## v5.2.1 (2025-08-29)
### Bug fixes
- Incorrect advertising interval calculation when scanner pauses for connections ([`1c8db59`](https://github.com/Bluetooth-Devices/habluetooth/commit/1c8db59b8da69439d253bc356fe7be5321c7fb23))
## v5.2.0 (2025-08-28)
### Features
- Add methods to set current and requested mode to scanner ([`3cf872c`](https://github.com/Bluetooth-Devices/habluetooth/commit/3cf872c899beadd9594a5749a5ce433d943852f4))
## v5.1.0 (2025-08-19)
### Features
- Warn when connections are established without bleak-retry-connector ([`ba23681`](https://github.com/Bluetooth-Devices/habluetooth/commit/ba23681de0a4056da130ce7d2e8d7c25dbb18ad0))
### Unknown
## v5.0.2 (2025-08-12)
### Bug fixes
- Solve performance regression while connecting in linux ([`47b5b7e`](https://github.com/Bluetooth-Devices/habluetooth/commit/47b5b7e4e00b2be7922a2101a690e37de0970e5e))
## v5.0.1 (2025-08-09)
### Bug fixes
- Ensure connect works without debug logging ([`4da091d`](https://github.com/Bluetooth-Devices/habluetooth/commit/4da091dc3ee512d499c82d179420f7fbc0539457))
## v5.0.0 (2025-08-09)
### Features
- Add bt management side channel ([`e345308`](https://github.com/Bluetooth-Devices/habluetooth/commit/e3453089f510548ec7605fe83aa2078028ef2468))
## v4.0.2 (2025-08-06)
### Bug fixes
- Add clear error when only passive bluetooth adapters are available ([`bbb494c`](https://github.com/Bluetooth-Devices/habluetooth/commit/bbb494c99bd87e8e090a922a28cf7e083b191bc4))
## v4.0.1 (2025-07-03)
### Bug fixes
- Small cleanups for bleak 1.x support ([`3cf74f4`](https://github.com/Bluetooth-Devices/habluetooth/commit/3cf74f492354c452469907fb84a74cac9c7edcd3))
## v4.0.0 (2025-07-03)
### Features
- Support bleak 1.x ([`a739199`](https://github.com/Bluetooth-Devices/habluetooth/commit/a739199cf6d56f5db316b149134c11eabfab9f1c))
## v3.49.0 (2025-06-03)
### Features
- Add raw_advertisement_data to diagnostics ([`a77933b`](https://github.com/Bluetooth-Devices/habluetooth/commit/a77933b8dd0195ab827907ea918c572cd1686750))
## v3.48.2 (2025-05-03)
### Bug fixes
- Remove duplicate _connecting slot from basehascanner ([`230bb03`](https://github.com/Bluetooth-Devices/habluetooth/commit/230bb038eea8ae07a3fe798ec15792489d06cd66))
## v3.48.1 (2025-05-03)
### Bug fixes
- Pin cython to <3.1 ([`21dc734`](https://github.com/Bluetooth-Devices/habluetooth/commit/21dc7340c548713c4539d8d8a067a2a574623906))
## v3.48.0 (2025-05-03)
### Features
- Refactor scanner history to live on the scanner itself ([`ea0d2fc`](https://github.com/Bluetooth-Devices/habluetooth/commit/ea0d2fc088832a1b3f8c7859c82e2e05bf1261f9))
## v3.47.1 (2025-05-03)
### Bug fixes
- Ensure logging does not fail when there is only a single scanner ([`d81378e`](https://github.com/Bluetooth-Devices/habluetooth/commit/d81378e6b4adedead6d04ab23be7b655cd3785fb))
## v3.47.0 (2025-05-03)
### Bug fixes
- Require bluetooth-auto-recovery >= 1.5.1 ([`8164ce5`](https://github.com/Bluetooth-Devices/habluetooth/commit/8164ce512084fe898cb80c5e44f664dde4751113))
### Features
- Avoid thundering heard of connections ([`943cc20`](https://github.com/Bluetooth-Devices/habluetooth/commit/943cc2043731f8d6fbb541f4d7ffcd37d8c6b4f3))
## v3.46.0 (2025-05-03)
### Features
- Improve recovery when adapter has gone silent and needs a usb reset ([`a4dd395`](https://github.com/Bluetooth-Devices/habluetooth/commit/a4dd395b7a8e70cb0ae94d97422d35eb638daaa5))
## v3.45.0 (2025-04-29)
### Features
- Improve performance of _async_on_advertisement_internal ([`be0b5a6`](https://github.com/Bluetooth-Devices/habluetooth/commit/be0b5a6d0da07f2f881984c92c0c7671117d3e5a))
## v3.44.0 (2025-04-28)
### Features
- Save the raw data in storage ([`eaf4107`](https://github.com/Bluetooth-Devices/habluetooth/commit/eaf41072ecc915b2de23ad3c9a03148f4b313f17))
## v3.43.0 (2025-04-28)
### Features
- Migrate storage code from bluetooth_adapters ([`5d671f9`](https://github.com/Bluetooth-Devices/habluetooth/commit/5d671f95b9a7964bfa871c7b42061a71a98ce80e))
## v3.42.0 (2025-04-27)
### Features
- Add raw field to bluetoothserviceinfobleak ([`343f18b`](https://github.com/Bluetooth-Devices/habluetooth/commit/343f18bfbbf3ebbee31e64beab60b2686700797f))
## v3.41.0 (2025-04-27)
### Features
- Add new _async_on_raw_advertisement base scanner api ([`fb2a487`](https://github.com/Bluetooth-Devices/habluetooth/commit/fb2a487c06cf102c17509410f916b5c06728df98))
## v3.40.0 (2025-04-27)
### Features
- Require bluetooth-data-tools 1.28.0 or later ([`e154136`](https://github.com/Bluetooth-Devices/habluetooth/commit/e154136db9f15d33c6de3d89bf9e4e53e03c690a))
## v3.39.0 (2025-04-17)
### Features
- Improve performance of _async_on_advertisement ([`0fc0500`](https://github.com/Bluetooth-Devices/habluetooth/commit/0fc0500d74cdc3d320111df979bef784a51a2eac))
## v3.38.1 (2025-04-14)
### Bug fixes
- Add missing dbus-fast dep on linux ([`5746448`](https://github.com/Bluetooth-Devices/habluetooth/commit/57464488482626577e9f84c42ab1ff100b7857b3))
## v3.38.0 (2025-03-22)
### Bug fixes
- Use project.license key ([`1decf97`](https://github.com/Bluetooth-Devices/habluetooth/commit/1decf9704f7db33bc8094880651321c1b58420c8))
### Features
- Improve performance of previous source checks ([`8d96528`](https://github.com/Bluetooth-Devices/habluetooth/commit/8d96528f605231f3089319c789390d784c45b4c5))
## v3.37.0 (2025-03-21)
### Features
- Improve performance of _prefer_previous_adv_from_different_source ([`73ec210`](https://github.com/Bluetooth-Devices/habluetooth/commit/73ec2107375be217ffb0310194be8c3d4f20e150))
## v3.36.0 (2025-03-21)
### Features
- Improve performance of filtering apple data ([`9f56840`](https://github.com/Bluetooth-Devices/habluetooth/commit/9f568405ae987de0fb3953d6ae7b39eabacde9ef))
## v3.35.0 (2025-03-21)
### Features
- Optimize previous local name matching ([`fadb722`](https://github.com/Bluetooth-Devices/habluetooth/commit/fadb722b8ded2bc15bd56b641a963d4c4d19838e))
## v3.34.1 (2025-03-21)
### Bug fixes
- Revert adding _async_on_advertisements ([`4bc3cb8`](https://github.com/Bluetooth-Devices/habluetooth/commit/4bc3cb89baf52570deec4f27ed3cd935249525ec))
## v3.34.0 (2025-03-21)
### Features
- Rename _async_on_raw_advertisement to _async_on_raw_advertisements ([`b3acb88`](https://github.com/Bluetooth-Devices/habluetooth/commit/b3acb882d888a33567ece3e7f9d0fa1d2b4c6acd))
## v3.33.0 (2025-03-21)
### Features
- Add _async_on_raw_advertisement ([`24d128f`](https://github.com/Bluetooth-Devices/habluetooth/commit/24d128fe4854135647e9a41c7eeaf1784fbda0bf))
## v3.32.0 (2025-03-15)
### Features
- Improve performance of dispatching discovery info to subclasses ([`d0fae7d`](https://github.com/Bluetooth-Devices/habluetooth/commit/d0fae7ddd9158903f6621888cc4c75480822ae35))
## v3.31.0 (2025-03-15)
### Features
- Avoid building on demand advertisementdata if there are no bleak callbacks ([`ae977b9`](https://github.com/Bluetooth-Devices/habluetooth/commit/ae977b9d53c29c581ff6394a2078d2a2b01066dd))
## v3.30.0 (2025-03-15)
### Features
- Improve performance of on demand advertisementdata construction ([`ab005cb`](https://github.com/Bluetooth-Devices/habluetooth/commit/ab005cbef5e2ece74a0facd502fca7173ba2b1fc))
## v3.29.0 (2025-03-15)
### Features
- Improve performance for device with large manufacturer data history ([`ec1f6aa`](https://github.com/Bluetooth-Devices/habluetooth/commit/ec1f6aa7989cea2a589029362461dba4f7a8f0db))
## v3.28.0 (2025-03-15)
### Features
- Improve performance of local name checks ([`9f57d2f`](https://github.com/Bluetooth-Devices/habluetooth/commit/9f57d2fcc23595b376d5785162c21633514f44bd))
## v3.27.0 (2025-03-14)
### Features
- Improve performance of base_scanner ([`5b8c59c`](https://github.com/Bluetooth-Devices/habluetooth/commit/5b8c59c7ffadead5997fa457b07ff37ec8ec31b5))
## v3.26.0 (2025-03-14)
### Features
- Improve manager performance ([`e0bdace`](https://github.com/Bluetooth-Devices/habluetooth/commit/e0bdace8180ff3ac450447be99f700fd647fb659))
## v3.25.1 (2025-03-13)
### Bug fixes
- Downgrade scanner gone quiet logger to debug ([`d450ffc`](https://github.com/Bluetooth-Devices/habluetooth/commit/d450ffca38dec015f44b5be08af484fe8ca09866))
## v3.25.0 (2025-03-05)
### Bug fixes
- Use trusted publishing for wheels ([`c726687`](https://github.com/Bluetooth-Devices/habluetooth/commit/c726687affb0025037676b76cf4ecefdef0da23f))
### Features
- Add armv7l to wheel builds ([`e394707`](https://github.com/Bluetooth-Devices/habluetooth/commit/e394707b6b7ffc54e6dc5b8c038a08c5404f1777))
- Reduce wheel sizes ([`5e6b644`](https://github.com/Bluetooth-Devices/habluetooth/commit/5e6b64476ff2db7a215d1b0d58ef01c04b839d34))
## v3.24.1 (2025-02-27)
### Bug fixes
- Update scanner discover signature for newer bleak ([`a071cb8`](https://github.com/Bluetooth-Devices/habluetooth/commit/a071cb8e3f921da30055b94a74a4b0aa339e53de))
## v3.24.0 (2025-02-22)
### Features
- Improve logging of scanner failures and time_since_last_detection ([`f0ff045`](https://github.com/Bluetooth-Devices/habluetooth/commit/f0ff04586849bda3933fbe98e8e1335c308999c4))
## v3.23.0 (2025-02-21)
### Features
- Add debug logging for connection paths ([`562d469`](https://github.com/Bluetooth-Devices/habluetooth/commit/562d46912e7596febc3ebcc0301280e6f334172b))
## v3.22.1 (2025-02-20)
### Bug fixes
- Try to force stop discovery if its stuck on ([`e28d836`](https://github.com/Bluetooth-Devices/habluetooth/commit/e28d836d28f0b8062831ee209ba54a7735c4d5ae))
## v3.22.0 (2025-02-18)
### Features
- Allow remote scanners to set current and requested mode ([`a39ba18`](https://github.com/Bluetooth-Devices/habluetooth/commit/a39ba184e0d01f983133534e4fd7c1b6202210fb))
## v3.21.1 (2025-02-04)
### Bug fixes
- Update poetry to v2 ([`aefe36e`](https://github.com/Bluetooth-Devices/habluetooth/commit/aefe36e2507566224267f371511c1f1c748a37a9))
## v3.21.0 (2025-02-01)
### Features
- Reduce remote scanner adv processing overhead ([`7bf302b`](https://github.com/Bluetooth-Devices/habluetooth/commit/7bf302bac3855cf7e229dd2744acce513b2e2ee4))
## v3.20.1 (2025-02-01)
### Bug fixes
- Remove unused centralbluetoothmanager in models ([`7466034`](https://github.com/Bluetooth-Devices/habluetooth/commit/74660343b30fec50b927fdddd92e72eacb4da6cf))
- Precision loss when comparing advs from different sources ([`02279a9`](https://github.com/Bluetooth-Devices/habluetooth/commit/02279a95ca5b590768bd631bf39ee507a64db7ad))
## v3.20.0 (2025-02-01)
### Features
- Reduce adv tracker overhead ([`69168a6`](https://github.com/Bluetooth-Devices/habluetooth/commit/69168a64572ab3fba696d2afedeb015953afb0cc))
## v3.19.0 (2025-02-01)
### Features
- Reduce overhead to convert non-connectable bluetoothserviceinfobleak to connectable ([`37fc839`](https://github.com/Bluetooth-Devices/habluetooth/commit/37fc839d5fc73ff6f784ec8041606be82d58322b))
## v3.18.0 (2025-02-01)
### Features
- Refactor scanner_adv_received to reduce ref counting ([`a1945ce`](https://github.com/Bluetooth-Devices/habluetooth/commit/a1945cedc2373082814e8f4b4426a50c79788305))
## v3.17.1 (2025-01-31)
### Bug fixes
- Ensure allocations are available if the adapter never makes any connections ([`b3dfa48`](https://github.com/Bluetooth-Devices/habluetooth/commit/b3dfa48dba2482c16f61fceaf9a0f58ea55df982))
## v3.17.0 (2025-01-31)
### Features
- Remove the need to call set_manager to set up ([`1312bf7`](https://github.com/Bluetooth-Devices/habluetooth/commit/1312bf7d978ff585e66d99bde766e85773fce006))
## v3.16.0 (2025-01-31)
### Features
- Allow bluetoothmanager to be created with defaults ([`70b2f69`](https://github.com/Bluetooth-Devices/habluetooth/commit/70b2f6952fbd3ecd499a4c66ec305869158a428e))
## v3.15.0 (2025-01-31)
### Features
- Include findmy packets in wanted adverts ([`5217850`](https://github.com/Bluetooth-Devices/habluetooth/commit/5217850934bfed5d8e70f8b43c84cd97cf53cdac))
## v3.14.0 (2025-01-29)
### Features
- Add allocations to diagnostics ([`aa41088`](https://github.com/Bluetooth-Devices/habluetooth/commit/aa4108872478720ab4cbcf52c5add015441fe72d))
## v3.13.0 (2025-01-28)
### Features
- Add async_register_scanner_registration_callback and async_current_scanners to the manager ([`99fcb46`](https://github.com/Bluetooth-Devices/habluetooth/commit/99fcb46a73ea6cb8f01817263d01a342365be78f))
## v3.12.0 (2025-01-22)
### Features
- Add support for connection allocations for non-connectable scanners ([`d76b7c9`](https://github.com/Bluetooth-Devices/habluetooth/commit/d76b7c9624b6c4e6beedc1bd56dd1a3c0df70eec))
## v3.11.2 (2025-01-22)
### Bug fixes
- Re-release again for failed arm runners ([`af2bb50`](https://github.com/Bluetooth-Devices/habluetooth/commit/af2bb50879713378a32339e490a57b56083a4fa7))
## v3.11.1 (2025-01-22)
### Bug fixes
- Re-release due to failed github action ([`90e2192`](https://github.com/Bluetooth-Devices/habluetooth/commit/90e2192ff75c13ccf610fd06a61e64d60dfd1a18))
## v3.11.0 (2025-01-22)
### Features
- Add api for getting current slot allocations ([`0a9bef9`](https://github.com/Bluetooth-Devices/habluetooth/commit/0a9bef927c5f29c3e724fb60aa06706b6d896f82))
## v3.10.0 (2025-01-21)
### Features
- Add support for getting callbacks when adapter allocations change ([`c6fd2ba`](https://github.com/Bluetooth-Devices/habluetooth/commit/c6fd2babf0c6438ff85220edef95df3d3b4fae9c))
## v3.9.2 (2025-01-20)
### Bug fixes
- Increase rssi switch value to 16 ([`db367db`](https://github.com/Bluetooth-Devices/habluetooth/commit/db367dbef3fa883348a72cf17e29d9c26a09de53))
## v3.9.1 (2025-01-20)
### Bug fixes
- Increase rssi switch threshold for advertisements ([`297c269`](https://github.com/Bluetooth-Devices/habluetooth/commit/297c2693f9a2c007f0e70175c24416c8bb7da099))
## v3.9.0 (2025-01-17)
### Features
- Switch to native arm runners for wheel builds ([`bf7e98b`](https://github.com/Bluetooth-Devices/habluetooth/commit/bf7e98b099597916bb7566eb03472023f8acef97))
## v3.8.0 (2025-01-10)
### Features
- Add async_register_disappeared_callback ([`ec1d445`](https://github.com/Bluetooth-Devices/habluetooth/commit/ec1d4456ca15c6fca3248f2e5d73fcb1ba9d36c6))
## v3.7.0 (2025-01-05)
### Bug fixes
- Publish workflow ([`341c8a4`](https://github.com/Bluetooth-Devices/habluetooth/commit/341c8a4b72fb2818a3bed44632048d8570fc3b67))
### Features
- Start building wheels for python 3.13 ([`26dd831`](https://github.com/Bluetooth-Devices/habluetooth/commit/26dd831c28f3c0dfe0745769749e795e7937c7df))
- Add codspeed benchmarks ([`5905fbd`](https://github.com/Bluetooth-Devices/habluetooth/commit/5905fbd2c54adea04c0e55fe8a299f771e6f31ed))
### Unknown
## v3.6.0 (2024-10-20)
### Features
- Speed up creation of advertisementdata namedtuple ([`28f7e60`](https://github.com/Bluetooth-Devices/habluetooth/commit/28f7e6093c3985da16e537bc9d989d839ad80c56))
## v3.5.0 (2024-10-05)
### Features
- Add support for python 3.13 ([`b8a4783`](https://github.com/Bluetooth-Devices/habluetooth/commit/b8a4783a43f6e771321974d2c085e5e0dda9e195))
## v3.4.1 (2024-09-22)
### Bug fixes
- Ensure build system required cython 3 ([`dc85d2f`](https://github.com/Bluetooth-Devices/habluetooth/commit/dc85d2fd1b8c8e4d8eb4515aa60af06782fc8722))
## v3.4.0 (2024-09-02)
### Features
- Add a fast cython init path for bluetoothserviceinfobleak ([`f532ed2`](https://github.com/Bluetooth-Devices/habluetooth/commit/f532ed215b429f0bbd14dacc30f87c53f22af245))
## v3.3.2 (2024-08-20)
### Bug fixes
- Disable 3.13 wheels ([`9e8bbff`](https://github.com/Bluetooth-Devices/habluetooth/commit/9e8bbff6179e08bd6e05341ff48fff3adc5c6157))
## v3.3.1 (2024-08-20)
### Bug fixes
- Bump cibuildwheel to fix wheel builds ([`68d838a`](https://github.com/Bluetooth-Devices/habluetooth/commit/68d838a1d2adab9efe1fb5eba65e81b5dcc9a351))
## v3.3.0 (2024-08-20)
### Bug fixes
- Cleanup advertisementmonitor mapper ([`7d3483d`](https://github.com/Bluetooth-Devices/habluetooth/commit/7d3483d87d3e03c19cf528a1838acce5b194533e))
### Features
- Override devicefound and devicelost for passive monitoring ([`a802859`](https://github.com/Bluetooth-Devices/habluetooth/commit/a8028596bf3576a35750ae8575f173c75f918f28))
## v3.2.0 (2024-07-27)
### Features
- Small speed ups to scanner detection callback ([`7a5129a`](https://github.com/Bluetooth-Devices/habluetooth/commit/7a5129a40a12382c089453880210c41bb0f28a32))
## v3.1.3 (2024-06-24)
### Bug fixes
- Wheel builds ([`b9a8eec`](https://github.com/Bluetooth-Devices/habluetooth/commit/b9a8eec4f79c2098c0ec318b6b1ff7e3376febf2))
## v3.1.2 (2024-06-24)
### Bug fixes
- Fix license classifier ([`04aaaa1`](https://github.com/Bluetooth-Devices/habluetooth/commit/04aaaa186c755b869c8d75678f563f6a9c089829))
## v3.1.1 (2024-05-23)
### Bug fixes
- Missing classmethod decorator on find_device_by_address ([`aa08b13`](https://github.com/Bluetooth-Devices/habluetooth/commit/aa08b136660cddea7c356274c21f20b6d0eef1fa))
## v3.1.0 (2024-05-22)
### Features
- Speed up dispatching bleak callbacks ([`cbc8b26`](https://github.com/Bluetooth-Devices/habluetooth/commit/cbc8b26f90b9ea4f2a8569c0625b527dd37ef180))
## v3.0.1 (2024-05-03)
### Bug fixes
- Ensure lazy advertisement uses none when name is not present ([`c300f73`](https://github.com/Bluetooth-Devices/habluetooth/commit/c300f73ba82d3549ea4c156ef11023e9478c8b6c))
## v3.0.0 (2024-05-02)
### Features
- Make generation of advertisementdata lazy ([`25f8437`](https://github.com/Bluetooth-Devices/habluetooth/commit/25f843795927ad663a1d5ef1fa9472ec366b9da5))
## v2.8.1 (2024-05-02)
### Bug fixes
- Add missing find_device_by_address mapping ([`cc8e57e`](https://github.com/Bluetooth-Devices/habluetooth/commit/cc8e57eef7b97a6f2a30488a64d156cb5023c6c6))
## v2.8.0 (2024-04-17)
### Features
- Add support for recovering failed adapters after reboot ([`04948c3`](https://github.com/Bluetooth-Devices/habluetooth/commit/04948c337adf0f7b291e4e33618a7eae6dc4ebc2))
## v2.7.0 (2024-04-17)
### Features
- Improve fallback to passive mode when active mode fails ([`17ecc01`](https://github.com/Bluetooth-Devices/habluetooth/commit/17ecc012e096bec0113efea9ceb6a21bb50023fe))
## v2.6.0 (2024-04-17)
### Features
- Speed up stopping the scanner when its stuck setting up ([`bba8b51`](https://github.com/Bluetooth-Devices/habluetooth/commit/bba8b514490d98dca1020bbfefd9dc1e6a79af5f))
## v2.5.3 (2024-04-17)
### Bug fixes
- Ensure scanner is stopped on cancellation ([`a21d70a`](https://github.com/Bluetooth-Devices/habluetooth/commit/a21d70a1ac88135eade61c0abc8912c5b04a6b8b))
## v2.5.2 (2024-04-16)
### Bug fixes
- Ensure discovered_devices returns an empty list for offline scanners ([`2350543`](https://github.com/Bluetooth-Devices/habluetooth/commit/23505437c98529f692ab2dc0f5c3bdb5c9b7e3bd))
## v2.5.1 (2024-04-16)
### Bug fixes
- Wheel builds ([`5bd671a`](https://github.com/Bluetooth-Devices/habluetooth/commit/5bd671a159292dffe30a69639411926d0bc28123))
## v2.5.0 (2024-04-16)
### Features
- Fallback to passive scanning if active cannot start ([`3fae981`](https://github.com/Bluetooth-Devices/habluetooth/commit/3fae98162e6b0279375823a3b6e60ee51b87c1bb))
## v2.4.2 (2024-02-29)
### Bug fixes
- Android beacons in passive mode with flags 0x02 ([`8330e18`](https://github.com/Bluetooth-Devices/habluetooth/commit/8330e187550ec00ed415d3650a2c231921fb8ae7))
## v2.4.1 (2024-02-23)
### Bug fixes
- Avoid concurrent refreshes of adapters ([`d355b17`](https://github.com/Bluetooth-Devices/habluetooth/commit/d355b1768705706dec7062ad5d6267089d87a88e))
## v2.4.0 (2024-01-22)
### Features
- Improve error reporting resolution suggestions ([`afff5ba`](https://github.com/Bluetooth-Devices/habluetooth/commit/afff5ba4dfd8a5582174b367ae5ed9c9953b81e9))
## v2.3.1 (2024-01-22)
### Bug fixes
- Ensure unavailable callbacks can be removed from fired callbacks ([`65e7706`](https://github.com/Bluetooth-Devices/habluetooth/commit/65e7706ef4cdb99f9df5a00f666ab1d30e92e3b1))
## v2.3.0 (2024-01-22)
### Features
- Reduce overhead to remove callbacks by using sets to store callbacks ([`05ceb85`](https://github.com/Bluetooth-Devices/habluetooth/commit/05ceb85901b17f72988068997c7f39bc0179dca2))
## v2.2.0 (2024-01-14)
### Features
- Improve remote scanner performance ([`c549b1c`](https://github.com/Bluetooth-Devices/habluetooth/commit/c549b1cf9bbbda0c39dfce92d2888d5b990211da))
## v2.1.0 (2024-01-10)
### Features
- Add support for windows ([`788dd77`](https://github.com/Bluetooth-Devices/habluetooth/commit/788dd77ffac6664083821d5ba8b264725a3baaff))
## v2.0.2 (2024-01-04)
### Bug fixes
- Handle subclassed str in the client wrapper ([`f18a30e`](https://github.com/Bluetooth-Devices/habluetooth/commit/f18a30e48fe064993dc64f3af01c5d64b676a82f))
## v2.0.1 (2023-12-31)
### Bug fixes
- Switching scanners too quickly ([`bd53685`](https://github.com/Bluetooth-Devices/habluetooth/commit/bd536854457bd8b27f9e91921965b88b0ff798c3))
## v2.0.0 (2023-12-21)
### Features
- Simplify async_register_scanner by removing connectable argument ([`10ac6da`](https://github.com/Bluetooth-Devices/habluetooth/commit/10ac6da0672c121b5f0246ed688e98111adc7339))
## v1.0.0 (2023-12-12)
### Features
- Eliminate the need to pass the new_info_callback ([`65c54a6`](https://github.com/Bluetooth-Devices/habluetooth/commit/65c54a68500be6053677511ffd21ce3dca4b6991))
## v0.11.1 (2023-12-11)
### Bug fixes
- Do not schedule an expire when restoring devices ([`144cf15`](https://github.com/Bluetooth-Devices/habluetooth/commit/144cf15050a68cca66e7a2e24a5ddc7b87c32e41))
## v0.11.0 (2023-12-11)
### Features
- Relocate bluetoothserviceinfobleak ([`4f4f32d`](https://github.com/Bluetooth-Devices/habluetooth/commit/4f4f32d78d6abe21e28171f54ff5f3b17c8fb702))
## v0.10.0 (2023-12-07)
### Features
- Small speed ups to base_scanner ([`e1ff7e9`](https://github.com/Bluetooth-Devices/habluetooth/commit/e1ff7e9fb91a274b1a4bf6943a26e2a3f19780e7))
## v0.9.0 (2023-12-06)
### Features
- Speed up processing incoming service infos ([`55f6522`](https://github.com/Bluetooth-Devices/habluetooth/commit/55f6522ffc2adaf7e203ff4d2c1b13adc5d8c6a2))
## v0.8.0 (2023-12-06)
### Features
- Auto build the cythonized manager ([`c3441e5`](https://github.com/Bluetooth-Devices/habluetooth/commit/c3441e5095d62e6e70c2c879c4b5c109a87f463c))
- Add cython implementation for manager ([`266a602`](https://github.com/Bluetooth-Devices/habluetooth/commit/266a6022fb433ef9399f72e87b18b86897524784))
## v0.7.0 (2023-12-05)
### Features
- Port bluetooth manager from ha ([`757640a`](https://github.com/Bluetooth-Devices/habluetooth/commit/757640a7b7f60072588168501148ba750316f170))
## v0.6.1 (2023-12-04)
### Bug fixes
- Add missing cythonize for the adv tracker ([`8140195`](https://github.com/Bluetooth-Devices/habluetooth/commit/8140195a27ef83ea89ca643a5899d80839e574ae))
## v0.6.0 (2023-12-04)
### Features
- Port advertisement_tracker ([`378667b`](https://github.com/Bluetooth-Devices/habluetooth/commit/378667bce851b5076ee79ff223a72501c5575325))
## v0.5.1 (2023-12-04)
### Bug fixes
- Remove slots to keep hascanner patchable ([`d068f48`](https://github.com/Bluetooth-Devices/habluetooth/commit/d068f480d292619a1fc49a1256be98bdc6efadd6))
## v0.5.0 (2023-12-03)
### Features
- Port local scanner support from ha ([`1b1d0e4`](https://github.com/Bluetooth-Devices/habluetooth/commit/1b1d0e4bc17a44a1b20382da6ae28ea8e50e80b7))
## v0.4.0 (2023-12-03)
### Features
- Add more typing for incoming bluetooth data ([`de590e5`](https://github.com/Bluetooth-Devices/habluetooth/commit/de590e5c886801ff4a87f99c118be8855f337bd0))
## v0.3.0 (2023-12-03)
### Features
- Refactor to be able to use __pyx_pyobject_fastcall ([`e15074b`](https://github.com/Bluetooth-Devices/habluetooth/commit/e15074b172242f44f641e5232ebdf6297537a2b8))
- Add basic pxd ([`fd97d07`](https://github.com/Bluetooth-Devices/habluetooth/commit/fd97d07db7c0e8e0e877e1544fd0e392d14448b3))
## v0.2.0 (2023-12-03)
### Features
- Add cython pxd for base_scanner ([`0195710`](https://github.com/Bluetooth-Devices/habluetooth/commit/0195710bc25c8c3cc68b17a8f31cf281494fdc22))
## v0.1.0 (2023-12-03)
### Features
- Port base scanner from ha ([`e01a57b`](https://github.com/Bluetooth-Devices/habluetooth/commit/e01a57b6e0003ea8fe64b8e6e11ce09a35c1ada2))
## v0.0.1 (2023-12-02)
### Bug fixes
- Reserve name ([`5493984`](https://github.com/Bluetooth-Devices/habluetooth/commit/5493984483902039ca396498122e6094524bbae6))
Bluetooth-Devices-habluetooth-21accde/CONTRIBUTING.md 0000664 0000000 0000000 00000007432 15070247161 0022361 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-21accde/LICENSE 0000664 0000000 0000000 00000026121 15070247161 0021131 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-21accde/README.md 0000664 0000000 0000000 00000007604 15070247161 0021410 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-21accde/build_ext.py 0000664 0000000 0000000 00000003350 15070247161 0022454 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/base_scanner.py",
"src/habluetooth/manager.py",
"src/habluetooth/models.py",
"src/habluetooth/scanner.py",
"src/habluetooth/channels/bluez.py",
]
EXTENSIONS = [
Extension(
ext.removeprefix("src/").removesuffix(".py").replace("/", "."),
[ext],
language="c",
extra_compile_args=["-O3", "-g0"],
)
for ext in TO_CYTHONIZE
]
class BuildExt(build_ext):
"""Build extension."""
def build_extensions(self) -> None:
"""Build extensions."""
try:
super().build_extensions()
except Exception as ex: # nosec
_LOGGER.debug("Failed to build extensions: %s", ex, exc_info=True)
pass
def build(setup_kwargs: Any) -> None:
"""Build optional cython modules."""
if os.environ.get("SKIP_CYTHON", False):
return
try:
from Cython.Build import cythonize
setup_kwargs.update(
{
"ext_modules": cythonize(
EXTENSIONS,
compiler_directives={"language_level": "3"}, # Python 3
),
"cmdclass": {"build_ext": BuildExt},
}
)
setup_kwargs["exclude_package_data"] = {
pkg: ["*.c"] for pkg in setup_kwargs["packages"]
}
except Exception:
if os.environ.get("REQUIRE_CYTHON"):
raise
pass
Bluetooth-Devices-habluetooth-21accde/commitlint.config.mjs 0000664 0000000 0000000 00000000362 15070247161 0024261 0 ustar 00root root 0000000 0000000 export default {
extends: ["@commitlint/config-conventional"],
rules: {
"header-max-length": [0, "always", Infinity],
"body-max-line-length": [0, "always", Infinity],
"footer-max-line-length": [0, "always", Infinity],
},
};
Bluetooth-Devices-habluetooth-21accde/docs/ 0000775 0000000 0000000 00000000000 15070247161 0021052 5 ustar 00root root 0000000 0000000 Bluetooth-Devices-habluetooth-21accde/docs/Makefile 0000664 0000000 0000000 00000001372 15070247161 0022515 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-21accde/docs/_static/ 0000775 0000000 0000000 00000000000 15070247161 0022500 5 ustar 00root root 0000000 0000000 Bluetooth-Devices-habluetooth-21accde/docs/_static/.gitkeep 0000664 0000000 0000000 00000000000 15070247161 0024117 0 ustar 00root root 0000000 0000000 Bluetooth-Devices-habluetooth-21accde/docs/changelog.md 0000664 0000000 0000000 00000000060 15070247161 0023317 0 ustar 00root root 0000000 0000000 (changelog)=
```{include} ../CHANGELOG.md
```
Bluetooth-Devices-habluetooth-21accde/docs/conf.py 0000664 0000000 0000000 00000001217 15070247161 0022352 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 = "5.7.0"
# General configuration
extensions = [
"myst_parser",
]
# The suffix of source filenames.
source_suffix = [
".rst",
".md",
]
templates_path = [
"_templates",
]
exclude_patterns = [
"_build",
"Thumbs.db",
".DS_Store",
]
# Options for HTML output
html_theme = "furo"
html_static_path = ["_static"]
Bluetooth-Devices-habluetooth-21accde/docs/contributing.md 0000664 0000000 0000000 00000000066 15070247161 0024105 0 ustar 00root root 0000000 0000000 (contributing)=
```{include} ../CONTRIBUTING.md
```
Bluetooth-Devices-habluetooth-21accde/docs/index.md 0000664 0000000 0000000 00000000350 15070247161 0022501 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-21accde/docs/installation.md 0000664 0000000 0000000 00000000415 15070247161 0024075 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-21accde/docs/make.bat 0000664 0000000 0000000 00000001375 15070247161 0022465 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-21accde/docs/usage.md 0000664 0000000 0000000 00000000326 15070247161 0022501 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-21accde/examples/ 0000775 0000000 0000000 00000000000 15070247161 0021740 5 ustar 00root root 0000000 0000000 Bluetooth-Devices-habluetooth-21accde/examples/bluez_api.py 0000664 0000000 0000000 00000002440 15070247161 0024264 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-21accde/poetry.lock 0000664 0000000 0000000 00000555126 15070247161 0022334 0 ustar 00root root 0000000 0000000 # This file is automatically @generated by Poetry 2.2.0 and should not be changed by hand.
[[package]]
name = "accessible-pygments"
version = "0.0.5"
description = "A collection of accessible pygments styles"
optional = false
python-versions = ">=3.9"
groups = ["docs"]
files = [
{file = "accessible_pygments-0.0.5-py3-none-any.whl", hash = "sha256:88ae3211e68a1d0b011504b2ffc1691feafce124b845bd072ab6f9f66f34d4b7"},
{file = "accessible_pygments-0.0.5.tar.gz", hash = "sha256:40918d3e6a2b619ad424cb91e556bd3bd8865443d9f22f1dcdf79e33c8046872"},
]
[package.dependencies]
pygments = ">=1.5"
[package.extras]
dev = ["pillow", "pkginfo (>=1.10)", "playwright", "pre-commit", "setuptools", "twine (>=5.0)"]
tests = ["hypothesis", "pytest"]
[[package]]
name = "aiooui"
version = "0.1.9"
description = "Async OUI lookups"
optional = false
python-versions = "<4.0,>=3.9"
groups = ["main"]
files = [
{file = "aiooui-0.1.9-cp310-cp310-manylinux_2_31_x86_64.whl", hash = "sha256:64d904b43f14dd1d8d9fcf1684d9e2f558bc5e0bd68dc10023c93355c9027907"},
{file = "aiooui-0.1.9-py3-none-any.whl", hash = "sha256:737a5e62d8726540218c2b70e5f966d9912121e4644f3d490daf8f3c18b182e5"},
{file = "aiooui-0.1.9.tar.gz", hash = "sha256:e8c8bc59ab352419e0747628b4cce7c4e04d492574c1971e223401126389c5d8"},
]
[[package]]
name = "alabaster"
version = "1.0.0"
description = "A light, configurable Sphinx theme"
optional = false
python-versions = ">=3.10"
groups = ["docs"]
files = [
{file = "alabaster-1.0.0-py3-none-any.whl", hash = "sha256:fc6786402dc3fcb2de3cabd5fe455a2db534b371124f1f21de8731783dec828b"},
{file = "alabaster-1.0.0.tar.gz", hash = "sha256:c00dca57bca26fa62a6d7d0a9fcce65f3e026e9bfe33e9c538fd3fbb2144fd9e"},
]
[[package]]
name = "anyio"
version = "4.9.0"
description = "High level compatibility layer for multiple asynchronous event loop implementations"
optional = false
python-versions = ">=3.9"
groups = ["docs"]
files = [
{file = "anyio-4.9.0-py3-none-any.whl", hash = "sha256:9f76d541cad6e36af7beb62e978876f3b41e3e04f2c1fbf0884604c0a9c4d93c"},
{file = "anyio-4.9.0.tar.gz", hash = "sha256:673c0c244e15788651a4ff38710fea9675823028a6f08a5eda409e0c9840a028"},
]
[package.dependencies]
idna = ">=2.8"
sniffio = ">=1.1"
typing_extensions = {version = ">=4.5", markers = "python_version < \"3.13\""}
[package.extras]
doc = ["Sphinx (>=8.2,<9.0)", "packaging", "sphinx-autodoc-typehints (>=1.2.0)", "sphinx_rtd_theme"]
test = ["anyio[trio]", "blockbuster (>=1.5.23)", "coverage[toml] (>=7)", "exceptiongroup (>=1.2.0)", "hypothesis (>=4.0)", "psutil (>=5.9)", "pytest (>=7.0)", "trustme", "truststore (>=0.9.1) ; python_version >= \"3.10\"", "uvloop (>=0.21) ; platform_python_implementation == \"CPython\" and platform_system != \"Windows\" and python_version < \"3.14\""]
trio = ["trio (>=0.26.1)"]
[[package]]
name = "async-interrupt"
version = "1.2.2"
description = "Context manager to raise an exception when a future is done"
optional = false
python-versions = ">=3.9"
groups = ["main"]
files = [
{file = "async_interrupt-1.2.2-py3-none-any.whl", hash = "sha256:0a8deb884acfb5fe55188a693ae8a4381bbbd2cb6e670dac83869489513eec2c"},
{file = "async_interrupt-1.2.2.tar.gz", hash = "sha256:be4331a029b8625777905376a6dc1370984c8c810f30b79703f3ee039d262bf7"},
]
[[package]]
name = "babel"
version = "2.17.0"
description = "Internationalization utilities"
optional = false
python-versions = ">=3.8"
groups = ["docs"]
files = [
{file = "babel-2.17.0-py3-none-any.whl", hash = "sha256:4d0b53093fdfb4b21c92b5213dba5a1b23885afa8383709427046b21c366e5f2"},
{file = "babel-2.17.0.tar.gz", hash = "sha256:0c54cffb19f690cdcc52a3b50bcbf71e07a808d1c80d549f2459b9d2cf0afb9d"},
]
[package.extras]
dev = ["backports.zoneinfo ; python_version < \"3.9\"", "freezegun (>=1.0,<2.0)", "jinja2 (>=3.0)", "pytest (>=6.0)", "pytest-cov", "pytz", "setuptools", "tzdata ; sys_platform == \"win32\""]
[[package]]
name = "beautifulsoup4"
version = "4.13.4"
description = "Screen-scraping library"
optional = false
python-versions = ">=3.7.0"
groups = ["docs"]
files = [
{file = "beautifulsoup4-4.13.4-py3-none-any.whl", hash = "sha256:9bbbb14bfde9d79f38b8cd5f8c7c85f4b8f2523190ebed90e950a8dea4cb1c4b"},
{file = "beautifulsoup4-4.13.4.tar.gz", hash = "sha256:dbb3c4e1ceae6aefebdaf2423247260cd062430a410e38c66f2baa50a8437195"},
]
[package.dependencies]
soupsieve = ">1.2"
typing-extensions = ">=4.0.0"
[package.extras]
cchardet = ["cchardet"]
chardet = ["chardet"]
charset-normalizer = ["charset-normalizer"]
html5lib = ["html5lib"]
lxml = ["lxml"]
[[package]]
name = "bleak"
version = "1.1.1"
description = "Bluetooth Low Energy platform Agnostic Klient"
optional = false
python-versions = ">=3.9"
groups = ["main"]
files = [
{file = "bleak-1.1.1-py3-none-any.whl", hash = "sha256:e601371396e357d95ee3c256db65b7da624c94ef6f051d47dfce93ea8361c22e"},
{file = "bleak-1.1.1.tar.gz", hash = "sha256:eeef18053eb3bd569a25bff62cd4eb9ee56be4d84f5321023a7c4920943e6ccb"},
]
[package.dependencies]
dbus-fast = {version = ">=1.83.0", markers = "platform_system == \"Linux\""}
pyobjc-core = {version = ">=10.3", markers = "platform_system == \"Darwin\""}
pyobjc-framework-CoreBluetooth = {version = ">=10.3", markers = "platform_system == \"Darwin\""}
pyobjc-framework-libdispatch = {version = ">=10.3", markers = "platform_system == \"Darwin\""}
typing-extensions = {version = ">=4.7.0", markers = "python_version < \"3.12\""}
winrt-runtime = {version = ">=3.1", markers = "platform_system == \"Windows\""}
"winrt-Windows.Devices.Bluetooth" = {version = ">=3.1", markers = "platform_system == \"Windows\""}
"winrt-Windows.Devices.Bluetooth.Advertisement" = {version = ">=3.1", markers = "platform_system == \"Windows\""}
"winrt-Windows.Devices.Bluetooth.GenericAttributeProfile" = {version = ">=3.1", markers = "platform_system == \"Windows\""}
"winrt-Windows.Devices.Enumeration" = {version = ">=3.1", markers = "platform_system == \"Windows\""}
"winrt-Windows.Foundation" = {version = ">=3.1", markers = "platform_system == \"Windows\""}
"winrt-Windows.Foundation.Collections" = {version = ">=3.1", markers = "platform_system == \"Windows\""}
"winrt-Windows.Storage.Streams" = {version = ">=3.1", markers = "platform_system == \"Windows\""}
[package.extras]
pythonista = ["bleak-pythonista (>=0.1.1)"]
[[package]]
name = "bleak-retry-connector"
version = "4.4.3"
description = "A connector for Bleak Clients that handles transient connection failures"
optional = false
python-versions = ">=3.10"
groups = ["main"]
files = [
{file = "bleak_retry_connector-4.4.3-py3-none-any.whl", hash = "sha256:17a478d525706488973b181fc789e960bc3fb4bcd94ccb0eee7b7b682442577b"},
{file = "bleak_retry_connector-4.4.3.tar.gz", hash = "sha256:70aa305dbd26eaf0586dd24723daac93ee3dd6a465e9782bf02b711fcbc4a527"},
]
[package.dependencies]
bleak = {version = ">=1", markers = "python_version >= \"3.10\" and python_version < \"3.14\""}
bluetooth-adapters = {version = ">=0.15.2", markers = "python_version >= \"3.10\" and python_version < \"3.14\" and platform_system == \"Linux\""}
dbus-fast = {version = ">=1.14.0", markers = "platform_system == \"Linux\""}
[[package]]
name = "bluetooth-adapters"
version = "2.1.1"
description = "Tools to enumerate and find Bluetooth Adapters"
optional = false
python-versions = ">=3.9"
groups = ["main"]
files = [
{file = "bluetooth_adapters-2.1.1-py3-none-any.whl", hash = "sha256:1f93026e530dcb2f4515a92955fa6f85934f928b009a181ee57edc8b4affd25c"},
{file = "bluetooth_adapters-2.1.1.tar.gz", hash = "sha256:f289e0f08814f74252a28862f488283680584744430d7eac45820f9c20ba041a"},
]
[package.dependencies]
aiooui = ">=0.1.1"
bleak = ">=1"
dbus-fast = {version = ">=1.21.0", markers = "platform_system == \"Linux\""}
uart-devices = ">=0.1.0"
usb-devices = ">=0.4.5"
[package.extras]
docs = ["Sphinx (>=5,<8)", "myst-parser (>=0.18,<3.1)", "sphinx-rtd-theme (>=1,<4)"]
[[package]]
name = "bluetooth-auto-recovery"
version = "1.5.3"
description = "Recover bluetooth adapters that are in an stuck state"
optional = false
python-versions = ">=3.9"
groups = ["main"]
files = [
{file = "bluetooth_auto_recovery-1.5.3-py3-none-any.whl", hash = "sha256:5d66b859a54ef20fdf1bd3cf6762f153e86651babe716836770da9d9c47b01c4"},
{file = "bluetooth_auto_recovery-1.5.3.tar.gz", hash = "sha256:0b36aa6be84474fff81c1ce328f016a6553272ac47050b1fa60f03e36a8db46d"},
]
[package.dependencies]
bluetooth-adapters = ">=0.16.0"
btsocket = ">=0.2.0"
PyRIC = ">=0.1.6.3"
usb-devices = ">=0.4.1"
[package.extras]
docs = ["Sphinx (>=5,<8)", "myst-parser (>=0.18,<3.1)", "sphinx-rtd-theme (>=1,<4)"]
[[package]]
name = "bluetooth-data-tools"
version = "1.28.2"
description = "Tools for converting bluetooth data and packets"
optional = false
python-versions = ">=3.10"
groups = ["main"]
files = [
{file = "bluetooth_data_tools-1.28.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:59de191b17a6d7ab23f00b375638667556424c53f08efd288fc9694cf347edb8"},
{file = "bluetooth_data_tools-1.28.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:12498ee9387680205aaa856f964d70517a1ac2d4d64bbb78c35cb1f152137bf1"},
{file = "bluetooth_data_tools-1.28.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9a559839fc1fd5f84439608b3df598ed583354a671306ff2cd4ef9e667cba855"},
{file = "bluetooth_data_tools-1.28.2-cp310-cp310-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:191b8e40b35cfd4201f01d7638bd9e845cb42479a6cc6943fd3cbcd2c978d434"},
{file = "bluetooth_data_tools-1.28.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:52b7bb1268d5e56a658efd666242a0b7b407f460a3257512b77a2fd3ed0bd9f7"},
{file = "bluetooth_data_tools-1.28.2-cp310-cp310-manylinux_2_31_armv7l.manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:04d2d0310eae1577554edf2b3306f9ed18b9621edff57e153204430cfa83a541"},
{file = "bluetooth_data_tools-1.28.2-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:82283c317e465dd998af4ddb5553f79992213b4c1055d0ed1977e6786a5401d8"},
{file = "bluetooth_data_tools-1.28.2-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:d26a2398886c37771e35fa4f2c36e965da771fe3e984e76c078a98bcc5eb3733"},
{file = "bluetooth_data_tools-1.28.2-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:ab975c1567aa3fd20e787c8ddc9d55984d0883a251c7d65c55dc3a173f9ef796"},
{file = "bluetooth_data_tools-1.28.2-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:0c8454962e005d48e23af3fe240235e16c81590805dfe0b27b08b82337df16a8"},
{file = "bluetooth_data_tools-1.28.2-cp310-cp310-win32.whl", hash = "sha256:8e492b786ac561f628bf964f522d669259b608d1f434102d003afdc46dcab473"},
{file = "bluetooth_data_tools-1.28.2-cp310-cp310-win_amd64.whl", hash = "sha256:567dae42c2e1d7da5aeca1ef181bd660db8fc2f3fe3af7e999e82a030a1a422d"},
{file = "bluetooth_data_tools-1.28.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:4042b36f1c50bcdc0afb6be78a0bac11a8be6a73a3284825502ce6e82661423e"},
{file = "bluetooth_data_tools-1.28.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:728cfb9fd366b01eec9eaa226353cdd43e099288974f6e9273dc90a577fc970a"},
{file = "bluetooth_data_tools-1.28.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2e3a2c9fba041b8486f1a4d4098b40241168a2d79ab0f5cdbccafadac7e41747"},
{file = "bluetooth_data_tools-1.28.2-cp311-cp311-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:5d3cd8bb926742f01ced40b635a860c1cf5a6b82c3ce88e8431ee77e1062b7d6"},
{file = "bluetooth_data_tools-1.28.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a791a07d60ae70e2fc174688abd1e28fdc54a0c337b6306ce6c6a66c06e41db7"},
{file = "bluetooth_data_tools-1.28.2-cp311-cp311-manylinux_2_31_armv7l.manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:d2049946c4a489db86c7f5cc32ef50894952244be07a5919a58f8b4b03f742b1"},
{file = "bluetooth_data_tools-1.28.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:b3344421a012641f21ffbc7e3d5955dbc19150ad48d800b4b61a7a2cb4ee0d87"},
{file = "bluetooth_data_tools-1.28.2-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:9e068aa7b0c147f8bae735298c041058693e8ee3763d769ca6cbd938210ed715"},
{file = "bluetooth_data_tools-1.28.2-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:0eed475d3dfe8af519348047f150b52dcaa5aa84c5a5bae023a6df3005fe95b1"},
{file = "bluetooth_data_tools-1.28.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:dfffdaab077173886c64c5c89329d00c7140302fd7f537e3f7bd00d8b851c9d8"},
{file = "bluetooth_data_tools-1.28.2-cp311-cp311-win32.whl", hash = "sha256:02045a0dc566122f3e30e4ac5861f35c5749cd52eda4abd5a3918c27bfff28fb"},
{file = "bluetooth_data_tools-1.28.2-cp311-cp311-win_amd64.whl", hash = "sha256:ed619bb4fd4a3794c9c11c140c59a727bb1f63d0888e135da8382d2dbc8c18f9"},
{file = "bluetooth_data_tools-1.28.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:a8eef61057a754293e2ca54e153ba804bc313257881bd8360de091524b3dffc0"},
{file = "bluetooth_data_tools-1.28.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:c1567e45ed6b4987db1e70e7f59d7499a57f8eec2e4a38131eeedd8c5b52bc81"},
{file = "bluetooth_data_tools-1.28.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:094a6a4aeaf82a6e939318f716294fa7d65a8f025f8b10471e2145320a57b412"},
{file = "bluetooth_data_tools-1.28.2-cp312-cp312-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:59398ab013e33539a08cc0dcf19f0fc4a7dc6311d5dc5bfb7af03906fc3a902f"},
{file = "bluetooth_data_tools-1.28.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:010564351a0b269371b5f5dc4ed1084b9524028f106cce49208db5b1bc236e84"},
{file = "bluetooth_data_tools-1.28.2-cp312-cp312-manylinux_2_31_armv7l.manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:639f89a57936fb65a881ea7dd60dc6d8bc5b859e740d2c4e699f28f28f3da992"},
{file = "bluetooth_data_tools-1.28.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:60dac54c9fe308a9dc4b7bc5c94d174c6e93931ac27cdfba8b2cd5c19d73f126"},
{file = "bluetooth_data_tools-1.28.2-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:50493246908a5aaa305d00df86a83d915a00cc1a783e05d6666ea3ff293cc137"},
{file = "bluetooth_data_tools-1.28.2-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:7fcbf974dc3a7d1a00cebacc424dcf1d18d4eac4bccecba1d8ff5a1e8eb0b348"},
{file = "bluetooth_data_tools-1.28.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:0a5e9eb874c9823d330dd0eadca9176a8a713395908f865c19156a233da7b5c2"},
{file = "bluetooth_data_tools-1.28.2-cp312-cp312-win32.whl", hash = "sha256:d3346ef76577f5060955a80c46ae16004f76673d844f8c44a416bd8177d6695c"},
{file = "bluetooth_data_tools-1.28.2-cp312-cp312-win_amd64.whl", hash = "sha256:872c31ba9042ed614aad459fd0358893554c67e2fa5c2d78d525cd96af3e31da"},
{file = "bluetooth_data_tools-1.28.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:71df3e6221ee472cb38fd625cecc6e0a8733e093e40c08e80638e9387349b43b"},
{file = "bluetooth_data_tools-1.28.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:b2925335caf40bb9872a8733d823bb8e97bac2bc7ce988a695452e4a39507e29"},
{file = "bluetooth_data_tools-1.28.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:535c037b3ccd86a5df890b338b901eea3e974692ae07b591c1f99e787d629170"},
{file = "bluetooth_data_tools-1.28.2-cp313-cp313-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:080668765dc7d04d6b78a7bc0feaffd14b45ccee58b5c005a22b78e3730934fd"},
{file = "bluetooth_data_tools-1.28.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:98c2947f86112fc308973df735f030ede800473dd61f9e32d62d55bfb5c00748"},
{file = "bluetooth_data_tools-1.28.2-cp313-cp313-manylinux_2_31_armv7l.manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:d74c6b9187b444e548cd01ce56c74eb0c1ba592043b9a1f48a9c2ed19a8a236a"},
{file = "bluetooth_data_tools-1.28.2-cp313-cp313-manylinux_2_36_x86_64.whl", hash = "sha256:ad09f0dbc343e51c34f32672aa877373d747eebe956c640117ce9472c86f1cb2"},
{file = "bluetooth_data_tools-1.28.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:c833481774fe319ef239351bb8a028cc2efe44ad7cf23681bd2cd2a4dfb71599"},
{file = "bluetooth_data_tools-1.28.2-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:a989a4a5e8e4d70410fd9bba7b03f970bed7b8f79531087565931314437420be"},
{file = "bluetooth_data_tools-1.28.2-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:6f30e619ca3b46716a7f8c2bde35776d36e6b98e1922f0642034618e1056b3b3"},
{file = "bluetooth_data_tools-1.28.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:cf3714c9e27aaa7db0800816bf766919cd1ac18080bac0102c2ad466db02f47a"},
{file = "bluetooth_data_tools-1.28.2-cp313-cp313-win32.whl", hash = "sha256:8f28eeee5fecaebeb9fc1012e4220bc3c1ee6ee82bf8a17b9183995933f6d938"},
{file = "bluetooth_data_tools-1.28.2-cp313-cp313-win_amd64.whl", hash = "sha256:e748587be85a8133b0a43e34e2c6f65dbf5113765a03d4f89c26039b8289decb"},
{file = "bluetooth_data_tools-1.28.2.tar.gz", hash = "sha256:2afa97695fc61c8d55d19ffa9485a498051410f399a183852d1bf29f675c3537"},
]
[package.dependencies]
cryptography = ">=41.0.3"
[package.extras]
docs = ["Sphinx (>=5,<9)", "myst-parser (>=0.18,<4.1)", "sphinx-rtd-theme (>=1,<4)"]
[[package]]
name = "btsocket"
version = "0.3.0"
description = "Python library for BlueZ Bluetooth Management API"
optional = false
python-versions = "*"
groups = ["main"]
files = [
{file = "btsocket-0.3.0-py2.py3-none-any.whl", hash = "sha256:949821c1b580a88e73804ad610f5173d6ae258e7b4e389da4f94d614344f1a9c"},
{file = "btsocket-0.3.0.tar.gz", hash = "sha256:7ea495de0ff883f0d9f8eea59c72ca7fed492994df668fe476b84d814a147a0d"},
]
[package.extras]
dev = ["bumpversion", "coverage", "pycodestyle", "pygments", "sphinx", "sphinx-rtd-theme", "twine"]
docs = ["pygments", "sphinx", "sphinx-rtd-theme"]
rel = ["bumpversion", "twine"]
test = ["coverage", "pycodestyle"]
[[package]]
name = "certifi"
version = "2025.6.15"
description = "Python package for providing Mozilla's CA Bundle."
optional = false
python-versions = ">=3.7"
groups = ["docs"]
files = [
{file = "certifi-2025.6.15-py3-none-any.whl", hash = "sha256:2e0c7ce7cb5d8f8634ca55d2ba7e6ec2689a2fd6537d8dec1296a477a4910057"},
{file = "certifi-2025.6.15.tar.gz", hash = "sha256:d747aa5a8b9bbbb1bb8c22bb13e22bd1f18e9796defa16bab421f7f7a317323b"},
]
[[package]]
name = "cffi"
version = "1.17.1"
description = "Foreign Function Interface for Python calling C code."
optional = false
python-versions = ">=3.8"
groups = ["main", "dev"]
files = [
{file = "cffi-1.17.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:df8b1c11f177bc2313ec4b2d46baec87a5f3e71fc8b45dab2ee7cae86d9aba14"},
{file = "cffi-1.17.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:8f2cdc858323644ab277e9bb925ad72ae0e67f69e804f4898c070998d50b1a67"},
{file = "cffi-1.17.1-cp310-cp310-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:edae79245293e15384b51f88b00613ba9f7198016a5948b5dddf4917d4d26382"},
{file = "cffi-1.17.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:45398b671ac6d70e67da8e4224a065cec6a93541bb7aebe1b198a61b58c7b702"},
{file = "cffi-1.17.1-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ad9413ccdeda48c5afdae7e4fa2192157e991ff761e7ab8fdd8926f40b160cc3"},
{file = "cffi-1.17.1-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:5da5719280082ac6bd9aa7becb3938dc9f9cbd57fac7d2871717b1feb0902ab6"},
{file = "cffi-1.17.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2bb1a08b8008b281856e5971307cc386a8e9c5b625ac297e853d36da6efe9c17"},
{file = "cffi-1.17.1-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:045d61c734659cc045141be4bae381a41d89b741f795af1dd018bfb532fd0df8"},
{file = "cffi-1.17.1-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:6883e737d7d9e4899a8a695e00ec36bd4e5e4f18fabe0aca0efe0a4b44cdb13e"},
{file = "cffi-1.17.1-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:6b8b4a92e1c65048ff98cfe1f735ef8f1ceb72e3d5f0c25fdb12087a23da22be"},
{file = "cffi-1.17.1-cp310-cp310-win32.whl", hash = "sha256:c9c3d058ebabb74db66e431095118094d06abf53284d9c81f27300d0e0d8bc7c"},
{file = "cffi-1.17.1-cp310-cp310-win_amd64.whl", hash = "sha256:0f048dcf80db46f0098ccac01132761580d28e28bc0f78ae0d58048063317e15"},
{file = "cffi-1.17.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:a45e3c6913c5b87b3ff120dcdc03f6131fa0065027d0ed7ee6190736a74cd401"},
{file = "cffi-1.17.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:30c5e0cb5ae493c04c8b42916e52ca38079f1b235c2f8ae5f4527b963c401caf"},
{file = "cffi-1.17.1-cp311-cp311-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f75c7ab1f9e4aca5414ed4d8e5c0e303a34f4421f8a0d47a4d019ceff0ab6af4"},
{file = "cffi-1.17.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a1ed2dd2972641495a3ec98445e09766f077aee98a1c896dcb4ad0d303628e41"},
{file = "cffi-1.17.1-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:46bf43160c1a35f7ec506d254e5c890f3c03648a4dbac12d624e4490a7046cd1"},
{file = "cffi-1.17.1-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a24ed04c8ffd54b0729c07cee15a81d964e6fee0e3d4d342a27b020d22959dc6"},
{file = "cffi-1.17.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:610faea79c43e44c71e1ec53a554553fa22321b65fae24889706c0a84d4ad86d"},
{file = "cffi-1.17.1-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:a9b15d491f3ad5d692e11f6b71f7857e7835eb677955c00cc0aefcd0669adaf6"},
{file = "cffi-1.17.1-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:de2ea4b5833625383e464549fec1bc395c1bdeeb5f25c4a3a82b5a8c756ec22f"},
{file = "cffi-1.17.1-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:fc48c783f9c87e60831201f2cce7f3b2e4846bf4d8728eabe54d60700b318a0b"},
{file = "cffi-1.17.1-cp311-cp311-win32.whl", hash = "sha256:85a950a4ac9c359340d5963966e3e0a94a676bd6245a4b55bc43949eee26a655"},
{file = "cffi-1.17.1-cp311-cp311-win_amd64.whl", hash = "sha256:caaf0640ef5f5517f49bc275eca1406b0ffa6aa184892812030f04c2abf589a0"},
{file = "cffi-1.17.1-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:805b4371bf7197c329fcb3ead37e710d1bca9da5d583f5073b799d5c5bd1eee4"},
{file = "cffi-1.17.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:733e99bc2df47476e3848417c5a4540522f234dfd4ef3ab7fafdf555b082ec0c"},
{file = "cffi-1.17.1-cp312-cp312-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1257bdabf294dceb59f5e70c64a3e2f462c30c7ad68092d01bbbfb1c16b1ba36"},
{file = "cffi-1.17.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:da95af8214998d77a98cc14e3a3bd00aa191526343078b530ceb0bd710fb48a5"},
{file = "cffi-1.17.1-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d63afe322132c194cf832bfec0dc69a99fb9bb6bbd550f161a49e9e855cc78ff"},
{file = "cffi-1.17.1-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f79fc4fc25f1c8698ff97788206bb3c2598949bfe0fef03d299eb1b5356ada99"},
{file = "cffi-1.17.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b62ce867176a75d03a665bad002af8e6d54644fad99a3c70905c543130e39d93"},
{file = "cffi-1.17.1-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:386c8bf53c502fff58903061338ce4f4950cbdcb23e2902d86c0f722b786bbe3"},
{file = "cffi-1.17.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:4ceb10419a9adf4460ea14cfd6bc43d08701f0835e979bf821052f1805850fe8"},
{file = "cffi-1.17.1-cp312-cp312-win32.whl", hash = "sha256:a08d7e755f8ed21095a310a693525137cfe756ce62d066e53f502a83dc550f65"},
{file = "cffi-1.17.1-cp312-cp312-win_amd64.whl", hash = "sha256:51392eae71afec0d0c8fb1a53b204dbb3bcabcb3c9b807eedf3e1e6ccf2de903"},
{file = "cffi-1.17.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:f3a2b4222ce6b60e2e8b337bb9596923045681d71e5a082783484d845390938e"},
{file = "cffi-1.17.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:0984a4925a435b1da406122d4d7968dd861c1385afe3b45ba82b750f229811e2"},
{file = "cffi-1.17.1-cp313-cp313-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d01b12eeeb4427d3110de311e1774046ad344f5b1a7403101878976ecd7a10f3"},
{file = "cffi-1.17.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:706510fe141c86a69c8ddc029c7910003a17353970cff3b904ff0686a5927683"},
{file = "cffi-1.17.1-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:de55b766c7aa2e2a3092c51e0483d700341182f08e67c63630d5b6f200bb28e5"},
{file = "cffi-1.17.1-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c59d6e989d07460165cc5ad3c61f9fd8f1b4796eacbd81cee78957842b834af4"},
{file = "cffi-1.17.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dd398dbc6773384a17fe0d3e7eeb8d1a21c2200473ee6806bb5e6a8e62bb73dd"},
{file = "cffi-1.17.1-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:3edc8d958eb099c634dace3c7e16560ae474aa3803a5df240542b305d14e14ed"},
{file = "cffi-1.17.1-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:72e72408cad3d5419375fc87d289076ee319835bdfa2caad331e377589aebba9"},
{file = "cffi-1.17.1-cp313-cp313-win32.whl", hash = "sha256:e03eab0a8677fa80d646b5ddece1cbeaf556c313dcfac435ba11f107ba117b5d"},
{file = "cffi-1.17.1-cp313-cp313-win_amd64.whl", hash = "sha256:f6a16c31041f09ead72d69f583767292f750d24913dadacf5756b966aacb3f1a"},
{file = "cffi-1.17.1-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:636062ea65bd0195bc012fea9321aca499c0504409f413dc88af450b57ffd03b"},
{file = "cffi-1.17.1-cp38-cp38-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c7eac2ef9b63c79431bc4b25f1cd649d7f061a28808cbc6c47b534bd789ef964"},
{file = "cffi-1.17.1-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e221cf152cff04059d011ee126477f0d9588303eb57e88923578ace7baad17f9"},
{file = "cffi-1.17.1-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:31000ec67d4221a71bd3f67df918b1f88f676f1c3b535a7eb473255fdc0b83fc"},
{file = "cffi-1.17.1-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:6f17be4345073b0a7b8ea599688f692ac3ef23ce28e5df79c04de519dbc4912c"},
{file = "cffi-1.17.1-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0e2b1fac190ae3ebfe37b979cc1ce69c81f4e4fe5746bb401dca63a9062cdaf1"},
{file = "cffi-1.17.1-cp38-cp38-win32.whl", hash = "sha256:7596d6620d3fa590f677e9ee430df2958d2d6d6de2feeae5b20e82c00b76fbf8"},
{file = "cffi-1.17.1-cp38-cp38-win_amd64.whl", hash = "sha256:78122be759c3f8a014ce010908ae03364d00a1f81ab5c7f4a7a5120607ea56e1"},
{file = "cffi-1.17.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:b2ab587605f4ba0bf81dc0cb08a41bd1c0a5906bd59243d56bad7668a6fc6c16"},
{file = "cffi-1.17.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:28b16024becceed8c6dfbc75629e27788d8a3f9030691a1dbf9821a128b22c36"},
{file = "cffi-1.17.1-cp39-cp39-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1d599671f396c4723d016dbddb72fe8e0397082b0a77a4fab8028923bec050e8"},
{file = "cffi-1.17.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ca74b8dbe6e8e8263c0ffd60277de77dcee6c837a3d0881d8c1ead7268c9e576"},
{file = "cffi-1.17.1-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f7f5baafcc48261359e14bcd6d9bff6d4b28d9103847c9e136694cb0501aef87"},
{file = "cffi-1.17.1-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:98e3969bcff97cae1b2def8ba499ea3d6f31ddfdb7635374834cf89a1a08ecf0"},
{file = "cffi-1.17.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:cdf5ce3acdfd1661132f2a9c19cac174758dc2352bfe37d98aa7512c6b7178b3"},
{file = "cffi-1.17.1-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:9755e4345d1ec879e3849e62222a18c7174d65a6a92d5b346b1863912168b595"},
{file = "cffi-1.17.1-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:f1e22e8c4419538cb197e4dd60acc919d7696e5ef98ee4da4e01d3f8cfa4cc5a"},
{file = "cffi-1.17.1-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:c03e868a0b3bc35839ba98e74211ed2b05d2119be4e8a0f224fba9384f1fe02e"},
{file = "cffi-1.17.1-cp39-cp39-win32.whl", hash = "sha256:e31ae45bc2e29f6b2abd0de1cc3b9d5205aa847cafaecb8af1476a609a2f6eb7"},
{file = "cffi-1.17.1-cp39-cp39-win_amd64.whl", hash = "sha256:d016c76bdd850f3c626af19b0542c9677ba156e4ee4fccfdd7848803533ef662"},
{file = "cffi-1.17.1.tar.gz", hash = "sha256:1c39c6016c32bc48dd54561950ebd6836e1670f2ae46128f67cf49e789c52824"},
]
markers = {main = "platform_python_implementation != \"PyPy\""}
[package.dependencies]
pycparser = "*"
[[package]]
name = "charset-normalizer"
version = "3.4.2"
description = "The Real First Universal Charset Detector. Open, modern and actively maintained alternative to Chardet."
optional = false
python-versions = ">=3.7"
groups = ["docs"]
files = [
{file = "charset_normalizer-3.4.2-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:7c48ed483eb946e6c04ccbe02c6b4d1d48e51944b6db70f697e089c193404941"},
{file = "charset_normalizer-3.4.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b2d318c11350e10662026ad0eb71bb51c7812fc8590825304ae0bdd4ac283acd"},
{file = "charset_normalizer-3.4.2-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9cbfacf36cb0ec2897ce0ebc5d08ca44213af24265bd56eca54bee7923c48fd6"},
{file = "charset_normalizer-3.4.2-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:18dd2e350387c87dabe711b86f83c9c78af772c748904d372ade190b5c7c9d4d"},
{file = "charset_normalizer-3.4.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8075c35cd58273fee266c58c0c9b670947c19df5fb98e7b66710e04ad4e9ff86"},
{file = "charset_normalizer-3.4.2-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:5bf4545e3b962767e5c06fe1738f951f77d27967cb2caa64c28be7c4563e162c"},
{file = "charset_normalizer-3.4.2-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:7a6ab32f7210554a96cd9e33abe3ddd86732beeafc7a28e9955cdf22ffadbab0"},
{file = "charset_normalizer-3.4.2-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:b33de11b92e9f75a2b545d6e9b6f37e398d86c3e9e9653c4864eb7e89c5773ef"},
{file = "charset_normalizer-3.4.2-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:8755483f3c00d6c9a77f490c17e6ab0c8729e39e6390328e42521ef175380ae6"},
{file = "charset_normalizer-3.4.2-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:68a328e5f55ec37c57f19ebb1fdc56a248db2e3e9ad769919a58672958e8f366"},
{file = "charset_normalizer-3.4.2-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:21b2899062867b0e1fde9b724f8aecb1af14f2778d69aacd1a5a1853a597a5db"},
{file = "charset_normalizer-3.4.2-cp310-cp310-win32.whl", hash = "sha256:e8082b26888e2f8b36a042a58307d5b917ef2b1cacab921ad3323ef91901c71a"},
{file = "charset_normalizer-3.4.2-cp310-cp310-win_amd64.whl", hash = "sha256:f69a27e45c43520f5487f27627059b64aaf160415589230992cec34c5e18a509"},
{file = "charset_normalizer-3.4.2-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:be1e352acbe3c78727a16a455126d9ff83ea2dfdcbc83148d2982305a04714c2"},
{file = "charset_normalizer-3.4.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:aa88ca0b1932e93f2d961bf3addbb2db902198dca337d88c89e1559e066e7645"},
{file = "charset_normalizer-3.4.2-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d524ba3f1581b35c03cb42beebab4a13e6cdad7b36246bd22541fa585a56cccd"},
{file = "charset_normalizer-3.4.2-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:28a1005facc94196e1fb3e82a3d442a9d9110b8434fc1ded7a24a2983c9888d8"},
{file = "charset_normalizer-3.4.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fdb20a30fe1175ecabed17cbf7812f7b804b8a315a25f24678bcdf120a90077f"},
{file = "charset_normalizer-3.4.2-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0f5d9ed7f254402c9e7d35d2f5972c9bbea9040e99cd2861bd77dc68263277c7"},
{file = "charset_normalizer-3.4.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:efd387a49825780ff861998cd959767800d54f8308936b21025326de4b5a42b9"},
{file = "charset_normalizer-3.4.2-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:f0aa37f3c979cf2546b73e8222bbfa3dc07a641585340179d768068e3455e544"},
{file = "charset_normalizer-3.4.2-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:e70e990b2137b29dc5564715de1e12701815dacc1d056308e2b17e9095372a82"},
{file = "charset_normalizer-3.4.2-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:0c8c57f84ccfc871a48a47321cfa49ae1df56cd1d965a09abe84066f6853b9c0"},
{file = "charset_normalizer-3.4.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:6b66f92b17849b85cad91259efc341dce9c1af48e2173bf38a85c6329f1033e5"},
{file = "charset_normalizer-3.4.2-cp311-cp311-win32.whl", hash = "sha256:daac4765328a919a805fa5e2720f3e94767abd632ae410a9062dff5412bae65a"},
{file = "charset_normalizer-3.4.2-cp311-cp311-win_amd64.whl", hash = "sha256:e53efc7c7cee4c1e70661e2e112ca46a575f90ed9ae3fef200f2a25e954f4b28"},
{file = "charset_normalizer-3.4.2-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:0c29de6a1a95f24b9a1aa7aefd27d2487263f00dfd55a77719b530788f75cff7"},
{file = "charset_normalizer-3.4.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:cddf7bd982eaa998934a91f69d182aec997c6c468898efe6679af88283b498d3"},
{file = "charset_normalizer-3.4.2-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:fcbe676a55d7445b22c10967bceaaf0ee69407fbe0ece4d032b6eb8d4565982a"},
{file = "charset_normalizer-3.4.2-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d41c4d287cfc69060fa91cae9683eacffad989f1a10811995fa309df656ec214"},
{file = "charset_normalizer-3.4.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4e594135de17ab3866138f496755f302b72157d115086d100c3f19370839dd3a"},
{file = "charset_normalizer-3.4.2-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:cf713fe9a71ef6fd5adf7a79670135081cd4431c2943864757f0fa3a65b1fafd"},
{file = "charset_normalizer-3.4.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:a370b3e078e418187da8c3674eddb9d983ec09445c99a3a263c2011993522981"},
{file = "charset_normalizer-3.4.2-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:a955b438e62efdf7e0b7b52a64dc5c3396e2634baa62471768a64bc2adb73d5c"},
{file = "charset_normalizer-3.4.2-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:7222ffd5e4de8e57e03ce2cef95a4c43c98fcb72ad86909abdfc2c17d227fc1b"},
{file = "charset_normalizer-3.4.2-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:bee093bf902e1d8fc0ac143c88902c3dfc8941f7ea1d6a8dd2bcb786d33db03d"},
{file = "charset_normalizer-3.4.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:dedb8adb91d11846ee08bec4c8236c8549ac721c245678282dcb06b221aab59f"},
{file = "charset_normalizer-3.4.2-cp312-cp312-win32.whl", hash = "sha256:db4c7bf0e07fc3b7d89ac2a5880a6a8062056801b83ff56d8464b70f65482b6c"},
{file = "charset_normalizer-3.4.2-cp312-cp312-win_amd64.whl", hash = "sha256:5a9979887252a82fefd3d3ed2a8e3b937a7a809f65dcb1e068b090e165bbe99e"},
{file = "charset_normalizer-3.4.2-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:926ca93accd5d36ccdabd803392ddc3e03e6d4cd1cf17deff3b989ab8e9dbcf0"},
{file = "charset_normalizer-3.4.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:eba9904b0f38a143592d9fc0e19e2df0fa2e41c3c3745554761c5f6447eedabf"},
{file = "charset_normalizer-3.4.2-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3fddb7e2c84ac87ac3a947cb4e66d143ca5863ef48e4a5ecb83bd48619e4634e"},
{file = "charset_normalizer-3.4.2-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:98f862da73774290f251b9df8d11161b6cf25b599a66baf087c1ffe340e9bfd1"},
{file = "charset_normalizer-3.4.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6c9379d65defcab82d07b2a9dfbfc2e95bc8fe0ebb1b176a3190230a3ef0e07c"},
{file = "charset_normalizer-3.4.2-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e635b87f01ebc977342e2697d05b56632f5f879a4f15955dfe8cef2448b51691"},
{file = "charset_normalizer-3.4.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:1c95a1e2902a8b722868587c0e1184ad5c55631de5afc0eb96bc4b0d738092c0"},
{file = "charset_normalizer-3.4.2-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:ef8de666d6179b009dce7bcb2ad4c4a779f113f12caf8dc77f0162c29d20490b"},
{file = "charset_normalizer-3.4.2-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:32fc0341d72e0f73f80acb0a2c94216bd704f4f0bce10aedea38f30502b271ff"},
{file = "charset_normalizer-3.4.2-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:289200a18fa698949d2b39c671c2cc7a24d44096784e76614899a7ccf2574b7b"},
{file = "charset_normalizer-3.4.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:4a476b06fbcf359ad25d34a057b7219281286ae2477cc5ff5e3f70a246971148"},
{file = "charset_normalizer-3.4.2-cp313-cp313-win32.whl", hash = "sha256:aaeeb6a479c7667fbe1099af9617c83aaca22182d6cf8c53966491a0f1b7ffb7"},
{file = "charset_normalizer-3.4.2-cp313-cp313-win_amd64.whl", hash = "sha256:aa6af9e7d59f9c12b33ae4e9450619cf2488e2bbe9b44030905877f0b2324980"},
{file = "charset_normalizer-3.4.2-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1cad5f45b3146325bb38d6855642f6fd609c3f7cad4dbaf75549bf3b904d3184"},
{file = "charset_normalizer-3.4.2-cp37-cp37m-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b2680962a4848b3c4f155dc2ee64505a9c57186d0d56b43123b17ca3de18f0fa"},
{file = "charset_normalizer-3.4.2-cp37-cp37m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:36b31da18b8890a76ec181c3cf44326bf2c48e36d393ca1b72b3f484113ea344"},
{file = "charset_normalizer-3.4.2-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f4074c5a429281bf056ddd4c5d3b740ebca4d43ffffe2ef4bf4d2d05114299da"},
{file = "charset_normalizer-3.4.2-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c9e36a97bee9b86ef9a1cf7bb96747eb7a15c2f22bdb5b516434b00f2a599f02"},
{file = "charset_normalizer-3.4.2-cp37-cp37m-musllinux_1_2_aarch64.whl", hash = "sha256:1b1bde144d98e446b056ef98e59c256e9294f6b74d7af6846bf5ffdafd687a7d"},
{file = "charset_normalizer-3.4.2-cp37-cp37m-musllinux_1_2_i686.whl", hash = "sha256:915f3849a011c1f593ab99092f3cecfcb4d65d8feb4a64cf1bf2d22074dc0ec4"},
{file = "charset_normalizer-3.4.2-cp37-cp37m-musllinux_1_2_ppc64le.whl", hash = "sha256:fb707f3e15060adf5b7ada797624a6c6e0138e2a26baa089df64c68ee98e040f"},
{file = "charset_normalizer-3.4.2-cp37-cp37m-musllinux_1_2_s390x.whl", hash = "sha256:25a23ea5c7edc53e0f29bae2c44fcb5a1aa10591aae107f2a2b2583a9c5cbc64"},
{file = "charset_normalizer-3.4.2-cp37-cp37m-musllinux_1_2_x86_64.whl", hash = "sha256:770cab594ecf99ae64c236bc9ee3439c3f46be49796e265ce0cc8bc17b10294f"},
{file = "charset_normalizer-3.4.2-cp37-cp37m-win32.whl", hash = "sha256:6a0289e4589e8bdfef02a80478f1dfcb14f0ab696b5a00e1f4b8a14a307a3c58"},
{file = "charset_normalizer-3.4.2-cp37-cp37m-win_amd64.whl", hash = "sha256:6fc1f5b51fa4cecaa18f2bd7a003f3dd039dd615cd69a2afd6d3b19aed6775f2"},
{file = "charset_normalizer-3.4.2-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:76af085e67e56c8816c3ccf256ebd136def2ed9654525348cfa744b6802b69eb"},
{file = "charset_normalizer-3.4.2-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e45ba65510e2647721e35323d6ef54c7974959f6081b58d4ef5d87c60c84919a"},
{file = "charset_normalizer-3.4.2-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:046595208aae0120559a67693ecc65dd75d46f7bf687f159127046628178dc45"},
{file = "charset_normalizer-3.4.2-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:75d10d37a47afee94919c4fab4c22b9bc2a8bf7d4f46f87363bcf0573f3ff4f5"},
{file = "charset_normalizer-3.4.2-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6333b3aa5a12c26b2a4d4e7335a28f1475e0e5e17d69d55141ee3cab736f66d1"},
{file = "charset_normalizer-3.4.2-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e8323a9b031aa0393768b87f04b4164a40037fb2a3c11ac06a03ffecd3618027"},
{file = "charset_normalizer-3.4.2-cp38-cp38-musllinux_1_2_aarch64.whl", hash = "sha256:24498ba8ed6c2e0b56d4acbf83f2d989720a93b41d712ebd4f4979660db4417b"},
{file = "charset_normalizer-3.4.2-cp38-cp38-musllinux_1_2_i686.whl", hash = "sha256:844da2b5728b5ce0e32d863af26f32b5ce61bc4273a9c720a9f3aa9df73b1455"},
{file = "charset_normalizer-3.4.2-cp38-cp38-musllinux_1_2_ppc64le.whl", hash = "sha256:65c981bdbd3f57670af8b59777cbfae75364b483fa8a9f420f08094531d54a01"},
{file = "charset_normalizer-3.4.2-cp38-cp38-musllinux_1_2_s390x.whl", hash = "sha256:3c21d4fca343c805a52c0c78edc01e3477f6dd1ad7c47653241cf2a206d4fc58"},
{file = "charset_normalizer-3.4.2-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:dc7039885fa1baf9be153a0626e337aa7ec8bf96b0128605fb0d77788ddc1681"},
{file = "charset_normalizer-3.4.2-cp38-cp38-win32.whl", hash = "sha256:8272b73e1c5603666618805fe821edba66892e2870058c94c53147602eab29c7"},
{file = "charset_normalizer-3.4.2-cp38-cp38-win_amd64.whl", hash = "sha256:70f7172939fdf8790425ba31915bfbe8335030f05b9913d7ae00a87d4395620a"},
{file = "charset_normalizer-3.4.2-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:005fa3432484527f9732ebd315da8da8001593e2cf46a3d817669f062c3d9ed4"},
{file = "charset_normalizer-3.4.2-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e92fca20c46e9f5e1bb485887d074918b13543b1c2a1185e69bb8d17ab6236a7"},
{file = "charset_normalizer-3.4.2-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:50bf98d5e563b83cc29471fa114366e6806bc06bc7a25fd59641e41445327836"},
{file = "charset_normalizer-3.4.2-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:721c76e84fe669be19c5791da68232ca2e05ba5185575086e384352e2c309597"},
{file = "charset_normalizer-3.4.2-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:82d8fd25b7f4675d0c47cf95b594d4e7b158aca33b76aa63d07186e13c0e0ab7"},
{file = "charset_normalizer-3.4.2-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b3daeac64d5b371dea99714f08ffc2c208522ec6b06fbc7866a450dd446f5c0f"},
{file = "charset_normalizer-3.4.2-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:dccab8d5fa1ef9bfba0590ecf4d46df048d18ffe3eec01eeb73a42e0d9e7a8ba"},
{file = "charset_normalizer-3.4.2-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:aaf27faa992bfee0264dc1f03f4c75e9fcdda66a519db6b957a3f826e285cf12"},
{file = "charset_normalizer-3.4.2-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:eb30abc20df9ab0814b5a2524f23d75dcf83cde762c161917a2b4b7b55b1e518"},
{file = "charset_normalizer-3.4.2-cp39-cp39-musllinux_1_2_s390x.whl", hash = "sha256:c72fbbe68c6f32f251bdc08b8611c7b3060612236e960ef848e0a517ddbe76c5"},
{file = "charset_normalizer-3.4.2-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:982bb1e8b4ffda883b3d0a521e23abcd6fd17418f6d2c4118d257a10199c0ce3"},
{file = "charset_normalizer-3.4.2-cp39-cp39-win32.whl", hash = "sha256:43e0933a0eff183ee85833f341ec567c0980dae57c464d8a508e1b2ceb336471"},
{file = "charset_normalizer-3.4.2-cp39-cp39-win_amd64.whl", hash = "sha256:d11b54acf878eef558599658b0ffca78138c8c3655cf4f3a4a673c437e67732e"},
{file = "charset_normalizer-3.4.2-py3-none-any.whl", hash = "sha256:7f56930ab0abd1c45cd15be65cc741c28b1c9a34876ce8c17a2fa107810c0af0"},
{file = "charset_normalizer-3.4.2.tar.gz", hash = "sha256:5baececa9ecba31eff645232d59845c07aa030f0c81ee70184a90d35099a0e63"},
]
[[package]]
name = "click"
version = "8.2.1"
description = "Composable command line interface toolkit"
optional = false
python-versions = ">=3.10"
groups = ["docs"]
files = [
{file = "click-8.2.1-py3-none-any.whl", hash = "sha256:61a3265b914e850b85317d0b3109c7f8cd35a670f963866005d6ef1d5175a12b"},
{file = "click-8.2.1.tar.gz", hash = "sha256:27c491cc05d968d271d5a1db13e3b5a184636d9d930f148c50b038f0d0646202"},
]
[package.dependencies]
colorama = {version = "*", markers = "platform_system == \"Windows\""}
[[package]]
name = "colorama"
version = "0.4.6"
description = "Cross-platform colored terminal text."
optional = false
python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7"
groups = ["dev", "docs"]
files = [
{file = "colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6"},
{file = "colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44"},
]
markers = {dev = "sys_platform == \"win32\""}
[[package]]
name = "coverage"
version = "7.10.6"
description = "Code coverage measurement for Python"
optional = false
python-versions = ">=3.9"
groups = ["dev"]
files = [
{file = "coverage-7.10.6-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:70e7bfbd57126b5554aa482691145f798d7df77489a177a6bef80de78860a356"},
{file = "coverage-7.10.6-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:e41be6f0f19da64af13403e52f2dec38bbc2937af54df8ecef10850ff8d35301"},
{file = "coverage-7.10.6-cp310-cp310-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:c61fc91ab80b23f5fddbee342d19662f3d3328173229caded831aa0bd7595460"},
{file = "coverage-7.10.6-cp310-cp310-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:10356fdd33a7cc06e8051413140bbdc6f972137508a3572e3f59f805cd2832fd"},
{file = "coverage-7.10.6-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:80b1695cf7c5ebe7b44bf2521221b9bb8cdf69b1f24231149a7e3eb1ae5fa2fb"},
{file = "coverage-7.10.6-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:2e4c33e6378b9d52d3454bd08847a8651f4ed23ddbb4a0520227bd346382bbc6"},
{file = "coverage-7.10.6-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:c8a3ec16e34ef980a46f60dc6ad86ec60f763c3f2fa0db6d261e6e754f72e945"},
{file = "coverage-7.10.6-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:7d79dabc0a56f5af990cc6da9ad1e40766e82773c075f09cc571e2076fef882e"},
{file = "coverage-7.10.6-cp310-cp310-win32.whl", hash = "sha256:86b9b59f2b16e981906e9d6383eb6446d5b46c278460ae2c36487667717eccf1"},
{file = "coverage-7.10.6-cp310-cp310-win_amd64.whl", hash = "sha256:e132b9152749bd33534e5bd8565c7576f135f157b4029b975e15ee184325f528"},
{file = "coverage-7.10.6-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:c706db3cabb7ceef779de68270150665e710b46d56372455cd741184f3868d8f"},
{file = "coverage-7.10.6-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:8e0c38dc289e0508ef68ec95834cb5d2e96fdbe792eaccaa1bccac3966bbadcc"},
{file = "coverage-7.10.6-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:752a3005a1ded28f2f3a6e8787e24f28d6abe176ca64677bcd8d53d6fe2ec08a"},
{file = "coverage-7.10.6-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:689920ecfd60f992cafca4f5477d55720466ad2c7fa29bb56ac8d44a1ac2b47a"},
{file = "coverage-7.10.6-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ec98435796d2624d6905820a42f82149ee9fc4f2d45c2c5bc5a44481cc50db62"},
{file = "coverage-7.10.6-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:b37201ce4a458c7a758ecc4efa92fa8ed783c66e0fa3c42ae19fc454a0792153"},
{file = "coverage-7.10.6-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:2904271c80898663c810a6b067920a61dd8d38341244a3605bd31ab55250dad5"},
{file = "coverage-7.10.6-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:5aea98383463d6e1fa4e95416d8de66f2d0cb588774ee20ae1b28df826bcb619"},
{file = "coverage-7.10.6-cp311-cp311-win32.whl", hash = "sha256:e3fb1fa01d3598002777dd259c0c2e6d9d5e10e7222976fc8e03992f972a2cba"},
{file = "coverage-7.10.6-cp311-cp311-win_amd64.whl", hash = "sha256:f35ed9d945bece26553d5b4c8630453169672bea0050a564456eb88bdffd927e"},
{file = "coverage-7.10.6-cp311-cp311-win_arm64.whl", hash = "sha256:99e1a305c7765631d74b98bf7dbf54eeea931f975e80f115437d23848ee8c27c"},
{file = "coverage-7.10.6-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:5b2dd6059938063a2c9fee1af729d4f2af28fd1a545e9b7652861f0d752ebcea"},
{file = "coverage-7.10.6-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:388d80e56191bf846c485c14ae2bc8898aa3124d9d35903fef7d907780477634"},
{file = "coverage-7.10.6-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:90cb5b1a4670662719591aa92d0095bb41714970c0b065b02a2610172dbf0af6"},
{file = "coverage-7.10.6-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:961834e2f2b863a0e14260a9a273aff07ff7818ab6e66d2addf5628590c628f9"},
{file = "coverage-7.10.6-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bf9a19f5012dab774628491659646335b1928cfc931bf8d97b0d5918dd58033c"},
{file = "coverage-7.10.6-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:99c4283e2a0e147b9c9cc6bc9c96124de9419d6044837e9799763a0e29a7321a"},
{file = "coverage-7.10.6-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:282b1b20f45df57cc508c1e033403f02283adfb67d4c9c35a90281d81e5c52c5"},
{file = "coverage-7.10.6-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:8cdbe264f11afd69841bd8c0d83ca10b5b32853263ee62e6ac6a0ab63895f972"},
{file = "coverage-7.10.6-cp312-cp312-win32.whl", hash = "sha256:a517feaf3a0a3eca1ee985d8373135cfdedfbba3882a5eab4362bda7c7cf518d"},
{file = "coverage-7.10.6-cp312-cp312-win_amd64.whl", hash = "sha256:856986eadf41f52b214176d894a7de05331117f6035a28ac0016c0f63d887629"},
{file = "coverage-7.10.6-cp312-cp312-win_arm64.whl", hash = "sha256:acf36b8268785aad739443fa2780c16260ee3fa09d12b3a70f772ef100939d80"},
{file = "coverage-7.10.6-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:ffea0575345e9ee0144dfe5701aa17f3ba546f8c3bb48db62ae101afb740e7d6"},
{file = "coverage-7.10.6-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:95d91d7317cde40a1c249d6b7382750b7e6d86fad9d8eaf4fa3f8f44cf171e80"},
{file = "coverage-7.10.6-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:3e23dd5408fe71a356b41baa82892772a4cefcf758f2ca3383d2aa39e1b7a003"},
{file = "coverage-7.10.6-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:0f3f56e4cb573755e96a16501a98bf211f100463d70275759e73f3cbc00d4f27"},
{file = "coverage-7.10.6-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:db4a1d897bbbe7339946ffa2fe60c10cc81c43fab8b062d3fcb84188688174a4"},
{file = "coverage-7.10.6-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d8fd7879082953c156d5b13c74aa6cca37f6a6f4747b39538504c3f9c63d043d"},
{file = "coverage-7.10.6-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:28395ca3f71cd103b8c116333fa9db867f3a3e1ad6a084aa3725ae002b6583bc"},
{file = "coverage-7.10.6-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:61c950fc33d29c91b9e18540e1aed7d9f6787cc870a3e4032493bbbe641d12fc"},
{file = "coverage-7.10.6-cp313-cp313-win32.whl", hash = "sha256:160c00a5e6b6bdf4e5984b0ef21fc860bc94416c41b7df4d63f536d17c38902e"},
{file = "coverage-7.10.6-cp313-cp313-win_amd64.whl", hash = "sha256:628055297f3e2aa181464c3808402887643405573eb3d9de060d81531fa79d32"},
{file = "coverage-7.10.6-cp313-cp313-win_arm64.whl", hash = "sha256:df4ec1f8540b0bcbe26ca7dd0f541847cc8a108b35596f9f91f59f0c060bfdd2"},
{file = "coverage-7.10.6-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:c9a8b7a34a4de3ed987f636f71881cd3b8339f61118b1aa311fbda12741bff0b"},
{file = "coverage-7.10.6-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:8dd5af36092430c2b075cee966719898f2ae87b636cefb85a653f1d0ba5d5393"},
{file = "coverage-7.10.6-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:b0353b0f0850d49ada66fdd7d0c7cdb0f86b900bb9e367024fd14a60cecc1e27"},
{file = "coverage-7.10.6-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:d6b9ae13d5d3e8aeca9ca94198aa7b3ebbc5acfada557d724f2a1f03d2c0b0df"},
{file = "coverage-7.10.6-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:675824a363cc05781b1527b39dc2587b8984965834a748177ee3c37b64ffeafb"},
{file = "coverage-7.10.6-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:692d70ea725f471a547c305f0d0fc6a73480c62fb0da726370c088ab21aed282"},
{file = "coverage-7.10.6-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:851430a9a361c7a8484a36126d1d0ff8d529d97385eacc8dfdc9bfc8c2d2cbe4"},
{file = "coverage-7.10.6-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:d9369a23186d189b2fc95cc08b8160ba242057e887d766864f7adf3c46b2df21"},
{file = "coverage-7.10.6-cp313-cp313t-win32.whl", hash = "sha256:92be86fcb125e9bda0da7806afd29a3fd33fdf58fba5d60318399adf40bf37d0"},
{file = "coverage-7.10.6-cp313-cp313t-win_amd64.whl", hash = "sha256:6b3039e2ca459a70c79523d39347d83b73f2f06af5624905eba7ec34d64d80b5"},
{file = "coverage-7.10.6-cp313-cp313t-win_arm64.whl", hash = "sha256:3fb99d0786fe17b228eab663d16bee2288e8724d26a199c29325aac4b0319b9b"},
{file = "coverage-7.10.6-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:6008a021907be8c4c02f37cdc3ffb258493bdebfeaf9a839f9e71dfdc47b018e"},
{file = "coverage-7.10.6-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:5e75e37f23eb144e78940b40395b42f2321951206a4f50e23cfd6e8a198d3ceb"},
{file = "coverage-7.10.6-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:0f7cb359a448e043c576f0da00aa8bfd796a01b06aa610ca453d4dde09cc1034"},
{file = "coverage-7.10.6-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:c68018e4fc4e14b5668f1353b41ccf4bc83ba355f0e1b3836861c6f042d89ac1"},
{file = "coverage-7.10.6-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:cd4b2b0707fc55afa160cd5fc33b27ccbf75ca11d81f4ec9863d5793fc6df56a"},
{file = "coverage-7.10.6-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:4cec13817a651f8804a86e4f79d815b3b28472c910e099e4d5a0e8a3b6a1d4cb"},
{file = "coverage-7.10.6-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:f2a6a8e06bbda06f78739f40bfb56c45d14eb8249d0f0ea6d4b3d48e1f7c695d"},
{file = "coverage-7.10.6-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:081b98395ced0d9bcf60ada7661a0b75f36b78b9d7e39ea0790bb4ed8da14747"},
{file = "coverage-7.10.6-cp314-cp314-win32.whl", hash = "sha256:6937347c5d7d069ee776b2bf4e1212f912a9f1f141a429c475e6089462fcecc5"},
{file = "coverage-7.10.6-cp314-cp314-win_amd64.whl", hash = "sha256:adec1d980fa07e60b6ef865f9e5410ba760e4e1d26f60f7e5772c73b9a5b0713"},
{file = "coverage-7.10.6-cp314-cp314-win_arm64.whl", hash = "sha256:a80f7aef9535442bdcf562e5a0d5a5538ce8abe6bb209cfbf170c462ac2c2a32"},
{file = "coverage-7.10.6-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:0de434f4fbbe5af4fa7989521c655c8c779afb61c53ab561b64dcee6149e4c65"},
{file = "coverage-7.10.6-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:6e31b8155150c57e5ac43ccd289d079eb3f825187d7c66e755a055d2c85794c6"},
{file = "coverage-7.10.6-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:98cede73eb83c31e2118ae8d379c12e3e42736903a8afcca92a7218e1f2903b0"},
{file = "coverage-7.10.6-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:f863c08f4ff6b64fa8045b1e3da480f5374779ef187f07b82e0538c68cb4ff8e"},
{file = "coverage-7.10.6-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2b38261034fda87be356f2c3f42221fdb4171c3ce7658066ae449241485390d5"},
{file = "coverage-7.10.6-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:0e93b1476b79eae849dc3872faeb0bf7948fd9ea34869590bc16a2a00b9c82a7"},
{file = "coverage-7.10.6-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:ff8a991f70f4c0cf53088abf1e3886edcc87d53004c7bb94e78650b4d3dac3b5"},
{file = "coverage-7.10.6-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:ac765b026c9f33044419cbba1da913cfb82cca1b60598ac1c7a5ed6aac4621a0"},
{file = "coverage-7.10.6-cp314-cp314t-win32.whl", hash = "sha256:441c357d55f4936875636ef2cfb3bee36e466dcf50df9afbd398ce79dba1ebb7"},
{file = "coverage-7.10.6-cp314-cp314t-win_amd64.whl", hash = "sha256:073711de3181b2e204e4870ac83a7c4853115b42e9cd4d145f2231e12d670930"},
{file = "coverage-7.10.6-cp314-cp314t-win_arm64.whl", hash = "sha256:137921f2bac5559334ba66122b753db6dc5d1cf01eb7b64eb412bb0d064ef35b"},
{file = "coverage-7.10.6-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:90558c35af64971d65fbd935c32010f9a2f52776103a259f1dee865fe8259352"},
{file = "coverage-7.10.6-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:8953746d371e5695405806c46d705a3cd170b9cc2b9f93953ad838f6c1e58612"},
{file = "coverage-7.10.6-cp39-cp39-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:c83f6afb480eae0313114297d29d7c295670a41c11b274e6bca0c64540c1ce7b"},
{file = "coverage-7.10.6-cp39-cp39-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:7eb68d356ba0cc158ca535ce1381dbf2037fa8cb5b1ae5ddfc302e7317d04144"},
{file = "coverage-7.10.6-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5b15a87265e96307482746d86995f4bff282f14b027db75469c446da6127433b"},
{file = "coverage-7.10.6-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:fc53ba868875bfbb66ee447d64d6413c2db91fddcfca57025a0e7ab5b07d5862"},
{file = "coverage-7.10.6-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:efeda443000aa23f276f4df973cb82beca682fd800bb119d19e80504ffe53ec2"},
{file = "coverage-7.10.6-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:9702b59d582ff1e184945d8b501ffdd08d2cee38d93a2206aa5f1365ce0b8d78"},
{file = "coverage-7.10.6-cp39-cp39-win32.whl", hash = "sha256:2195f8e16ba1a44651ca684db2ea2b2d4b5345da12f07d9c22a395202a05b23c"},
{file = "coverage-7.10.6-cp39-cp39-win_amd64.whl", hash = "sha256:f32ff80e7ef6a5b5b606ea69a36e97b219cd9dc799bcf2963018a4d8f788cfbf"},
{file = "coverage-7.10.6-py3-none-any.whl", hash = "sha256:92c4ecf6bf11b2e85fd4d8204814dc26e6a19f0c9d938c207c5cb0eadfcabbe3"},
{file = "coverage-7.10.6.tar.gz", hash = "sha256:f644a3ae5933a552a29dbb9aa2f90c677a875f80ebea028e5a52a4f429044b90"},
]
[package.extras]
toml = ["tomli ; python_full_version <= \"3.11.0a6\""]
[[package]]
name = "cryptography"
version = "45.0.5"
description = "cryptography is a package which provides cryptographic recipes and primitives to Python developers."
optional = false
python-versions = "!=3.9.0,!=3.9.1,>=3.7"
groups = ["main"]
files = [
{file = "cryptography-45.0.5-cp311-abi3-macosx_10_9_universal2.whl", hash = "sha256:101ee65078f6dd3e5a028d4f19c07ffa4dd22cce6a20eaa160f8b5219911e7d8"},
{file = "cryptography-45.0.5-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:3a264aae5f7fbb089dbc01e0242d3b67dffe3e6292e1f5182122bdf58e65215d"},
{file = "cryptography-45.0.5-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:e74d30ec9c7cb2f404af331d5b4099a9b322a8a6b25c4632755c8757345baac5"},
{file = "cryptography-45.0.5-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:3af26738f2db354aafe492fb3869e955b12b2ef2e16908c8b9cb928128d42c57"},
{file = "cryptography-45.0.5-cp311-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:e6c00130ed423201c5bc5544c23359141660b07999ad82e34e7bb8f882bb78e0"},
{file = "cryptography-45.0.5-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:dd420e577921c8c2d31289536c386aaa30140b473835e97f83bc71ea9d2baf2d"},
{file = "cryptography-45.0.5-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:d05a38884db2ba215218745f0781775806bde4f32e07b135348355fe8e4991d9"},
{file = "cryptography-45.0.5-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:ad0caded895a00261a5b4aa9af828baede54638754b51955a0ac75576b831b27"},
{file = "cryptography-45.0.5-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:9024beb59aca9d31d36fcdc1604dd9bbeed0a55bface9f1908df19178e2f116e"},
{file = "cryptography-45.0.5-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:91098f02ca81579c85f66df8a588c78f331ca19089763d733e34ad359f474174"},
{file = "cryptography-45.0.5-cp311-abi3-win32.whl", hash = "sha256:926c3ea71a6043921050eaa639137e13dbe7b4ab25800932a8498364fc1abec9"},
{file = "cryptography-45.0.5-cp311-abi3-win_amd64.whl", hash = "sha256:b85980d1e345fe769cfc57c57db2b59cff5464ee0c045d52c0df087e926fbe63"},
{file = "cryptography-45.0.5-cp37-abi3-macosx_10_9_universal2.whl", hash = "sha256:f3562c2f23c612f2e4a6964a61d942f891d29ee320edb62ff48ffb99f3de9ae8"},
{file = "cryptography-45.0.5-cp37-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:3fcfbefc4a7f332dece7272a88e410f611e79458fab97b5efe14e54fe476f4fd"},
{file = "cryptography-45.0.5-cp37-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:460f8c39ba66af7db0545a8c6f2eabcbc5a5528fc1cf6c3fa9a1e44cec33385e"},
{file = "cryptography-45.0.5-cp37-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:9b4cf6318915dccfe218e69bbec417fdd7c7185aa7aab139a2c0beb7468c89f0"},
{file = "cryptography-45.0.5-cp37-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:2089cc8f70a6e454601525e5bf2779e665d7865af002a5dec8d14e561002e135"},
{file = "cryptography-45.0.5-cp37-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:0027d566d65a38497bc37e0dd7c2f8ceda73597d2ac9ba93810204f56f52ebc7"},
{file = "cryptography-45.0.5-cp37-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:be97d3a19c16a9be00edf79dca949c8fa7eff621763666a145f9f9535a5d7f42"},
{file = "cryptography-45.0.5-cp37-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:7760c1c2e1a7084153a0f68fab76e754083b126a47d0117c9ed15e69e2103492"},
{file = "cryptography-45.0.5-cp37-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:6ff8728d8d890b3dda5765276d1bc6fb099252915a2cd3aff960c4c195745dd0"},
{file = "cryptography-45.0.5-cp37-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:7259038202a47fdecee7e62e0fd0b0738b6daa335354396c6ddebdbe1206af2a"},
{file = "cryptography-45.0.5-cp37-abi3-win32.whl", hash = "sha256:1e1da5accc0c750056c556a93c3e9cb828970206c68867712ca5805e46dc806f"},
{file = "cryptography-45.0.5-cp37-abi3-win_amd64.whl", hash = "sha256:90cb0a7bb35959f37e23303b7eed0a32280510030daba3f7fdfbb65defde6a97"},
{file = "cryptography-45.0.5-pp310-pypy310_pp73-macosx_10_9_x86_64.whl", hash = "sha256:206210d03c1193f4e1ff681d22885181d47efa1ab3018766a7b32a7b3d6e6afd"},
{file = "cryptography-45.0.5-pp310-pypy310_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:c648025b6840fe62e57107e0a25f604db740e728bd67da4f6f060f03017d5097"},
{file = "cryptography-45.0.5-pp310-pypy310_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:b8fa8b0a35a9982a3c60ec79905ba5bb090fc0b9addcfd3dc2dd04267e45f25e"},
{file = "cryptography-45.0.5-pp310-pypy310_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:14d96584701a887763384f3c47f0ca7c1cce322aa1c31172680eb596b890ec30"},
{file = "cryptography-45.0.5-pp310-pypy310_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:57c816dfbd1659a367831baca4b775b2a5b43c003daf52e9d57e1d30bc2e1b0e"},
{file = "cryptography-45.0.5-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:b9e38e0a83cd51e07f5a48ff9691cae95a79bea28fe4ded168a8e5c6c77e819d"},
{file = "cryptography-45.0.5-pp311-pypy311_pp73-macosx_10_9_x86_64.whl", hash = "sha256:8c4a6ff8a30e9e3d38ac0539e9a9e02540ab3f827a3394f8852432f6b0ea152e"},
{file = "cryptography-45.0.5-pp311-pypy311_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:bd4c45986472694e5121084c6ebbd112aa919a25e783b87eb95953c9573906d6"},
{file = "cryptography-45.0.5-pp311-pypy311_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:982518cd64c54fcada9d7e5cf28eabd3ee76bd03ab18e08a48cad7e8b6f31b18"},
{file = "cryptography-45.0.5-pp311-pypy311_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:12e55281d993a793b0e883066f590c1ae1e802e3acb67f8b442e721e475e6463"},
{file = "cryptography-45.0.5-pp311-pypy311_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:5aa1e32983d4443e310f726ee4b071ab7569f58eedfdd65e9675484a4eb67bd1"},
{file = "cryptography-45.0.5-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:e357286c1b76403dd384d938f93c46b2b058ed4dfcdce64a770f0537ed3feb6f"},
{file = "cryptography-45.0.5.tar.gz", hash = "sha256:72e76caa004ab63accdf26023fccd1d087f6d90ec6048ff33ad0445abf7f605a"},
]
[package.dependencies]
cffi = {version = ">=1.14", markers = "platform_python_implementation != \"PyPy\""}
[package.extras]
docs = ["sphinx (>=5.3.0)", "sphinx-inline-tabs ; python_full_version >= \"3.8.0\"", "sphinx-rtd-theme (>=3.0.0) ; python_full_version >= \"3.8.0\""]
docstest = ["pyenchant (>=3)", "readme-renderer (>=30.0)", "sphinxcontrib-spelling (>=7.3.1)"]
nox = ["nox (>=2024.4.15)", "nox[uv] (>=2024.3.2) ; python_full_version >= \"3.8.0\""]
pep8test = ["check-sdist ; python_full_version >= \"3.8.0\"", "click (>=8.0.1)", "mypy (>=1.4)", "ruff (>=0.3.6)"]
sdist = ["build (>=1.0.0)"]
ssh = ["bcrypt (>=3.1.5)"]
test = ["certifi (>=2024)", "cryptography-vectors (==45.0.5)", "pretend (>=0.7)", "pytest (>=7.4.0)", "pytest-benchmark (>=4.0)", "pytest-cov (>=2.10.1)", "pytest-xdist (>=3.5.0)"]
test-randomorder = ["pytest-randomly"]
[[package]]
name = "dbus-fast"
version = "2.44.3"
description = "A faster version of dbus-next"
optional = false
python-versions = ">=3.9"
groups = ["main", "dev"]
files = [
{file = "dbus_fast-2.44.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:644880d8db53a6d92e88015f6ac6e0d9a5c1bfdacbc5356de816212cca33c629"},
{file = "dbus_fast-2.44.3-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:be7e2e39bc6a5e0fe758d9d7abb19f91a7540e3b45124764f318147b74c9b2e6"},
{file = "dbus_fast-2.44.3-cp310-cp310-manylinux_2_17_i686.manylinux_2_5_i686.manylinux1_i686.manylinux2014_i686.whl", hash = "sha256:049236a2cacddc6f1f8583371d8fa54d0a01e2081c8f1311a6ad71b27b1512aa"},
{file = "dbus_fast-2.44.3-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:69bb4820259e0969ae79585ffc98409bf781589c138a90d4799d5751c83ed04a"},
{file = "dbus_fast-2.44.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:86350a3fc4304f50c56730b64bd3d709458648fa1b23f8e9449dfcce206defe4"},
{file = "dbus_fast-2.44.3-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:89c418c8f18fff8eb17143184d4e0f68216c4d702f16cba4323a6b6be6aaab2a"},
{file = "dbus_fast-2.44.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:c700cdb06e74a6c462d180eff146105fe08f0dc4a8f1f8ff93022175c8e6fe76"},
{file = "dbus_fast-2.44.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:9018568987b878e577bc3e692f2eef6b7a4482490a373ec00098578fa919076c"},
{file = "dbus_fast-2.44.3-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3879fb6d6e9260b310fed33457835e11b83e96144bfcf2cbb9abcd3e740c2836"},
{file = "dbus_fast-2.44.3-cp311-cp311-manylinux_2_17_i686.manylinux_2_5_i686.manylinux1_i686.manylinux2014_i686.whl", hash = "sha256:0c68f14d5a329bd494a2da561da961ddfb3f3351d41225dcf0e59106f32bf5d6"},
{file = "dbus_fast-2.44.3-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e3f10ee6ba45f37d067775c0719d072bc4a7e0bdc9a0411f5c7c93af0bfd9958"},
{file = "dbus_fast-2.44.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:7bec6cb61d9ce56715410e17e6e6d935df6d39bc01e0aae691135229a0d69072"},
{file = "dbus_fast-2.44.3-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:94ae76e470c5cf6eb507e2a92e698a9183b3558e3a09efcb7fe2152b92dd300b"},
{file = "dbus_fast-2.44.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:3f1df8582723ee1b1689243663f4e93fc406f0966ff3e9c26a21cb498de3b9ca"},
{file = "dbus_fast-2.44.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:861352c19f57087e9b2ff7e16a1bab0cfb2e7dc982ce0249aad2a36e1af8f110"},
{file = "dbus_fast-2.44.3-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5aafa42df91e17023885c508539df2f6312abb9d050f56e39345175cef05bfbb"},
{file = "dbus_fast-2.44.3-cp312-cp312-manylinux_2_17_i686.manylinux_2_5_i686.manylinux1_i686.manylinux2014_i686.whl", hash = "sha256:4e5c2515bdc159eaa9ac9e99115016af65261cb4d1d237162295966ad1d8cac0"},
{file = "dbus_fast-2.44.3-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3dab3b4802e1c518b8f3d98bfefe1f696125c00016faf1b6f1fd5170efc06d7e"},
{file = "dbus_fast-2.44.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:42842e8f396be5d938c60cb449600df811373efd57dc630bb40d6d36f4e710a4"},
{file = "dbus_fast-2.44.3-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:93ea055c644bdfd7c70614f7c860db9f5234736a15992df9e4a723fa55ef7622"},
{file = "dbus_fast-2.44.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:c9764e4188e21ad4a9f65856f3adacfc83d583a950d4dabc5ec5856db387784b"},
{file = "dbus_fast-2.44.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:d967a751cc2dd530d5b756a22bf67a603ebeca13c6f72d8b1cb8575b872caa16"},
{file = "dbus_fast-2.44.3-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:da0910f813350b951efe4964a19d7f4aaf253b6c1021b0d68340160a990dc2fc"},
{file = "dbus_fast-2.44.3-cp313-cp313-manylinux_2_17_i686.manylinux_2_5_i686.manylinux1_i686.manylinux2014_i686.whl", hash = "sha256:253ad2417b0651ba32325661bb559228ceaedea9fb75d238972087a5f66551fd"},
{file = "dbus_fast-2.44.3-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ebb4c56bef8f69e4e2606eb29a5c137ba448cf7d6958f4f2fba263d74623bd06"},
{file = "dbus_fast-2.44.3-cp313-cp313-manylinux_2_36_x86_64.whl", hash = "sha256:6e0a6a27a1f53b32259d0789bca6f53decd88dec52722cac9a93327f8b7670c3"},
{file = "dbus_fast-2.44.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:2a990390c5d019e8e4d41268a3ead0eb6e48e977173d7685b0f5b5b3d0695c2f"},
{file = "dbus_fast-2.44.3-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:5aca3c940eddb99f19bd3f0c6c50cd566fd98396dd9516d35dbf12af25b7a2c6"},
{file = "dbus_fast-2.44.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:0046e74c25b79ffb6ea5b07f33b5da0bdc2a75ad6aede3f7836654485239121d"},
{file = "dbus_fast-2.44.3-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:fce364e03b98a6acb4694f1c24b05bfc33d10045af1469378a25ffe4fa046f40"},
{file = "dbus_fast-2.44.3-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:fd955b153622df80cc420fe53c265cd43b7c559100a9e52c83ab0425bc083604"},
{file = "dbus_fast-2.44.3-cp39-cp39-manylinux_2_17_i686.manylinux_2_5_i686.manylinux1_i686.manylinux2014_i686.whl", hash = "sha256:6b00eef5437d27917d55d04b3edea60c12a3e2a94fd82e81b396311ff7bb1c88"},
{file = "dbus_fast-2.44.3-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8468df924e5a3870b1e23377ea573e4b43a22ab1730084eab1b838fd18c9a589"},
{file = "dbus_fast-2.44.3-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:e4dd64813f175403fac894b5f6f6ff028127ea3c6ca8eda41770f39ba9815572"},
{file = "dbus_fast-2.44.3-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:f36183af2c6d3a00bd555e7d871d8c3214bb91c42439428dfcf7cc664081182a"},
{file = "dbus_fast-2.44.3-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:0bb0dfc386ae246def7ee64ce058d099b1bc8c35cd5325e6cd80f57b8115fec7"},
{file = "dbus_fast-2.44.3-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:1b853f75e10b34bb2ba76706d10fdab5ba0cef9ebc1faec1969c84e5b155b3b8"},
{file = "dbus_fast-2.44.3-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:de00d3d7731b2f915ac3f4ed2119442f3054efeb84c5bdd21717b92241b68f82"},
{file = "dbus_fast-2.44.3-pp310-pypy310_pp73-manylinux_2_17_i686.manylinux_2_5_i686.manylinux1_i686.manylinux2014_i686.whl", hash = "sha256:92377b4f274e3e70b9fcffd9a0e37a9808748f8df4b9d510a81f36b9e8c0f42f"},
{file = "dbus_fast-2.44.3-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux_2_5_x86_64.manylinux1_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b6ee1dc3e05b47e89b6be5b45d345b57a85b822f3a55299b569766384e74d0f9"},
{file = "dbus_fast-2.44.3-pp39-pypy39_pp73-macosx_11_0_arm64.whl", hash = "sha256:780c960c546fe509dd2b7a8c7f5eeef3a88f99cdea77225a400a47411b9aea17"},
{file = "dbus_fast-2.44.3-pp39-pypy39_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4d4200a3c33684df692a545b16f72f52e70ecd68e8226273e828fc12fbcdde88"},
{file = "dbus_fast-2.44.3-pp39-pypy39_pp73-manylinux_2_17_i686.manylinux_2_5_i686.manylinux1_i686.manylinux2014_i686.whl", hash = "sha256:e1643f9d47450e29fd14e62c583c71f332337dc157e9536692e5c0cd5e70ec53"},
{file = "dbus_fast-2.44.3-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux_2_5_x86_64.manylinux1_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e1f3c673c40a3f82388b377d492aa31f9ba66c20ba1183f1bcd8f9b64eda599c"},
{file = "dbus_fast-2.44.3.tar.gz", hash = "sha256:962b36abbe885159e31135c57a7d9659997c61a13d55ecb070a61dc502dbd87e"},
]
[[package]]
name = "docutils"
version = "0.21.2"
description = "Docutils -- Python Documentation Utilities"
optional = false
python-versions = ">=3.9"
groups = ["docs"]
files = [
{file = "docutils-0.21.2-py3-none-any.whl", hash = "sha256:dafca5b9e384f0e419294eb4d2ff9fa826435bf15f15b7bd45723e8ad76811b2"},
{file = "docutils-0.21.2.tar.gz", hash = "sha256:3a6b18732edf182daa3cd12775bbb338cf5691468f91eeeb109deff6ebfa986f"},
]
[[package]]
name = "freezegun"
version = "1.5.5"
description = "Let your Python tests travel through time"
optional = false
python-versions = ">=3.8"
groups = ["dev"]
files = [
{file = "freezegun-1.5.5-py3-none-any.whl", hash = "sha256:cd557f4a75cf074e84bc374249b9dd491eaeacd61376b9eb3c423282211619d2"},
{file = "freezegun-1.5.5.tar.gz", hash = "sha256:ac7742a6cc6c25a2c35e9292dfd554b897b517d2dec26891a2e8debf205cb94a"},
]
[package.dependencies]
python-dateutil = ">=2.7"
[[package]]
name = "furo"
version = "2025.9.25"
description = "A clean customisable Sphinx documentation theme."
optional = false
python-versions = ">=3.8"
groups = ["docs"]
files = [
{file = "furo-2025.9.25-py3-none-any.whl", hash = "sha256:2937f68e823b8e37b410c972c371bc2b1d88026709534927158e0cb3fac95afe"},
{file = "furo-2025.9.25.tar.gz", hash = "sha256:3eac05582768fdbbc2bdfa1cdbcdd5d33cfc8b4bd2051729ff4e026a1d7e0a98"},
]
[package.dependencies]
accessible-pygments = ">=0.0.5"
beautifulsoup4 = "*"
pygments = ">=2.7"
sphinx = ">=6.0,<9.0"
sphinx-basic-ng = ">=1.0.0.beta2"
[[package]]
name = "h11"
version = "0.16.0"
description = "A pure-Python, bring-your-own-I/O implementation of HTTP/1.1"
optional = false
python-versions = ">=3.8"
groups = ["docs"]
files = [
{file = "h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86"},
{file = "h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1"},
]
[[package]]
name = "idna"
version = "3.10"
description = "Internationalized Domain Names in Applications (IDNA)"
optional = false
python-versions = ">=3.6"
groups = ["docs"]
files = [
{file = "idna-3.10-py3-none-any.whl", hash = "sha256:946d195a0d259cbba61165e88e65941f16e9b36ea6ddb97f00452bae8b1287d3"},
{file = "idna-3.10.tar.gz", hash = "sha256:12f65c9b470abda6dc35cf8e63cc574b1c52b11df2c86030af0ac09b01b13ea9"},
]
[package.extras]
all = ["flake8 (>=7.1.1)", "mypy (>=1.11.2)", "pytest (>=8.3.2)", "ruff (>=0.6.2)"]
[[package]]
name = "imagesize"
version = "1.4.1"
description = "Getting image size from png/jpeg/jpeg2000/gif file"
optional = false
python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*"
groups = ["docs"]
files = [
{file = "imagesize-1.4.1-py2.py3-none-any.whl", hash = "sha256:0d8d18d08f840c19d0ee7ca1fd82490fdc3729b7ac93f49870406ddde8ef8d8b"},
{file = "imagesize-1.4.1.tar.gz", hash = "sha256:69150444affb9cb0d5cc5a92b3676f0b2fb7cd9ae39e947a5e11a36b4497cd4a"},
]
[[package]]
name = "iniconfig"
version = "2.1.0"
description = "brain-dead simple config-ini parsing"
optional = false
python-versions = ">=3.8"
groups = ["dev"]
files = [
{file = "iniconfig-2.1.0-py3-none-any.whl", hash = "sha256:9deba5723312380e77435581c6bf4935c94cbfab9b1ed33ef8d238ea168eb760"},
{file = "iniconfig-2.1.0.tar.gz", hash = "sha256:3abbd2e30b36733fee78f9c7f7308f2d0050e88f0087fd25c2645f63c773e1c7"},
]
[[package]]
name = "jinja2"
version = "3.1.6"
description = "A very fast and expressive template engine."
optional = false
python-versions = ">=3.7"
groups = ["docs"]
files = [
{file = "jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67"},
{file = "jinja2-3.1.6.tar.gz", hash = "sha256:0137fb05990d35f1275a587e9aee6d56da821fc83491a0fb838183be43f66d6d"},
]
[package.dependencies]
MarkupSafe = ">=2.0"
[package.extras]
i18n = ["Babel (>=2.7)"]
[[package]]
name = "markdown-it-py"
version = "3.0.0"
description = "Python port of markdown-it. Markdown parsing, done right!"
optional = false
python-versions = ">=3.8"
groups = ["dev", "docs"]
files = [
{file = "markdown-it-py-3.0.0.tar.gz", hash = "sha256:e3f60a94fa066dc52ec76661e37c851cb232d92f9886b15cb560aaada2df8feb"},
{file = "markdown_it_py-3.0.0-py3-none-any.whl", hash = "sha256:355216845c60bd96232cd8d8c40e8f9765cc86f46880e43a8fd22dc1a1a8cab1"},
]
[package.dependencies]
mdurl = ">=0.1,<1.0"
[package.extras]
benchmarking = ["psutil", "pytest", "pytest-benchmark"]
code-style = ["pre-commit (>=3.0,<4.0)"]
compare = ["commonmark (>=0.9,<1.0)", "markdown (>=3.4,<4.0)", "mistletoe (>=1.0,<2.0)", "mistune (>=2.0,<3.0)", "panflute (>=2.3,<3.0)"]
linkify = ["linkify-it-py (>=1,<3)"]
plugins = ["mdit-py-plugins"]
profiling = ["gprof2dot"]
rtd = ["jupyter_sphinx", "mdit-py-plugins", "myst-parser", "pyyaml", "sphinx", "sphinx-copybutton", "sphinx-design", "sphinx_book_theme"]
testing = ["coverage", "pytest", "pytest-cov", "pytest-regressions"]
[[package]]
name = "markupsafe"
version = "3.0.2"
description = "Safely add untrusted strings to HTML/XML markup."
optional = false
python-versions = ">=3.9"
groups = ["docs"]
files = [
{file = "MarkupSafe-3.0.2-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:7e94c425039cde14257288fd61dcfb01963e658efbc0ff54f5306b06054700f8"},
{file = "MarkupSafe-3.0.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:9e2d922824181480953426608b81967de705c3cef4d1af983af849d7bd619158"},
{file = "MarkupSafe-3.0.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:38a9ef736c01fccdd6600705b09dc574584b89bea478200c5fbf112a6b0d5579"},
{file = "MarkupSafe-3.0.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bbcb445fa71794da8f178f0f6d66789a28d7319071af7a496d4d507ed566270d"},
{file = "MarkupSafe-3.0.2-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:57cb5a3cf367aeb1d316576250f65edec5bb3be939e9247ae594b4bcbc317dfb"},
{file = "MarkupSafe-3.0.2-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:3809ede931876f5b2ec92eef964286840ed3540dadf803dd570c3b7e13141a3b"},
{file = "MarkupSafe-3.0.2-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:e07c3764494e3776c602c1e78e298937c3315ccc9043ead7e685b7f2b8d47b3c"},
{file = "MarkupSafe-3.0.2-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:b424c77b206d63d500bcb69fa55ed8d0e6a3774056bdc4839fc9298a7edca171"},
{file = "MarkupSafe-3.0.2-cp310-cp310-win32.whl", hash = "sha256:fcabf5ff6eea076f859677f5f0b6b5c1a51e70a376b0579e0eadef8db48c6b50"},
{file = "MarkupSafe-3.0.2-cp310-cp310-win_amd64.whl", hash = "sha256:6af100e168aa82a50e186c82875a5893c5597a0c1ccdb0d8b40240b1f28b969a"},
{file = "MarkupSafe-3.0.2-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:9025b4018f3a1314059769c7bf15441064b2207cb3f065e6ea1e7359cb46db9d"},
{file = "MarkupSafe-3.0.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:93335ca3812df2f366e80509ae119189886b0f3c2b81325d39efdb84a1e2ae93"},
{file = "MarkupSafe-3.0.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2cb8438c3cbb25e220c2ab33bb226559e7afb3baec11c4f218ffa7308603c832"},
{file = "MarkupSafe-3.0.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a123e330ef0853c6e822384873bef7507557d8e4a082961e1defa947aa59ba84"},
{file = "MarkupSafe-3.0.2-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1e084f686b92e5b83186b07e8a17fc09e38fff551f3602b249881fec658d3eca"},
{file = "MarkupSafe-3.0.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:d8213e09c917a951de9d09ecee036d5c7d36cb6cb7dbaece4c71a60d79fb9798"},
{file = "MarkupSafe-3.0.2-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:5b02fb34468b6aaa40dfc198d813a641e3a63b98c2b05a16b9f80b7ec314185e"},
{file = "MarkupSafe-3.0.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:0bff5e0ae4ef2e1ae4fdf2dfd5b76c75e5c2fa4132d05fc1b0dabcd20c7e28c4"},
{file = "MarkupSafe-3.0.2-cp311-cp311-win32.whl", hash = "sha256:6c89876f41da747c8d3677a2b540fb32ef5715f97b66eeb0c6b66f5e3ef6f59d"},
{file = "MarkupSafe-3.0.2-cp311-cp311-win_amd64.whl", hash = "sha256:70a87b411535ccad5ef2f1df5136506a10775d267e197e4cf531ced10537bd6b"},
{file = "MarkupSafe-3.0.2-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:9778bd8ab0a994ebf6f84c2b949e65736d5575320a17ae8984a77fab08db94cf"},
{file = "MarkupSafe-3.0.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:846ade7b71e3536c4e56b386c2a47adf5741d2d8b94ec9dc3e92e5e1ee1e2225"},
{file = "MarkupSafe-3.0.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1c99d261bd2d5f6b59325c92c73df481e05e57f19837bdca8413b9eac4bd8028"},
{file = "MarkupSafe-3.0.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e17c96c14e19278594aa4841ec148115f9c7615a47382ecb6b82bd8fea3ab0c8"},
{file = "MarkupSafe-3.0.2-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:88416bd1e65dcea10bc7569faacb2c20ce071dd1f87539ca2ab364bf6231393c"},
{file = "MarkupSafe-3.0.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:2181e67807fc2fa785d0592dc2d6206c019b9502410671cc905d132a92866557"},
{file = "MarkupSafe-3.0.2-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:52305740fe773d09cffb16f8ed0427942901f00adedac82ec8b67752f58a1b22"},
{file = "MarkupSafe-3.0.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:ad10d3ded218f1039f11a75f8091880239651b52e9bb592ca27de44eed242a48"},
{file = "MarkupSafe-3.0.2-cp312-cp312-win32.whl", hash = "sha256:0f4ca02bea9a23221c0182836703cbf8930c5e9454bacce27e767509fa286a30"},
{file = "MarkupSafe-3.0.2-cp312-cp312-win_amd64.whl", hash = "sha256:8e06879fc22a25ca47312fbe7c8264eb0b662f6db27cb2d3bbbc74b1df4b9b87"},
{file = "MarkupSafe-3.0.2-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:ba9527cdd4c926ed0760bc301f6728ef34d841f405abf9d4f959c478421e4efd"},
{file = "MarkupSafe-3.0.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f8b3d067f2e40fe93e1ccdd6b2e1d16c43140e76f02fb1319a05cf2b79d99430"},
{file = "MarkupSafe-3.0.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:569511d3b58c8791ab4c2e1285575265991e6d8f8700c7be0e88f86cb0672094"},
{file = "MarkupSafe-3.0.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:15ab75ef81add55874e7ab7055e9c397312385bd9ced94920f2802310c930396"},
{file = "MarkupSafe-3.0.2-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f3818cb119498c0678015754eba762e0d61e5b52d34c8b13d770f0719f7b1d79"},
{file = "MarkupSafe-3.0.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:cdb82a876c47801bb54a690c5ae105a46b392ac6099881cdfb9f6e95e4014c6a"},
{file = "MarkupSafe-3.0.2-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:cabc348d87e913db6ab4aa100f01b08f481097838bdddf7c7a84b7575b7309ca"},
{file = "MarkupSafe-3.0.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:444dcda765c8a838eaae23112db52f1efaf750daddb2d9ca300bcae1039adc5c"},
{file = "MarkupSafe-3.0.2-cp313-cp313-win32.whl", hash = "sha256:bcf3e58998965654fdaff38e58584d8937aa3096ab5354d493c77d1fdd66d7a1"},
{file = "MarkupSafe-3.0.2-cp313-cp313-win_amd64.whl", hash = "sha256:e6a2a455bd412959b57a172ce6328d2dd1f01cb2135efda2e4576e8a23fa3b0f"},
{file = "MarkupSafe-3.0.2-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:b5a6b3ada725cea8a5e634536b1b01c30bcdcd7f9c6fff4151548d5bf6b3a36c"},
{file = "MarkupSafe-3.0.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:a904af0a6162c73e3edcb969eeeb53a63ceeb5d8cf642fade7d39e7963a22ddb"},
{file = "MarkupSafe-3.0.2-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4aa4e5faecf353ed117801a068ebab7b7e09ffb6e1d5e412dc852e0da018126c"},
{file = "MarkupSafe-3.0.2-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c0ef13eaeee5b615fb07c9a7dadb38eac06a0608b41570d8ade51c56539e509d"},
{file = "MarkupSafe-3.0.2-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d16a81a06776313e817c951135cf7340a3e91e8c1ff2fac444cfd75fffa04afe"},
{file = "MarkupSafe-3.0.2-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:6381026f158fdb7c72a168278597a5e3a5222e83ea18f543112b2662a9b699c5"},
{file = "MarkupSafe-3.0.2-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:3d79d162e7be8f996986c064d1c7c817f6df3a77fe3d6859f6f9e7be4b8c213a"},
{file = "MarkupSafe-3.0.2-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:131a3c7689c85f5ad20f9f6fb1b866f402c445b220c19fe4308c0b147ccd2ad9"},
{file = "MarkupSafe-3.0.2-cp313-cp313t-win32.whl", hash = "sha256:ba8062ed2cf21c07a9e295d5b8a2a5ce678b913b45fdf68c32d95d6c1291e0b6"},
{file = "MarkupSafe-3.0.2-cp313-cp313t-win_amd64.whl", hash = "sha256:e444a31f8db13eb18ada366ab3cf45fd4b31e4db1236a4448f68778c1d1a5a2f"},
{file = "MarkupSafe-3.0.2-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:eaa0a10b7f72326f1372a713e73c3f739b524b3af41feb43e4921cb529f5929a"},
{file = "MarkupSafe-3.0.2-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:48032821bbdf20f5799ff537c7ac3d1fba0ba032cfc06194faffa8cda8b560ff"},
{file = "MarkupSafe-3.0.2-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1a9d3f5f0901fdec14d8d2f66ef7d035f2157240a433441719ac9a3fba440b13"},
{file = "MarkupSafe-3.0.2-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:88b49a3b9ff31e19998750c38e030fc7bb937398b1f78cfa599aaef92d693144"},
{file = "MarkupSafe-3.0.2-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:cfad01eed2c2e0c01fd0ecd2ef42c492f7f93902e39a42fc9ee1692961443a29"},
{file = "MarkupSafe-3.0.2-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:1225beacc926f536dc82e45f8a4d68502949dc67eea90eab715dea3a21c1b5f0"},
{file = "MarkupSafe-3.0.2-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:3169b1eefae027567d1ce6ee7cae382c57fe26e82775f460f0b2778beaad66c0"},
{file = "MarkupSafe-3.0.2-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:eb7972a85c54febfb25b5c4b4f3af4dcc731994c7da0d8a0b4a6eb0640e1d178"},
{file = "MarkupSafe-3.0.2-cp39-cp39-win32.whl", hash = "sha256:8c4e8c3ce11e1f92f6536ff07154f9d49677ebaaafc32db9db4620bc11ed480f"},
{file = "MarkupSafe-3.0.2-cp39-cp39-win_amd64.whl", hash = "sha256:6e296a513ca3d94054c2c881cc913116e90fd030ad1c656b3869762b754f5f8a"},
{file = "markupsafe-3.0.2.tar.gz", hash = "sha256:ee55d3edf80167e48ea11a923c7386f4669df67d7994554387f84e7d8b0a2bf0"},
]
[[package]]
name = "mdit-py-plugins"
version = "0.4.2"
description = "Collection of plugins for markdown-it-py"
optional = false
python-versions = ">=3.8"
groups = ["docs"]
files = [
{file = "mdit_py_plugins-0.4.2-py3-none-any.whl", hash = "sha256:0c673c3f889399a33b95e88d2f0d111b4447bdfea7f237dab2d488f459835636"},
{file = "mdit_py_plugins-0.4.2.tar.gz", hash = "sha256:5f2cd1fdb606ddf152d37ec30e46101a60512bc0e5fa1a7002c36647b09e26b5"},
]
[package.dependencies]
markdown-it-py = ">=1.0.0,<4.0.0"
[package.extras]
code-style = ["pre-commit"]
rtd = ["myst-parser", "sphinx-book-theme"]
testing = ["coverage", "pytest", "pytest-cov", "pytest-regressions"]
[[package]]
name = "mdurl"
version = "0.1.2"
description = "Markdown URL utilities"
optional = false
python-versions = ">=3.7"
groups = ["dev", "docs"]
files = [
{file = "mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8"},
{file = "mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba"},
]
[[package]]
name = "myst-parser"
version = "4.0.1"
description = "An extended [CommonMark](https://spec.commonmark.org/) compliant parser,"
optional = false
python-versions = ">=3.10"
groups = ["docs"]
files = [
{file = "myst_parser-4.0.1-py3-none-any.whl", hash = "sha256:9134e88959ec3b5780aedf8a99680ea242869d012e8821db3126d427edc9c95d"},
{file = "myst_parser-4.0.1.tar.gz", hash = "sha256:5cfea715e4f3574138aecbf7d54132296bfd72bb614d31168f48c477a830a7c4"},
]
[package.dependencies]
docutils = ">=0.19,<0.22"
jinja2 = "*"
markdown-it-py = ">=3.0,<4.0"
mdit-py-plugins = ">=0.4.1,<1.0"
pyyaml = "*"
sphinx = ">=7,<9"
[package.extras]
code-style = ["pre-commit (>=4.0,<5.0)"]
linkify = ["linkify-it-py (>=2.0,<3.0)"]
rtd = ["ipython", "sphinx (>=7)", "sphinx-autodoc2 (>=0.5.0,<0.6.0)", "sphinx-book-theme (>=1.1,<2.0)", "sphinx-copybutton", "sphinx-design", "sphinx-pyscript", "sphinx-tippy (>=0.4.3)", "sphinx-togglebutton", "sphinxext-opengraph (>=0.9.0,<0.10.0)", "sphinxext-rediraffe (>=0.2.7,<0.3.0)"]
testing = ["beautifulsoup4", "coverage[toml]", "defusedxml", "pygments (<2.19)", "pytest (>=8,<9)", "pytest-cov", "pytest-param-files (>=0.6.0,<0.7.0)", "pytest-regressions", "sphinx-pytest"]
testing-docutils = ["pygments", "pytest (>=8,<9)", "pytest-param-files (>=0.6.0,<0.7.0)"]
[[package]]
name = "packaging"
version = "25.0"
description = "Core utilities for Python packages"
optional = false
python-versions = ">=3.8"
groups = ["dev", "docs"]
files = [
{file = "packaging-25.0-py3-none-any.whl", hash = "sha256:29572ef2b1f17581046b3a2227d5c611fb25ec70ca1ba8554b24b0e69331a484"},
{file = "packaging-25.0.tar.gz", hash = "sha256:d443872c98d677bf60f6a1f2f8c1cb748e8fe762d2bf9d3148b5599295b0fc4f"},
]
[[package]]
name = "pluggy"
version = "1.6.0"
description = "plugin and hook calling mechanisms for python"
optional = false
python-versions = ">=3.9"
groups = ["dev"]
files = [
{file = "pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746"},
{file = "pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3"},
]
[package.extras]
dev = ["pre-commit", "tox"]
testing = ["coverage", "pytest", "pytest-benchmark"]
[[package]]
name = "pycparser"
version = "2.22"
description = "C parser in Python"
optional = false
python-versions = ">=3.8"
groups = ["main", "dev"]
files = [
{file = "pycparser-2.22-py3-none-any.whl", hash = "sha256:c3702b6d3dd8c7abc1afa565d7e63d53a1d0bd86cdc24edd75470f4de499cfcc"},
{file = "pycparser-2.22.tar.gz", hash = "sha256:491c8be9c040f5390f5bf44a5b07752bd07f56edf992381b05c701439eec10f6"},
]
markers = {main = "platform_python_implementation != \"PyPy\""}
[[package]]
name = "pygments"
version = "2.19.2"
description = "Pygments is a syntax highlighting package written in Python."
optional = false
python-versions = ">=3.8"
groups = ["dev", "docs"]
files = [
{file = "pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b"},
{file = "pygments-2.19.2.tar.gz", hash = "sha256:636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887"},
]
[package.extras]
windows-terminal = ["colorama (>=0.4.6)"]
[[package]]
name = "pyobjc-core"
version = "11.1"
description = "Python<->ObjC Interoperability Module"
optional = false
python-versions = ">=3.8"
groups = ["main"]
markers = "platform_system == \"Darwin\""
files = [
{file = "pyobjc_core-11.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:4c7536f3e94de0a3eae6bb382d75f1219280aa867cdf37beef39d9e7d580173c"},
{file = "pyobjc_core-11.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:ec36680b5c14e2f73d432b03ba7c1457dc6ca70fa59fd7daea1073f2b4157d33"},
{file = "pyobjc_core-11.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:765b97dea6b87ec4612b3212258024d8496ea23517c95a1c5f0735f96b7fd529"},
{file = "pyobjc_core-11.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:18986f83998fbd5d3f56d8a8428b2f3e0754fd15cef3ef786ca0d29619024f2c"},
{file = "pyobjc_core-11.1-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:8849e78cfe6595c4911fbba29683decfb0bf57a350aed8a43316976ba6f659d2"},
{file = "pyobjc_core-11.1-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:8cb9ed17a8d84a312a6e8b665dd22393d48336ea1d8277e7ad20c19a38edf731"},
{file = "pyobjc_core-11.1-cp314-cp314t-macosx_11_0_universal2.whl", hash = "sha256:f2455683e807f8541f0d83fbba0f5d9a46128ab0d5cc83ea208f0bec759b7f96"},
{file = "pyobjc_core-11.1-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:4a99e6558b48b8e47c092051e7b3be05df1c8d0617b62f6fa6a316c01902d157"},
{file = "pyobjc_core-11.1.tar.gz", hash = "sha256:b63d4d90c5df7e762f34739b39cc55bc63dbcf9fb2fb3f2671e528488c7a87fe"},
]
[[package]]
name = "pyobjc-framework-cocoa"
version = "11.1"
description = "Wrappers for the Cocoa frameworks on macOS"
optional = false
python-versions = ">=3.9"
groups = ["main"]
markers = "platform_system == \"Darwin\""
files = [
{file = "pyobjc_framework_cocoa-11.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:b27a5bdb3ab6cdeb998443ff3fce194ffae5f518c6a079b832dbafc4426937f9"},
{file = "pyobjc_framework_cocoa-11.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:7b9a9b8ba07f5bf84866399e3de2aa311ed1c34d5d2788a995bdbe82cc36cfa0"},
{file = "pyobjc_framework_cocoa-11.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:806de56f06dfba8f301a244cce289d54877c36b4b19818e3b53150eb7c2424d0"},
{file = "pyobjc_framework_cocoa-11.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:54e93e1d9b0fc41c032582a6f0834befe1d418d73893968f3f450281b11603da"},
{file = "pyobjc_framework_cocoa-11.1-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:fd5245ee1997d93e78b72703be1289d75d88ff6490af94462b564892e9266350"},
{file = "pyobjc_framework_cocoa-11.1-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:aede53a1afc5433e1e7d66568cc52acceeb171b0a6005407a42e8e82580b4fc0"},
{file = "pyobjc_framework_cocoa-11.1-cp314-cp314t-macosx_11_0_universal2.whl", hash = "sha256:1b5de4e1757bb65689d6dc1f8d8717de9ec8587eb0c4831c134f13aba29f9b71"},
{file = "pyobjc_framework_cocoa-11.1-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:bbee71eeb93b1b31ffbac8560b59a0524a8a4b90846a260d2c4f2188f3d4c721"},
{file = "pyobjc_framework_cocoa-11.1.tar.gz", hash = "sha256:87df76b9b73e7ca699a828ff112564b59251bb9bbe72e610e670a4dc9940d038"},
]
[package.dependencies]
pyobjc-core = ">=11.1"
[[package]]
name = "pyobjc-framework-corebluetooth"
version = "11.1"
description = "Wrappers for the framework CoreBluetooth on macOS"
optional = false
python-versions = ">=3.9"
groups = ["main"]
markers = "platform_system == \"Darwin\""
files = [
{file = "pyobjc_framework_corebluetooth-11.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:ab509994503a5f0ec0f446a7ccc9f9a672d5a427d40dba4563dd00e8e17dfb06"},
{file = "pyobjc_framework_corebluetooth-11.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:433b8593eb1ea8b6262b243ec903e1de4434b768ce103ebe15aac249b890cc2a"},
{file = "pyobjc_framework_corebluetooth-11.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:36bef95a822c68b72f505cf909913affd61a15b56eeaeafea7302d35a82f4f05"},
{file = "pyobjc_framework_corebluetooth-11.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:992404b03033ecf637e9174caed70cb22fd1be2a98c16faa699217678e62a5c7"},
{file = "pyobjc_framework_corebluetooth-11.1-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:ebb8648f5e33d98446eb1d6c4654ba4fcc15d62bfcb47fa3bbd5596f6ecdb37c"},
{file = "pyobjc_framework_corebluetooth-11.1-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:e84cbf52006a93d937b90421ada0bc4a146d6d348eb40ae10d5bd2256cc92206"},
{file = "pyobjc_framework_corebluetooth-11.1-cp314-cp314t-macosx_11_0_universal2.whl", hash = "sha256:4da1106265d7efd3f726bacdf13ba9528cc380fb534b5af38b22a397e6908291"},
{file = "pyobjc_framework_corebluetooth-11.1-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:e9fa3781fea20a31b3bb809deaeeab3bdc7b86602a1fd829f0e86db11d7aa577"},
{file = "pyobjc_framework_corebluetooth-11.1.tar.gz", hash = "sha256:1deba46e3fcaf5e1c314f4bbafb77d9fe49ec248c493ad00d8aff2df212d6190"},
]
[package.dependencies]
pyobjc-core = ">=11.1"
pyobjc-framework-Cocoa = ">=11.1"
[[package]]
name = "pyobjc-framework-libdispatch"
version = "11.1"
description = "Wrappers for libdispatch on macOS"
optional = false
python-versions = ">=3.9"
groups = ["main"]
markers = "platform_system == \"Darwin\""
files = [
{file = "pyobjc_framework_libdispatch-11.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:9c598c073a541b5956b5457b94bd33b9ce19ef8d867235439a0fad22d6beab49"},
{file = "pyobjc_framework_libdispatch-11.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:2ddca472c2cbc6bb192e05b8b501d528ce49333abe7ef0eef28df3133a8e18b7"},
{file = "pyobjc_framework_libdispatch-11.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:dc9a7b8c2e8a63789b7cf69563bb7247bde15353208ef1353fff0af61b281684"},
{file = "pyobjc_framework_libdispatch-11.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:c4e219849f5426745eb429f3aee58342a59f81e3144b37aa20e81dacc6177de1"},
{file = "pyobjc_framework_libdispatch-11.1-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:a9357736cb47b4a789f59f8fab9b0d10b0a9c84f9876367c398718d3de085888"},
{file = "pyobjc_framework_libdispatch-11.1-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:cd08f32ea7724906ef504a0fd40a32e2a0be4d64b9239530a31767ca9ccfc921"},
{file = "pyobjc_framework_libdispatch-11.1-cp314-cp314t-macosx_11_0_universal2.whl", hash = "sha256:5d9985b0e050cae72bf2c6a1cc8180ff4fa3a812cd63b2dc59e09c6f7f6263a1"},
{file = "pyobjc_framework_libdispatch-11.1-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:cfe515f4c3ea66c13fce4a527230027517b8b779b40bbcb220ff7cdf3ad20bc4"},
{file = "pyobjc_framework_libdispatch-11.1.tar.gz", hash = "sha256:11a704e50a0b7dbfb01552b7d686473ffa63b5254100fdb271a1fe368dd08e87"},
]
[package.dependencies]
pyobjc-core = ">=11.1"
pyobjc-framework-Cocoa = ">=11.1"
[[package]]
name = "pyric"
version = "0.1.6.3"
description = "Python Wireless Library"
optional = false
python-versions = "*"
groups = ["main"]
files = [
{file = "PyRIC-0.1.6.3.tar.gz", hash = "sha256:b539b01cafebd2406c00097f94525ea0f8ecd1dd92f7731f43eac0ef16c2ccc9"},
]
[[package]]
name = "pytest"
version = "8.4.2"
description = "pytest: simple powerful testing with Python"
optional = false
python-versions = ">=3.9"
groups = ["dev"]
files = [
{file = "pytest-8.4.2-py3-none-any.whl", hash = "sha256:872f880de3fc3a5bdc88a11b39c9710c3497a547cfa9320bc3c5e62fbf272e79"},
{file = "pytest-8.4.2.tar.gz", hash = "sha256:86c0d0b93306b961d58d62a4db4879f27fe25513d4b969df351abdddb3c30e01"},
]
[package.dependencies]
colorama = {version = ">=0.4", markers = "sys_platform == \"win32\""}
iniconfig = ">=1"
packaging = ">=20"
pluggy = ">=1.5,<2"
pygments = ">=2.7.2"
[package.extras]
dev = ["argcomplete", "attrs (>=19.2)", "hypothesis (>=3.56)", "mock", "requests", "setuptools", "xmlschema"]
[[package]]
name = "pytest-asyncio"
version = "1.2.0"
description = "Pytest support for asyncio"
optional = false
python-versions = ">=3.9"
groups = ["dev"]
files = [
{file = "pytest_asyncio-1.2.0-py3-none-any.whl", hash = "sha256:8e17ae5e46d8e7efe51ab6494dd2010f4ca8dae51652aa3c8d55acf50bfb2e99"},
{file = "pytest_asyncio-1.2.0.tar.gz", hash = "sha256:c609a64a2a8768462d0c99811ddb8bd2583c33fd33cf7f21af1c142e824ffb57"},
]
[package.dependencies]
pytest = ">=8.2,<9"
typing-extensions = {version = ">=4.12", markers = "python_version < \"3.13\""}
[package.extras]
docs = ["sphinx (>=5.3)", "sphinx-rtd-theme (>=1)"]
testing = ["coverage (>=6.2)", "hypothesis (>=5.7.1)"]
[[package]]
name = "pytest-codspeed"
version = "4.0.0"
description = "Pytest plugin to create CodSpeed benchmarks"
optional = false
python-versions = ">=3.9"
groups = ["dev"]
files = [
{file = "pytest_codspeed-4.0.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2517731b20a6aa9fe61d04822b802e1637ee67fd865189485b384a9d5897117f"},
{file = "pytest_codspeed-4.0.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1e5076bb5119d4f8248822b5cd6b768f70a18c7e1a7fbcd96a99cd4a6430096e"},
{file = "pytest_codspeed-4.0.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:06b324acdfe2076a0c97a9d31e8645f820822d6f0e766c73426767ff887a9381"},
{file = "pytest_codspeed-4.0.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9ebdac1a4d6138e1ca4f5391e7e3cafad6e3aa6d5660d1b243871b691bc1396c"},
{file = "pytest_codspeed-4.0.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7f3def79d4072867d038a33e7f35bc7fb1a2a75236a624b3a690c5540017cb38"},
{file = "pytest_codspeed-4.0.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:01d29d4538c2d111c0034f71811bcce577304506d22af4dd65df87fadf3ab495"},
{file = "pytest_codspeed-4.0.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:90894c93c9e23f12487b7fdf16c28da8f6275d565056772072beb41a72a54cf9"},
{file = "pytest_codspeed-4.0.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:79e9c40852fa7fc76776db4f1d290eceaeee2d6c5d2dc95a66c7cc690d83889e"},
{file = "pytest_codspeed-4.0.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7330b6eadd6a729d4dba95d26496ee1c6f1649d552f515ef537b14a43908eb67"},
{file = "pytest_codspeed-4.0.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e1271cd28e895132b20d12875554a544ee041f7acfb8112af8a5c3cb201f2fc8"},
{file = "pytest_codspeed-4.0.0-py3-none-any.whl", hash = "sha256:c5debd4b127dc1c507397a8304776f52cabbfa53aad6f51eae329a5489df1e06"},
{file = "pytest_codspeed-4.0.0.tar.gz", hash = "sha256:0e9af08ca93ad897b376771db92693a81aa8990eecc2a778740412e00a6f6eaf"},
]
[package.dependencies]
cffi = ">=1.17.1"
pytest = ">=3.8"
rich = ">=13.8.1"
[package.extras]
compat = ["pytest-benchmark (>=5.0.0,<5.1.0)", "pytest-xdist (>=3.6.1,<3.7.0)"]
lint = ["mypy (>=1.11.2,<1.12.0)", "ruff (>=0.11.12,<0.12.0)"]
test = ["pytest (>=7.0,<8.0)", "pytest-cov (>=4.0.0,<4.1.0)"]
[[package]]
name = "pytest-cov"
version = "7.0.0"
description = "Pytest plugin for measuring coverage."
optional = false
python-versions = ">=3.9"
groups = ["dev"]
files = [
{file = "pytest_cov-7.0.0-py3-none-any.whl", hash = "sha256:3b8e9558b16cc1479da72058bdecf8073661c7f57f7d3c5f22a1c23507f2d861"},
{file = "pytest_cov-7.0.0.tar.gz", hash = "sha256:33c97eda2e049a0c5298e91f519302a1334c26ac65c1a483d6206fd458361af1"},
]
[package.dependencies]
coverage = {version = ">=7.10.6", extras = ["toml"]}
pluggy = ">=1.2"
pytest = ">=7"
[package.extras]
testing = ["process-tests", "pytest-xdist", "virtualenv"]
[[package]]
name = "python-dateutil"
version = "2.9.0.post0"
description = "Extensions to the standard Python datetime module"
optional = false
python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,>=2.7"
groups = ["dev"]
files = [
{file = "python-dateutil-2.9.0.post0.tar.gz", hash = "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3"},
{file = "python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427"},
]
[package.dependencies]
six = ">=1.5"
[[package]]
name = "pyyaml"
version = "6.0.2"
description = "YAML parser and emitter for Python"
optional = false
python-versions = ">=3.8"
groups = ["docs"]
files = [
{file = "PyYAML-6.0.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:0a9a2848a5b7feac301353437eb7d5957887edbf81d56e903999a75a3d743086"},
{file = "PyYAML-6.0.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:29717114e51c84ddfba879543fb232a6ed60086602313ca38cce623c1d62cfbf"},
{file = "PyYAML-6.0.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8824b5a04a04a047e72eea5cec3bc266db09e35de6bdfe34c9436ac5ee27d237"},
{file = "PyYAML-6.0.2-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:7c36280e6fb8385e520936c3cb3b8042851904eba0e58d277dca80a5cfed590b"},
{file = "PyYAML-6.0.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ec031d5d2feb36d1d1a24380e4db6d43695f3748343d99434e6f5f9156aaa2ed"},
{file = "PyYAML-6.0.2-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:936d68689298c36b53b29f23c6dbb74de12b4ac12ca6cfe0e047bedceea56180"},
{file = "PyYAML-6.0.2-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:23502f431948090f597378482b4812b0caae32c22213aecf3b55325e049a6c68"},
{file = "PyYAML-6.0.2-cp310-cp310-win32.whl", hash = "sha256:2e99c6826ffa974fe6e27cdb5ed0021786b03fc98e5ee3c5bfe1fd5015f42b99"},
{file = "PyYAML-6.0.2-cp310-cp310-win_amd64.whl", hash = "sha256:a4d3091415f010369ae4ed1fc6b79def9416358877534caf6a0fdd2146c87a3e"},
{file = "PyYAML-6.0.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:cc1c1159b3d456576af7a3e4d1ba7e6924cb39de8f67111c735f6fc832082774"},
{file = "PyYAML-6.0.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:1e2120ef853f59c7419231f3bf4e7021f1b936f6ebd222406c3b60212205d2ee"},
{file = "PyYAML-6.0.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5d225db5a45f21e78dd9358e58a98702a0302f2659a3c6cd320564b75b86f47c"},
{file = "PyYAML-6.0.2-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:5ac9328ec4831237bec75defaf839f7d4564be1e6b25ac710bd1a96321cc8317"},
{file = "PyYAML-6.0.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3ad2a3decf9aaba3d29c8f537ac4b243e36bef957511b4766cb0057d32b0be85"},
{file = "PyYAML-6.0.2-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:ff3824dc5261f50c9b0dfb3be22b4567a6f938ccce4587b38952d85fd9e9afe4"},
{file = "PyYAML-6.0.2-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:797b4f722ffa07cc8d62053e4cff1486fa6dc094105d13fea7b1de7d8bf71c9e"},
{file = "PyYAML-6.0.2-cp311-cp311-win32.whl", hash = "sha256:11d8f3dd2b9c1207dcaf2ee0bbbfd5991f571186ec9cc78427ba5bd32afae4b5"},
{file = "PyYAML-6.0.2-cp311-cp311-win_amd64.whl", hash = "sha256:e10ce637b18caea04431ce14fabcf5c64a1c61ec9c56b071a4b7ca131ca52d44"},
{file = "PyYAML-6.0.2-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:c70c95198c015b85feafc136515252a261a84561b7b1d51e3384e0655ddf25ab"},
{file = "PyYAML-6.0.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ce826d6ef20b1bc864f0a68340c8b3287705cae2f8b4b1d932177dcc76721725"},
{file = "PyYAML-6.0.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1f71ea527786de97d1a0cc0eacd1defc0985dcf6b3f17bb77dcfc8c34bec4dc5"},
{file = "PyYAML-6.0.2-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9b22676e8097e9e22e36d6b7bda33190d0d400f345f23d4065d48f4ca7ae0425"},
{file = "PyYAML-6.0.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:80bab7bfc629882493af4aa31a4cfa43a4c57c83813253626916b8c7ada83476"},
{file = "PyYAML-6.0.2-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:0833f8694549e586547b576dcfaba4a6b55b9e96098b36cdc7ebefe667dfed48"},
{file = "PyYAML-6.0.2-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:8b9c7197f7cb2738065c481a0461e50ad02f18c78cd75775628afb4d7137fb3b"},
{file = "PyYAML-6.0.2-cp312-cp312-win32.whl", hash = "sha256:ef6107725bd54b262d6dedcc2af448a266975032bc85ef0172c5f059da6325b4"},
{file = "PyYAML-6.0.2-cp312-cp312-win_amd64.whl", hash = "sha256:7e7401d0de89a9a855c839bc697c079a4af81cf878373abd7dc625847d25cbd8"},
{file = "PyYAML-6.0.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:efdca5630322a10774e8e98e1af481aad470dd62c3170801852d752aa7a783ba"},
{file = "PyYAML-6.0.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:50187695423ffe49e2deacb8cd10510bc361faac997de9efef88badc3bb9e2d1"},
{file = "PyYAML-6.0.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0ffe8360bab4910ef1b9e87fb812d8bc0a308b0d0eef8c8f44e0254ab3b07133"},
{file = "PyYAML-6.0.2-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:17e311b6c678207928d649faa7cb0d7b4c26a0ba73d41e99c4fff6b6c3276484"},
{file = "PyYAML-6.0.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:70b189594dbe54f75ab3a1acec5f1e3faa7e8cf2f1e08d9b561cb41b845f69d5"},
{file = "PyYAML-6.0.2-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:41e4e3953a79407c794916fa277a82531dd93aad34e29c2a514c2c0c5fe971cc"},
{file = "PyYAML-6.0.2-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:68ccc6023a3400877818152ad9a1033e3db8625d899c72eacb5a668902e4d652"},
{file = "PyYAML-6.0.2-cp313-cp313-win32.whl", hash = "sha256:bc2fa7c6b47d6bc618dd7fb02ef6fdedb1090ec036abab80d4681424b84c1183"},
{file = "PyYAML-6.0.2-cp313-cp313-win_amd64.whl", hash = "sha256:8388ee1976c416731879ac16da0aff3f63b286ffdd57cdeb95f3f2e085687563"},
{file = "PyYAML-6.0.2-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:24471b829b3bf607e04e88d79542a9d48bb037c2267d7927a874e6c205ca7e9a"},
{file = "PyYAML-6.0.2-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d7fded462629cfa4b685c5416b949ebad6cec74af5e2d42905d41e257e0869f5"},
{file = "PyYAML-6.0.2-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d84a1718ee396f54f3a086ea0a66d8e552b2ab2017ef8b420e92edbc841c352d"},
{file = "PyYAML-6.0.2-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9056c1ecd25795207ad294bcf39f2db3d845767be0ea6e6a34d856f006006083"},
{file = "PyYAML-6.0.2-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:82d09873e40955485746739bcb8b4586983670466c23382c19cffecbf1fd8706"},
{file = "PyYAML-6.0.2-cp38-cp38-win32.whl", hash = "sha256:43fa96a3ca0d6b1812e01ced1044a003533c47f6ee8aca31724f78e93ccc089a"},
{file = "PyYAML-6.0.2-cp38-cp38-win_amd64.whl", hash = "sha256:01179a4a8559ab5de078078f37e5c1a30d76bb88519906844fd7bdea1b7729ff"},
{file = "PyYAML-6.0.2-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:688ba32a1cffef67fd2e9398a2efebaea461578b0923624778664cc1c914db5d"},
{file = "PyYAML-6.0.2-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:a8786accb172bd8afb8be14490a16625cbc387036876ab6ba70912730faf8e1f"},
{file = "PyYAML-6.0.2-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d8e03406cac8513435335dbab54c0d385e4a49e4945d2909a581c83647ca0290"},
{file = "PyYAML-6.0.2-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f753120cb8181e736c57ef7636e83f31b9c0d1722c516f7e86cf15b7aa57ff12"},
{file = "PyYAML-6.0.2-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3b1fdb9dc17f5a7677423d508ab4f243a726dea51fa5e70992e59a7411c89d19"},
{file = "PyYAML-6.0.2-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:0b69e4ce7a131fe56b7e4d770c67429700908fc0752af059838b1cfb41960e4e"},
{file = "PyYAML-6.0.2-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:a9f8c2e67970f13b16084e04f134610fd1d374bf477b17ec1599185cf611d725"},
{file = "PyYAML-6.0.2-cp39-cp39-win32.whl", hash = "sha256:6395c297d42274772abc367baaa79683958044e5d3835486c16da75d2a694631"},
{file = "PyYAML-6.0.2-cp39-cp39-win_amd64.whl", hash = "sha256:39693e1f8320ae4f43943590b49779ffb98acb81f788220ea932a6b6c51004d8"},
{file = "pyyaml-6.0.2.tar.gz", hash = "sha256:d584d9ec91ad65861cc08d42e834324ef890a082e591037abe114850ff7bbc3e"},
]
[[package]]
name = "requests"
version = "2.32.4"
description = "Python HTTP for Humans."
optional = false
python-versions = ">=3.8"
groups = ["docs"]
files = [
{file = "requests-2.32.4-py3-none-any.whl", hash = "sha256:27babd3cda2a6d50b30443204ee89830707d396671944c998b5975b031ac2b2c"},
{file = "requests-2.32.4.tar.gz", hash = "sha256:27d0316682c8a29834d3264820024b62a36942083d52caf2f14c0591336d3422"},
]
[package.dependencies]
certifi = ">=2017.4.17"
charset_normalizer = ">=2,<4"
idna = ">=2.5,<4"
urllib3 = ">=1.21.1,<3"
[package.extras]
socks = ["PySocks (>=1.5.6,!=1.5.7)"]
use-chardet-on-py3 = ["chardet (>=3.0.2,<6)"]
[[package]]
name = "rich"
version = "14.0.0"
description = "Render rich text, tables, progress bars, syntax highlighting, markdown and more to the terminal"
optional = false
python-versions = ">=3.8.0"
groups = ["dev"]
files = [
{file = "rich-14.0.0-py3-none-any.whl", hash = "sha256:1c9491e1951aac09caffd42f448ee3d04e58923ffe14993f6e83068dc395d7e0"},
{file = "rich-14.0.0.tar.gz", hash = "sha256:82f1bc23a6a21ebca4ae0c45af9bdbc492ed20231dcb63f297d6d1021a9d5725"},
]
[package.dependencies]
markdown-it-py = ">=2.2.0"
pygments = ">=2.13.0,<3.0.0"
[package.extras]
jupyter = ["ipywidgets (>=7.5.1,<9)"]
[[package]]
name = "roman-numerals-py"
version = "3.1.0"
description = "Manipulate well-formed Roman numerals"
optional = false
python-versions = ">=3.9"
groups = ["docs"]
files = [
{file = "roman_numerals_py-3.1.0-py3-none-any.whl", hash = "sha256:9da2ad2fb670bcf24e81070ceb3be72f6c11c440d73bd579fbeca1e9f330954c"},
{file = "roman_numerals_py-3.1.0.tar.gz", hash = "sha256:be4bf804f083a4ce001b5eb7e3c0862479d10f94c936f6c4e5f250aa5ff5bd2d"},
]
[package.extras]
lint = ["mypy (==1.15.0)", "pyright (==1.1.394)", "ruff (==0.9.7)"]
test = ["pytest (>=8)"]
[[package]]
name = "six"
version = "1.17.0"
description = "Python 2 and 3 compatibility utilities"
optional = false
python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,>=2.7"
groups = ["dev"]
files = [
{file = "six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274"},
{file = "six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81"},
]
[[package]]
name = "sniffio"
version = "1.3.1"
description = "Sniff out which async library your code is running under"
optional = false
python-versions = ">=3.7"
groups = ["docs"]
files = [
{file = "sniffio-1.3.1-py3-none-any.whl", hash = "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2"},
{file = "sniffio-1.3.1.tar.gz", hash = "sha256:f4324edc670a0f49750a81b895f35c3adb843cca46f0530f79fc1babb23789dc"},
]
[[package]]
name = "snowballstemmer"
version = "3.0.1"
description = "This package provides 32 stemmers for 30 languages generated from Snowball algorithms."
optional = false
python-versions = "!=3.0.*,!=3.1.*,!=3.2.*"
groups = ["docs"]
files = [
{file = "snowballstemmer-3.0.1-py3-none-any.whl", hash = "sha256:6cd7b3897da8d6c9ffb968a6781fa6532dce9c3618a4b127d920dab764a19064"},
{file = "snowballstemmer-3.0.1.tar.gz", hash = "sha256:6d5eeeec8e9f84d4d56b847692bacf79bc2c8e90c7f80ca4444ff8b6f2e52895"},
]
[[package]]
name = "soupsieve"
version = "2.7"
description = "A modern CSS selector implementation for Beautiful Soup."
optional = false
python-versions = ">=3.8"
groups = ["docs"]
files = [
{file = "soupsieve-2.7-py3-none-any.whl", hash = "sha256:6e60cc5c1ffaf1cebcc12e8188320b72071e922c2e897f737cadce79ad5d30c4"},
{file = "soupsieve-2.7.tar.gz", hash = "sha256:ad282f9b6926286d2ead4750552c8a6142bc4c783fd66b0293547c8fe6ae126a"},
]
[[package]]
name = "sphinx"
version = "8.2.3"
description = "Python documentation generator"
optional = false
python-versions = ">=3.11"
groups = ["docs"]
files = [
{file = "sphinx-8.2.3-py3-none-any.whl", hash = "sha256:4405915165f13521d875a8c29c8970800a0141c14cc5416a38feca4ea5d9b9c3"},
{file = "sphinx-8.2.3.tar.gz", hash = "sha256:398ad29dee7f63a75888314e9424d40f52ce5a6a87ae88e7071e80af296ec348"},
]
[package.dependencies]
alabaster = ">=0.7.14"
babel = ">=2.13"
colorama = {version = ">=0.4.6", markers = "sys_platform == \"win32\""}
docutils = ">=0.20,<0.22"
imagesize = ">=1.3"
Jinja2 = ">=3.1"
packaging = ">=23.0"
Pygments = ">=2.17"
requests = ">=2.30.0"
roman-numerals-py = ">=1.0.0"
snowballstemmer = ">=2.2"
sphinxcontrib-applehelp = ">=1.0.7"
sphinxcontrib-devhelp = ">=1.0.6"
sphinxcontrib-htmlhelp = ">=2.0.6"
sphinxcontrib-jsmath = ">=1.0.1"
sphinxcontrib-qthelp = ">=1.0.6"
sphinxcontrib-serializinghtml = ">=1.1.9"
[package.extras]
docs = ["sphinxcontrib-websupport"]
lint = ["betterproto (==2.0.0b6)", "mypy (==1.15.0)", "pypi-attestations (==0.0.21)", "pyright (==1.1.395)", "pytest (>=8.0)", "ruff (==0.9.9)", "sphinx-lint (>=0.9)", "types-Pillow (==10.2.0.20240822)", "types-Pygments (==2.19.0.20250219)", "types-colorama (==0.4.15.20240311)", "types-defusedxml (==0.7.0.20240218)", "types-docutils (==0.21.0.20241128)", "types-requests (==2.32.0.20241016)", "types-urllib3 (==1.26.25.14)"]
test = ["cython (>=3.0)", "defusedxml (>=0.7.1)", "pytest (>=8.0)", "pytest-xdist[psutil] (>=3.4)", "setuptools (>=70.0)", "typing_extensions (>=4.9)"]
[[package]]
name = "sphinx-autobuild"
version = "2025.8.25"
description = "Rebuild Sphinx documentation on changes, with hot reloading in the browser."
optional = false
python-versions = ">=3.11"
groups = ["docs"]
files = [
{file = "sphinx_autobuild-2025.8.25-py3-none-any.whl", hash = "sha256:b750ac7d5a18603e4665294323fd20f6dcc0a984117026d1986704fa68f0379a"},
{file = "sphinx_autobuild-2025.8.25.tar.gz", hash = "sha256:9cf5aab32853c8c31af572e4fecdc09c997e2b8be5a07daf2a389e270e85b213"},
]
[package.dependencies]
colorama = ">=0.4.6"
Sphinx = "*"
starlette = ">=0.35"
uvicorn = ">=0.25"
watchfiles = ">=0.20"
websockets = ">=11"
[package.extras]
test = ["httpx", "pytest (>=6)"]
[[package]]
name = "sphinx-basic-ng"
version = "1.0.0b2"
description = "A modern skeleton for Sphinx themes."
optional = false
python-versions = ">=3.7"
groups = ["docs"]
files = [
{file = "sphinx_basic_ng-1.0.0b2-py3-none-any.whl", hash = "sha256:eb09aedbabfb650607e9b4b68c9d240b90b1e1be221d6ad71d61c52e29f7932b"},
{file = "sphinx_basic_ng-1.0.0b2.tar.gz", hash = "sha256:9ec55a47c90c8c002b5960c57492ec3021f5193cb26cebc2dc4ea226848651c9"},
]
[package.dependencies]
sphinx = ">=4.0"
[package.extras]
docs = ["furo", "ipython", "myst-parser", "sphinx-copybutton", "sphinx-inline-tabs"]
[[package]]
name = "sphinxcontrib-applehelp"
version = "2.0.0"
description = "sphinxcontrib-applehelp is a Sphinx extension which outputs Apple help books"
optional = false
python-versions = ">=3.9"
groups = ["docs"]
files = [
{file = "sphinxcontrib_applehelp-2.0.0-py3-none-any.whl", hash = "sha256:4cd3f0ec4ac5dd9c17ec65e9ab272c9b867ea77425228e68ecf08d6b28ddbdb5"},
{file = "sphinxcontrib_applehelp-2.0.0.tar.gz", hash = "sha256:2f29ef331735ce958efa4734873f084941970894c6090408b079c61b2e1c06d1"},
]
[package.extras]
lint = ["mypy", "ruff (==0.5.5)", "types-docutils"]
standalone = ["Sphinx (>=5)"]
test = ["pytest"]
[[package]]
name = "sphinxcontrib-devhelp"
version = "2.0.0"
description = "sphinxcontrib-devhelp is a sphinx extension which outputs Devhelp documents"
optional = false
python-versions = ">=3.9"
groups = ["docs"]
files = [
{file = "sphinxcontrib_devhelp-2.0.0-py3-none-any.whl", hash = "sha256:aefb8b83854e4b0998877524d1029fd3e6879210422ee3780459e28a1f03a8a2"},
{file = "sphinxcontrib_devhelp-2.0.0.tar.gz", hash = "sha256:411f5d96d445d1d73bb5d52133377b4248ec79db5c793ce7dbe59e074b4dd1ad"},
]
[package.extras]
lint = ["mypy", "ruff (==0.5.5)", "types-docutils"]
standalone = ["Sphinx (>=5)"]
test = ["pytest"]
[[package]]
name = "sphinxcontrib-htmlhelp"
version = "2.1.0"
description = "sphinxcontrib-htmlhelp is a sphinx extension which renders HTML help files"
optional = false
python-versions = ">=3.9"
groups = ["docs"]
files = [
{file = "sphinxcontrib_htmlhelp-2.1.0-py3-none-any.whl", hash = "sha256:166759820b47002d22914d64a075ce08f4c46818e17cfc9470a9786b759b19f8"},
{file = "sphinxcontrib_htmlhelp-2.1.0.tar.gz", hash = "sha256:c9e2916ace8aad64cc13a0d233ee22317f2b9025b9cf3295249fa985cc7082e9"},
]
[package.extras]
lint = ["mypy", "ruff (==0.5.5)", "types-docutils"]
standalone = ["Sphinx (>=5)"]
test = ["html5lib", "pytest"]
[[package]]
name = "sphinxcontrib-jsmath"
version = "1.0.1"
description = "A sphinx extension which renders display math in HTML via JavaScript"
optional = false
python-versions = ">=3.5"
groups = ["docs"]
files = [
{file = "sphinxcontrib-jsmath-1.0.1.tar.gz", hash = "sha256:a9925e4a4587247ed2191a22df5f6970656cb8ca2bd6284309578f2153e0c4b8"},
{file = "sphinxcontrib_jsmath-1.0.1-py2.py3-none-any.whl", hash = "sha256:2ec2eaebfb78f3f2078e73666b1415417a116cc848b72e5172e596c871103178"},
]
[package.extras]
test = ["flake8", "mypy", "pytest"]
[[package]]
name = "sphinxcontrib-qthelp"
version = "2.0.0"
description = "sphinxcontrib-qthelp is a sphinx extension which outputs QtHelp documents"
optional = false
python-versions = ">=3.9"
groups = ["docs"]
files = [
{file = "sphinxcontrib_qthelp-2.0.0-py3-none-any.whl", hash = "sha256:b18a828cdba941ccd6ee8445dbe72ffa3ef8cbe7505d8cd1fa0d42d3f2d5f3eb"},
{file = "sphinxcontrib_qthelp-2.0.0.tar.gz", hash = "sha256:4fe7d0ac8fc171045be623aba3e2a8f613f8682731f9153bb2e40ece16b9bbab"},
]
[package.extras]
lint = ["mypy", "ruff (==0.5.5)", "types-docutils"]
standalone = ["Sphinx (>=5)"]
test = ["defusedxml (>=0.7.1)", "pytest"]
[[package]]
name = "sphinxcontrib-serializinghtml"
version = "2.0.0"
description = "sphinxcontrib-serializinghtml is a sphinx extension which outputs \"serialized\" HTML files (json and pickle)"
optional = false
python-versions = ">=3.9"
groups = ["docs"]
files = [
{file = "sphinxcontrib_serializinghtml-2.0.0-py3-none-any.whl", hash = "sha256:6e2cb0eef194e10c27ec0023bfeb25badbbb5868244cf5bc5bdc04e4464bf331"},
{file = "sphinxcontrib_serializinghtml-2.0.0.tar.gz", hash = "sha256:e9d912827f872c029017a53f0ef2180b327c3f7fd23c87229f7a8e8b70031d4d"},
]
[package.extras]
lint = ["mypy", "ruff (==0.5.5)", "types-docutils"]
standalone = ["Sphinx (>=5)"]
test = ["pytest"]
[[package]]
name = "starlette"
version = "0.47.2"
description = "The little ASGI library that shines."
optional = false
python-versions = ">=3.9"
groups = ["docs"]
files = [
{file = "starlette-0.47.2-py3-none-any.whl", hash = "sha256:c5847e96134e5c5371ee9fac6fdf1a67336d5815e09eb2a01fdb57a351ef915b"},
{file = "starlette-0.47.2.tar.gz", hash = "sha256:6ae9aa5db235e4846decc1e7b79c4f346adf41e9777aebeb49dfd09bbd7023d8"},
]
[package.dependencies]
anyio = ">=3.6.2,<5"
typing-extensions = {version = ">=4.10.0", markers = "python_version < \"3.13\""}
[package.extras]
full = ["httpx (>=0.27.0,<0.29.0)", "itsdangerous", "jinja2", "python-multipart (>=0.0.18)", "pyyaml"]
[[package]]
name = "typing-extensions"
version = "4.14.0"
description = "Backported and Experimental Type Hints for Python 3.9+"
optional = false
python-versions = ">=3.9"
groups = ["main", "dev", "docs"]
files = [
{file = "typing_extensions-4.14.0-py3-none-any.whl", hash = "sha256:a1514509136dd0b477638fc68d6a91497af5076466ad0fa6c338e44e359944af"},
{file = "typing_extensions-4.14.0.tar.gz", hash = "sha256:8676b788e32f02ab42d9e7c61324048ae4c6d844a399eebace3d4979d75ceef4"},
]
markers = {main = "python_version == \"3.11\" or platform_system == \"Windows\"", dev = "python_version < \"3.13\""}
[[package]]
name = "uart-devices"
version = "0.1.1"
description = "UART Devices for Linux"
optional = false
python-versions = "<4.0,>=3.9"
groups = ["main"]
files = [
{file = "uart_devices-0.1.1-py3-none-any.whl", hash = "sha256:55bc8cce66465e90b298f0910e5c496bc7be021341c5455954cf61c6253dc123"},
{file = "uart_devices-0.1.1.tar.gz", hash = "sha256:3a52c4ae0f5f7400ebe1ae5f6e2a2d40cc0b7f18a50e895236535c4e53c6ed34"},
]
[[package]]
name = "urllib3"
version = "2.5.0"
description = "HTTP library with thread-safe connection pooling, file post, and more."
optional = false
python-versions = ">=3.9"
groups = ["docs"]
files = [
{file = "urllib3-2.5.0-py3-none-any.whl", hash = "sha256:e6b01673c0fa6a13e374b50871808eb3bf7046c4b125b216f6bf1cc604cff0dc"},
{file = "urllib3-2.5.0.tar.gz", hash = "sha256:3fc47733c7e419d4bc3f6b3dc2b4f890bb743906a30d56ba4a5bfa4bbff92760"},
]
[package.extras]
brotli = ["brotli (>=1.0.9) ; platform_python_implementation == \"CPython\"", "brotlicffi (>=0.8.0) ; platform_python_implementation != \"CPython\""]
h2 = ["h2 (>=4,<5)"]
socks = ["pysocks (>=1.5.6,!=1.5.7,<2.0)"]
zstd = ["zstandard (>=0.18.0)"]
[[package]]
name = "usb-devices"
version = "0.4.5"
description = "Tools for mapping, describing, and resetting USB devices"
optional = false
python-versions = ">=3.9,<4.0"
groups = ["main"]
files = [
{file = "usb_devices-0.4.5-py3-none-any.whl", hash = "sha256:8a415219ef1395e25aa0bddcad484c88edf9673acdeae8a07223ca7222a01dcf"},
{file = "usb_devices-0.4.5.tar.gz", hash = "sha256:9b5c7606df2bc791c6c45b7f76244a0cbed83cb6fa4c68791a143c03345e195d"},
]
[[package]]
name = "uvicorn"
version = "0.35.0"
description = "The lightning-fast ASGI server."
optional = false
python-versions = ">=3.9"
groups = ["docs"]
files = [
{file = "uvicorn-0.35.0-py3-none-any.whl", hash = "sha256:197535216b25ff9b785e29a0b79199f55222193d47f820816e7da751e9bc8d4a"},
{file = "uvicorn-0.35.0.tar.gz", hash = "sha256:bc662f087f7cf2ce11a1d7fd70b90c9f98ef2e2831556dd078d131b96cc94a01"},
]
[package.dependencies]
click = ">=7.0"
h11 = ">=0.8"
[package.extras]
standard = ["colorama (>=0.4) ; sys_platform == \"win32\"", "httptools (>=0.6.3)", "python-dotenv (>=0.13)", "pyyaml (>=5.1)", "uvloop (>=0.15.1) ; sys_platform != \"win32\" and sys_platform != \"cygwin\" and platform_python_implementation != \"PyPy\"", "watchfiles (>=0.13)", "websockets (>=10.4)"]
[[package]]
name = "watchfiles"
version = "1.1.0"
description = "Simple, modern and high performance file watching and code reload in python."
optional = false
python-versions = ">=3.9"
groups = ["docs"]
files = [
{file = "watchfiles-1.1.0-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:27f30e14aa1c1e91cb653f03a63445739919aef84c8d2517997a83155e7a2fcc"},
{file = "watchfiles-1.1.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:3366f56c272232860ab45c77c3ca7b74ee819c8e1f6f35a7125556b198bbc6df"},
{file = "watchfiles-1.1.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8412eacef34cae2836d891836a7fff7b754d6bcac61f6c12ba5ca9bc7e427b68"},
{file = "watchfiles-1.1.0-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:df670918eb7dd719642e05979fc84704af913d563fd17ed636f7c4783003fdcc"},
{file = "watchfiles-1.1.0-cp310-cp310-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d7642b9bc4827b5518ebdb3b82698ada8c14c7661ddec5fe719f3e56ccd13c97"},
{file = "watchfiles-1.1.0-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:199207b2d3eeaeb80ef4411875a6243d9ad8bc35b07fc42daa6b801cc39cc41c"},
{file = "watchfiles-1.1.0-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a479466da6db5c1e8754caee6c262cd373e6e6c363172d74394f4bff3d84d7b5"},
{file = "watchfiles-1.1.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:935f9edd022ec13e447e5723a7d14456c8af254544cefbc533f6dd276c9aa0d9"},
{file = "watchfiles-1.1.0-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:8076a5769d6bdf5f673a19d51da05fc79e2bbf25e9fe755c47595785c06a8c72"},
{file = "watchfiles-1.1.0-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:86b1e28d4c37e89220e924305cd9f82866bb0ace666943a6e4196c5df4d58dcc"},
{file = "watchfiles-1.1.0-cp310-cp310-win32.whl", hash = "sha256:d1caf40c1c657b27858f9774d5c0e232089bca9cb8ee17ce7478c6e9264d2587"},
{file = "watchfiles-1.1.0-cp310-cp310-win_amd64.whl", hash = "sha256:a89c75a5b9bc329131115a409d0acc16e8da8dfd5867ba59f1dd66ae7ea8fa82"},
{file = "watchfiles-1.1.0-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:c9649dfc57cc1f9835551deb17689e8d44666315f2e82d337b9f07bd76ae3aa2"},
{file = "watchfiles-1.1.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:406520216186b99374cdb58bc48e34bb74535adec160c8459894884c983a149c"},
{file = "watchfiles-1.1.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:cb45350fd1dc75cd68d3d72c47f5b513cb0578da716df5fba02fff31c69d5f2d"},
{file = "watchfiles-1.1.0-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:11ee4444250fcbeb47459a877e5e80ed994ce8e8d20283857fc128be1715dac7"},
{file = "watchfiles-1.1.0-cp311-cp311-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:bda8136e6a80bdea23e5e74e09df0362744d24ffb8cd59c4a95a6ce3d142f79c"},
{file = "watchfiles-1.1.0-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b915daeb2d8c1f5cee4b970f2e2c988ce6514aace3c9296e58dd64dc9aa5d575"},
{file = "watchfiles-1.1.0-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ed8fc66786de8d0376f9f913c09e963c66e90ced9aa11997f93bdb30f7c872a8"},
{file = "watchfiles-1.1.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fe4371595edf78c41ef8ac8df20df3943e13defd0efcb732b2e393b5a8a7a71f"},
{file = "watchfiles-1.1.0-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:b7c5f6fe273291f4d414d55b2c80d33c457b8a42677ad14b4b47ff025d0893e4"},
{file = "watchfiles-1.1.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:7738027989881e70e3723c75921f1efa45225084228788fc59ea8c6d732eb30d"},
{file = "watchfiles-1.1.0-cp311-cp311-win32.whl", hash = "sha256:622d6b2c06be19f6e89b1d951485a232e3b59618def88dbeda575ed8f0d8dbf2"},
{file = "watchfiles-1.1.0-cp311-cp311-win_amd64.whl", hash = "sha256:48aa25e5992b61debc908a61ab4d3f216b64f44fdaa71eb082d8b2de846b7d12"},
{file = "watchfiles-1.1.0-cp311-cp311-win_arm64.whl", hash = "sha256:00645eb79a3faa70d9cb15c8d4187bb72970b2470e938670240c7998dad9f13a"},
{file = "watchfiles-1.1.0-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:9dc001c3e10de4725c749d4c2f2bdc6ae24de5a88a339c4bce32300a31ede179"},
{file = "watchfiles-1.1.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:d9ba68ec283153dead62cbe81872d28e053745f12335d037de9cbd14bd1877f5"},
{file = "watchfiles-1.1.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:130fc497b8ee68dce163e4254d9b0356411d1490e868bd8790028bc46c5cc297"},
{file = "watchfiles-1.1.0-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:50a51a90610d0845a5931a780d8e51d7bd7f309ebc25132ba975aca016b576a0"},
{file = "watchfiles-1.1.0-cp312-cp312-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:dc44678a72ac0910bac46fa6a0de6af9ba1355669b3dfaf1ce5f05ca7a74364e"},
{file = "watchfiles-1.1.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a543492513a93b001975ae283a51f4b67973662a375a403ae82f420d2c7205ee"},
{file = "watchfiles-1.1.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8ac164e20d17cc285f2b94dc31c384bc3aa3dd5e7490473b3db043dd70fbccfd"},
{file = "watchfiles-1.1.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f7590d5a455321e53857892ab8879dce62d1f4b04748769f5adf2e707afb9d4f"},
{file = "watchfiles-1.1.0-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:37d3d3f7defb13f62ece99e9be912afe9dd8a0077b7c45ee5a57c74811d581a4"},
{file = "watchfiles-1.1.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:7080c4bb3efd70a07b1cc2df99a7aa51d98685be56be6038c3169199d0a1c69f"},
{file = "watchfiles-1.1.0-cp312-cp312-win32.whl", hash = "sha256:cbcf8630ef4afb05dc30107bfa17f16c0896bb30ee48fc24bf64c1f970f3b1fd"},
{file = "watchfiles-1.1.0-cp312-cp312-win_amd64.whl", hash = "sha256:cbd949bdd87567b0ad183d7676feb98136cde5bb9025403794a4c0db28ed3a47"},
{file = "watchfiles-1.1.0-cp312-cp312-win_arm64.whl", hash = "sha256:0a7d40b77f07be87c6faa93d0951a0fcd8cbca1ddff60a1b65d741bac6f3a9f6"},
{file = "watchfiles-1.1.0-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:5007f860c7f1f8df471e4e04aaa8c43673429047d63205d1630880f7637bca30"},
{file = "watchfiles-1.1.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:20ecc8abbd957046f1fe9562757903f5eaf57c3bce70929fda6c7711bb58074a"},
{file = "watchfiles-1.1.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f2f0498b7d2a3c072766dba3274fe22a183dbea1f99d188f1c6c72209a1063dc"},
{file = "watchfiles-1.1.0-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:239736577e848678e13b201bba14e89718f5c2133dfd6b1f7846fa1b58a8532b"},
{file = "watchfiles-1.1.0-cp313-cp313-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:eff4b8d89f444f7e49136dc695599a591ff769300734446c0a86cba2eb2f9895"},
{file = "watchfiles-1.1.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:12b0a02a91762c08f7264e2e79542f76870c3040bbc847fb67410ab81474932a"},
{file = "watchfiles-1.1.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:29e7bc2eee15cbb339c68445959108803dc14ee0c7b4eea556400131a8de462b"},
{file = "watchfiles-1.1.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d9481174d3ed982e269c090f780122fb59cee6c3796f74efe74e70f7780ed94c"},
{file = "watchfiles-1.1.0-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:80f811146831c8c86ab17b640801c25dc0a88c630e855e2bef3568f30434d52b"},
{file = "watchfiles-1.1.0-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:60022527e71d1d1fda67a33150ee42869042bce3d0fcc9cc49be009a9cded3fb"},
{file = "watchfiles-1.1.0-cp313-cp313-win32.whl", hash = "sha256:32d6d4e583593cb8576e129879ea0991660b935177c0f93c6681359b3654bfa9"},
{file = "watchfiles-1.1.0-cp313-cp313-win_amd64.whl", hash = "sha256:f21af781a4a6fbad54f03c598ab620e3a77032c5878f3d780448421a6e1818c7"},
{file = "watchfiles-1.1.0-cp313-cp313-win_arm64.whl", hash = "sha256:5366164391873ed76bfdf618818c82084c9db7fac82b64a20c44d335eec9ced5"},
{file = "watchfiles-1.1.0-cp313-cp313t-macosx_10_12_x86_64.whl", hash = "sha256:17ab167cca6339c2b830b744eaf10803d2a5b6683be4d79d8475d88b4a8a4be1"},
{file = "watchfiles-1.1.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:328dbc9bff7205c215a7807da7c18dce37da7da718e798356212d22696404339"},
{file = "watchfiles-1.1.0-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f7208ab6e009c627b7557ce55c465c98967e8caa8b11833531fdf95799372633"},
{file = "watchfiles-1.1.0-cp313-cp313t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:a8f6f72974a19efead54195bc9bed4d850fc047bb7aa971268fd9a8387c89011"},
{file = "watchfiles-1.1.0-cp313-cp313t-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d181ef50923c29cf0450c3cd47e2f0557b62218c50b2ab8ce2ecaa02bd97e670"},
{file = "watchfiles-1.1.0-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:adb4167043d3a78280d5d05ce0ba22055c266cf8655ce942f2fb881262ff3cdf"},
{file = "watchfiles-1.1.0-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8c5701dc474b041e2934a26d31d39f90fac8a3dee2322b39f7729867f932b1d4"},
{file = "watchfiles-1.1.0-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b067915e3c3936966a8607f6fe5487df0c9c4afb85226613b520890049deea20"},
{file = "watchfiles-1.1.0-cp313-cp313t-musllinux_1_1_aarch64.whl", hash = "sha256:9c733cda03b6d636b4219625a4acb5c6ffb10803338e437fb614fef9516825ef"},
{file = "watchfiles-1.1.0-cp313-cp313t-musllinux_1_1_x86_64.whl", hash = "sha256:cc08ef8b90d78bfac66f0def80240b0197008e4852c9f285907377b2947ffdcb"},
{file = "watchfiles-1.1.0-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:9974d2f7dc561cce3bb88dfa8eb309dab64c729de85fba32e98d75cf24b66297"},
{file = "watchfiles-1.1.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c68e9f1fcb4d43798ad8814c4c1b61547b014b667216cb754e606bfade587018"},
{file = "watchfiles-1.1.0-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:95ab1594377effac17110e1352989bdd7bdfca9ff0e5eeccd8c69c5389b826d0"},
{file = "watchfiles-1.1.0-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:fba9b62da882c1be1280a7584ec4515d0a6006a94d6e5819730ec2eab60ffe12"},
{file = "watchfiles-1.1.0-cp314-cp314-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3434e401f3ce0ed6b42569128b3d1e3af773d7ec18751b918b89cd49c14eaafb"},
{file = "watchfiles-1.1.0-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:fa257a4d0d21fcbca5b5fcba9dca5a78011cb93c0323fb8855c6d2dfbc76eb77"},
{file = "watchfiles-1.1.0-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:7fd1b3879a578a8ec2076c7961076df540b9af317123f84569f5a9ddee64ce92"},
{file = "watchfiles-1.1.0-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:62cc7a30eeb0e20ecc5f4bd113cd69dcdb745a07c68c0370cea919f373f65d9e"},
{file = "watchfiles-1.1.0-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:891c69e027748b4a73847335d208e374ce54ca3c335907d381fde4e41661b13b"},
{file = "watchfiles-1.1.0-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:12fe8eaffaf0faa7906895b4f8bb88264035b3f0243275e0bf24af0436b27259"},
{file = "watchfiles-1.1.0-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:bfe3c517c283e484843cb2e357dd57ba009cff351edf45fb455b5fbd1f45b15f"},
{file = "watchfiles-1.1.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:a9ccbf1f129480ed3044f540c0fdbc4ee556f7175e5ab40fe077ff6baf286d4e"},
{file = "watchfiles-1.1.0-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ba0e3255b0396cac3cc7bbace76404dd72b5438bf0d8e7cefa2f79a7f3649caa"},
{file = "watchfiles-1.1.0-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:4281cd9fce9fc0a9dbf0fc1217f39bf9cf2b4d315d9626ef1d4e87b84699e7e8"},
{file = "watchfiles-1.1.0-cp314-cp314t-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:6d2404af8db1329f9a3c9b79ff63e0ae7131986446901582067d9304ae8aaf7f"},
{file = "watchfiles-1.1.0-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e78b6ed8165996013165eeabd875c5dfc19d41b54f94b40e9fff0eb3193e5e8e"},
{file = "watchfiles-1.1.0-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:249590eb75ccc117f488e2fabd1bfa33c580e24b96f00658ad88e38844a040bb"},
{file = "watchfiles-1.1.0-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d05686b5487cfa2e2c28ff1aa370ea3e6c5accfe6435944ddea1e10d93872147"},
{file = "watchfiles-1.1.0-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:d0e10e6f8f6dc5762adee7dece33b722282e1f59aa6a55da5d493a97282fedd8"},
{file = "watchfiles-1.1.0-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:af06c863f152005c7592df1d6a7009c836a247c9d8adb78fef8575a5a98699db"},
{file = "watchfiles-1.1.0-cp39-cp39-macosx_10_12_x86_64.whl", hash = "sha256:865c8e95713744cf5ae261f3067861e9da5f1370ba91fc536431e29b418676fa"},
{file = "watchfiles-1.1.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:42f92befc848bb7a19658f21f3e7bae80d7d005d13891c62c2cd4d4d0abb3433"},
{file = "watchfiles-1.1.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:aa0cc8365ab29487eb4f9979fd41b22549853389e22d5de3f134a6796e1b05a4"},
{file = "watchfiles-1.1.0-cp39-cp39-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:90ebb429e933645f3da534c89b29b665e285048973b4d2b6946526888c3eb2c7"},
{file = "watchfiles-1.1.0-cp39-cp39-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c588c45da9b08ab3da81d08d7987dae6d2a3badd63acdb3e206a42dbfa7cb76f"},
{file = "watchfiles-1.1.0-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:7c55b0f9f68590115c25272b06e63f0824f03d4fc7d6deed43d8ad5660cabdbf"},
{file = "watchfiles-1.1.0-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:cd17a1e489f02ce9117b0de3c0b1fab1c3e2eedc82311b299ee6b6faf6c23a29"},
{file = "watchfiles-1.1.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:da71945c9ace018d8634822f16cbc2a78323ef6c876b1d34bbf5d5222fd6a72e"},
{file = "watchfiles-1.1.0-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:51556d5004887045dba3acdd1fdf61dddea2be0a7e18048b5e853dcd37149b86"},
{file = "watchfiles-1.1.0-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:04e4ed5d1cd3eae68c89bcc1a485a109f39f2fd8de05f705e98af6b5f1861f1f"},
{file = "watchfiles-1.1.0-cp39-cp39-win32.whl", hash = "sha256:c600e85f2ffd9f1035222b1a312aff85fd11ea39baff1d705b9b047aad2ce267"},
{file = "watchfiles-1.1.0-cp39-cp39-win_amd64.whl", hash = "sha256:3aba215958d88182e8d2acba0fdaf687745180974946609119953c0e112397dc"},
{file = "watchfiles-1.1.0-pp310-pypy310_pp73-macosx_10_12_x86_64.whl", hash = "sha256:3a6fd40bbb50d24976eb275ccb55cd1951dfb63dbc27cae3066a6ca5f4beabd5"},
{file = "watchfiles-1.1.0-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:9f811079d2f9795b5d48b55a37aa7773680a5659afe34b54cc1d86590a51507d"},
{file = "watchfiles-1.1.0-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a2726d7bfd9f76158c84c10a409b77a320426540df8c35be172444394b17f7ea"},
{file = "watchfiles-1.1.0-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:df32d59cb9780f66d165a9a7a26f19df2c7d24e3bd58713108b41d0ff4f929c6"},
{file = "watchfiles-1.1.0-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:0ece16b563b17ab26eaa2d52230c9a7ae46cf01759621f4fbbca280e438267b3"},
{file = "watchfiles-1.1.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:51b81e55d40c4b4aa8658427a3ee7ea847c591ae9e8b81ef94a90b668999353c"},
{file = "watchfiles-1.1.0-pp311-pypy311_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f2bcdc54ea267fe72bfc7d83c041e4eb58d7d8dc6f578dfddb52f037ce62f432"},
{file = "watchfiles-1.1.0-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:923fec6e5461c42bd7e3fd5ec37492c6f3468be0499bc0707b4bbbc16ac21792"},
{file = "watchfiles-1.1.0-pp39-pypy39_pp73-macosx_10_12_x86_64.whl", hash = "sha256:7b3443f4ec3ba5aa00b0e9fa90cf31d98321cbff8b925a7c7b84161619870bc9"},
{file = "watchfiles-1.1.0-pp39-pypy39_pp73-macosx_11_0_arm64.whl", hash = "sha256:7049e52167fc75fc3cc418fc13d39a8e520cbb60ca08b47f6cedb85e181d2f2a"},
{file = "watchfiles-1.1.0-pp39-pypy39_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:54062ef956807ba806559b3c3d52105ae1827a0d4ab47b621b31132b6b7e2866"},
{file = "watchfiles-1.1.0-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7a7bd57a1bb02f9d5c398c0c1675384e7ab1dd39da0ca50b7f09af45fa435277"},
{file = "watchfiles-1.1.0.tar.gz", hash = "sha256:693ed7ec72cbfcee399e92c895362b6e66d63dac6b91e2c11ae03d10d503e575"},
]
[package.dependencies]
anyio = ">=3.0.0"
[[package]]
name = "websockets"
version = "15.0.1"
description = "An implementation of the WebSocket Protocol (RFC 6455 & 7692)"
optional = false
python-versions = ">=3.9"
groups = ["docs"]
files = [
{file = "websockets-15.0.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:d63efaa0cd96cf0c5fe4d581521d9fa87744540d4bc999ae6e08595a1014b45b"},
{file = "websockets-15.0.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:ac60e3b188ec7574cb761b08d50fcedf9d77f1530352db4eef1707fe9dee7205"},
{file = "websockets-15.0.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:5756779642579d902eed757b21b0164cd6fe338506a8083eb58af5c372e39d9a"},
{file = "websockets-15.0.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0fdfe3e2a29e4db3659dbd5bbf04560cea53dd9610273917799f1cde46aa725e"},
{file = "websockets-15.0.1-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:4c2529b320eb9e35af0fa3016c187dffb84a3ecc572bcee7c3ce302bfeba52bf"},
{file = "websockets-15.0.1-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ac1e5c9054fe23226fb11e05a6e630837f074174c4c2f0fe442996112a6de4fb"},
{file = "websockets-15.0.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:5df592cd503496351d6dc14f7cdad49f268d8e618f80dce0cd5a36b93c3fc08d"},
{file = "websockets-15.0.1-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:0a34631031a8f05657e8e90903e656959234f3a04552259458aac0b0f9ae6fd9"},
{file = "websockets-15.0.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:3d00075aa65772e7ce9e990cab3ff1de702aa09be3940d1dc88d5abf1ab8a09c"},
{file = "websockets-15.0.1-cp310-cp310-win32.whl", hash = "sha256:1234d4ef35db82f5446dca8e35a7da7964d02c127b095e172e54397fb6a6c256"},
{file = "websockets-15.0.1-cp310-cp310-win_amd64.whl", hash = "sha256:39c1fec2c11dc8d89bba6b2bf1556af381611a173ac2b511cf7231622058af41"},
{file = "websockets-15.0.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:823c248b690b2fd9303ba00c4f66cd5e2d8c3ba4aa968b2779be9532a4dad431"},
{file = "websockets-15.0.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:678999709e68425ae2593acf2e3ebcbcf2e69885a5ee78f9eb80e6e371f1bf57"},
{file = "websockets-15.0.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:d50fd1ee42388dcfb2b3676132c78116490976f1300da28eb629272d5d93e905"},
{file = "websockets-15.0.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d99e5546bf73dbad5bf3547174cd6cb8ba7273062a23808ffea025ecb1cf8562"},
{file = "websockets-15.0.1-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:66dd88c918e3287efc22409d426c8f729688d89a0c587c88971a0faa2c2f3792"},
{file = "websockets-15.0.1-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8dd8327c795b3e3f219760fa603dcae1dcc148172290a8ab15158cf85a953413"},
{file = "websockets-15.0.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:8fdc51055e6ff4adeb88d58a11042ec9a5eae317a0a53d12c062c8a8865909e8"},
{file = "websockets-15.0.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:693f0192126df6c2327cce3baa7c06f2a117575e32ab2308f7f8216c29d9e2e3"},
{file = "websockets-15.0.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:54479983bd5fb469c38f2f5c7e3a24f9a4e70594cd68cd1fa6b9340dadaff7cf"},
{file = "websockets-15.0.1-cp311-cp311-win32.whl", hash = "sha256:16b6c1b3e57799b9d38427dda63edcbe4926352c47cf88588c0be4ace18dac85"},
{file = "websockets-15.0.1-cp311-cp311-win_amd64.whl", hash = "sha256:27ccee0071a0e75d22cb35849b1db43f2ecd3e161041ac1ee9d2352ddf72f065"},
{file = "websockets-15.0.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:3e90baa811a5d73f3ca0bcbf32064d663ed81318ab225ee4f427ad4e26e5aff3"},
{file = "websockets-15.0.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:592f1a9fe869c778694f0aa806ba0374e97648ab57936f092fd9d87f8bc03665"},
{file = "websockets-15.0.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:0701bc3cfcb9164d04a14b149fd74be7347a530ad3bbf15ab2c678a2cd3dd9a2"},
{file = "websockets-15.0.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e8b56bdcdb4505c8078cb6c7157d9811a85790f2f2b3632c7d1462ab5783d215"},
{file = "websockets-15.0.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0af68c55afbd5f07986df82831c7bff04846928ea8d1fd7f30052638788bc9b5"},
{file = "websockets-15.0.1-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:64dee438fed052b52e4f98f76c5790513235efaa1ef7f3f2192c392cd7c91b65"},
{file = "websockets-15.0.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:d5f6b181bb38171a8ad1d6aa58a67a6aa9d4b38d0f8c5f496b9e42561dfc62fe"},
{file = "websockets-15.0.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:5d54b09eba2bada6011aea5375542a157637b91029687eb4fdb2dab11059c1b4"},
{file = "websockets-15.0.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:3be571a8b5afed347da347bfcf27ba12b069d9d7f42cb8c7028b5e98bbb12597"},
{file = "websockets-15.0.1-cp312-cp312-win32.whl", hash = "sha256:c338ffa0520bdb12fbc527265235639fb76e7bc7faafbb93f6ba80d9c06578a9"},
{file = "websockets-15.0.1-cp312-cp312-win_amd64.whl", hash = "sha256:fcd5cf9e305d7b8338754470cf69cf81f420459dbae8a3b40cee57417f4614a7"},
{file = "websockets-15.0.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:ee443ef070bb3b6ed74514f5efaa37a252af57c90eb33b956d35c8e9c10a1931"},
{file = "websockets-15.0.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:5a939de6b7b4e18ca683218320fc67ea886038265fd1ed30173f5ce3f8e85675"},
{file = "websockets-15.0.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:746ee8dba912cd6fc889a8147168991d50ed70447bf18bcda7039f7d2e3d9151"},
{file = "websockets-15.0.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:595b6c3969023ecf9041b2936ac3827e4623bfa3ccf007575f04c5a6aa318c22"},
{file = "websockets-15.0.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3c714d2fc58b5ca3e285461a4cc0c9a66bd0e24c5da9911e30158286c9b5be7f"},
{file = "websockets-15.0.1-cp313-cp313-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0f3c1e2ab208db911594ae5b4f79addeb3501604a165019dd221c0bdcabe4db8"},
{file = "websockets-15.0.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:229cf1d3ca6c1804400b0a9790dc66528e08a6a1feec0d5040e8b9eb14422375"},
{file = "websockets-15.0.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:756c56e867a90fb00177d530dca4b097dd753cde348448a1012ed6c5131f8b7d"},
{file = "websockets-15.0.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:558d023b3df0bffe50a04e710bc87742de35060580a293c2a984299ed83bc4e4"},
{file = "websockets-15.0.1-cp313-cp313-win32.whl", hash = "sha256:ba9e56e8ceeeedb2e080147ba85ffcd5cd0711b89576b83784d8605a7df455fa"},
{file = "websockets-15.0.1-cp313-cp313-win_amd64.whl", hash = "sha256:e09473f095a819042ecb2ab9465aee615bd9c2028e4ef7d933600a8401c79561"},
{file = "websockets-15.0.1-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:5f4c04ead5aed67c8a1a20491d54cdfba5884507a48dd798ecaf13c74c4489f5"},
{file = "websockets-15.0.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:abdc0c6c8c648b4805c5eacd131910d2a7f6455dfd3becab248ef108e89ab16a"},
{file = "websockets-15.0.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:a625e06551975f4b7ea7102bc43895b90742746797e2e14b70ed61c43a90f09b"},
{file = "websockets-15.0.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d591f8de75824cbb7acad4e05d2d710484f15f29d4a915092675ad3456f11770"},
{file = "websockets-15.0.1-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:47819cea040f31d670cc8d324bb6435c6f133b8c7a19ec3d61634e62f8d8f9eb"},
{file = "websockets-15.0.1-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ac017dd64572e5c3bd01939121e4d16cf30e5d7e110a119399cf3133b63ad054"},
{file = "websockets-15.0.1-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:4a9fac8e469d04ce6c25bb2610dc535235bd4aa14996b4e6dbebf5e007eba5ee"},
{file = "websockets-15.0.1-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:363c6f671b761efcb30608d24925a382497c12c506b51661883c3e22337265ed"},
{file = "websockets-15.0.1-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:2034693ad3097d5355bfdacfffcbd3ef5694f9718ab7f29c29689a9eae841880"},
{file = "websockets-15.0.1-cp39-cp39-win32.whl", hash = "sha256:3b1ac0d3e594bf121308112697cf4b32be538fb1444468fb0a6ae4feebc83411"},
{file = "websockets-15.0.1-cp39-cp39-win_amd64.whl", hash = "sha256:b7643a03db5c95c799b89b31c036d5f27eeb4d259c798e878d6937d71832b1e4"},
{file = "websockets-15.0.1-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:0c9e74d766f2818bb95f84c25be4dea09841ac0f734d1966f415e4edfc4ef1c3"},
{file = "websockets-15.0.1-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:1009ee0c7739c08a0cd59de430d6de452a55e42d6b522de7aa15e6f67db0b8e1"},
{file = "websockets-15.0.1-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:76d1f20b1c7a2fa82367e04982e708723ba0e7b8d43aa643d3dcd404d74f1475"},
{file = "websockets-15.0.1-pp310-pypy310_pp73-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f29d80eb9a9263b8d109135351caf568cc3f80b9928bccde535c235de55c22d9"},
{file = "websockets-15.0.1-pp310-pypy310_pp73-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b359ed09954d7c18bbc1680f380c7301f92c60bf924171629c5db97febb12f04"},
{file = "websockets-15.0.1-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:cad21560da69f4ce7658ca2cb83138fb4cf695a2ba3e475e0559e05991aa8122"},
{file = "websockets-15.0.1-pp39-pypy39_pp73-macosx_10_15_x86_64.whl", hash = "sha256:7f493881579c90fc262d9cdbaa05a6b54b3811c2f300766748db79f098db9940"},
{file = "websockets-15.0.1-pp39-pypy39_pp73-macosx_11_0_arm64.whl", hash = "sha256:47b099e1f4fbc95b701b6e85768e1fcdaf1630f3cbe4765fa216596f12310e2e"},
{file = "websockets-15.0.1-pp39-pypy39_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:67f2b6de947f8c757db2db9c71527933ad0019737ec374a8a6be9a956786aaf9"},
{file = "websockets-15.0.1-pp39-pypy39_pp73-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d08eb4c2b7d6c41da6ca0600c077e93f5adcfd979cd777d747e9ee624556da4b"},
{file = "websockets-15.0.1-pp39-pypy39_pp73-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4b826973a4a2ae47ba357e4e82fa44a463b8f168e1ca775ac64521442b19e87f"},
{file = "websockets-15.0.1-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:21c1fa28a6a7e3cbdc171c694398b6df4744613ce9b36b1a498e816787e28123"},
{file = "websockets-15.0.1-py3-none-any.whl", hash = "sha256:f7a866fbc1e97b5c617ee4116daaa09b722101d4a3c170c787450ba409f9736f"},
{file = "websockets-15.0.1.tar.gz", hash = "sha256:82544de02076bafba038ce055ee6412d68da13ab47f0c60cab827346de828dee"},
]
[[package]]
name = "winrt-runtime"
version = "3.2.1"
description = "Python projection of Windows Runtime (WinRT) APIs"
optional = false
python-versions = ">=3.9"
groups = ["main"]
markers = "platform_system == \"Windows\""
files = [
{file = "winrt_runtime-3.2.1-cp310-cp310-win32.whl", hash = "sha256:25a2d1e2b45423742319f7e10fa8ca2e7063f01284b6e85e99d805c4b50bbfb3"},
{file = "winrt_runtime-3.2.1-cp310-cp310-win_amd64.whl", hash = "sha256:dc81d5fb736bf1ddecf743928622253dce4d0aac9a57faad776d7a3834e13257"},
{file = "winrt_runtime-3.2.1-cp310-cp310-win_arm64.whl", hash = "sha256:363f584b1e9fcb601e3e178636d8877e6f0537ac3c96ce4a96f06066f8ff0eae"},
{file = "winrt_runtime-3.2.1-cp311-cp311-win32.whl", hash = "sha256:9e9b64f1ba631cc4b9fe60b8ff16fef3f32c7ce2fcc84735a63129ff8b15c022"},
{file = "winrt_runtime-3.2.1-cp311-cp311-win_amd64.whl", hash = "sha256:c0a9046ae416808420a358c51705af8ae100acd40bc578be57ddfdd51cbb0f9c"},
{file = "winrt_runtime-3.2.1-cp311-cp311-win_arm64.whl", hash = "sha256:e94f3cb40ea2d723c44c82c16d715c03c6b3bd977d135b49535fdd5415fd9130"},
{file = "winrt_runtime-3.2.1-cp312-cp312-win32.whl", hash = "sha256:762b3d972a2f7037f7db3acbaf379dd6d8f6cda505f71f66c6b425d1a1eae2f1"},
{file = "winrt_runtime-3.2.1-cp312-cp312-win_amd64.whl", hash = "sha256:06510db215d4f0dc45c00fbb1251c6544e91742a0ad928011db33b30677e1576"},
{file = "winrt_runtime-3.2.1-cp312-cp312-win_arm64.whl", hash = "sha256:14562c29a087ccad38e379e585fef333e5c94166c807bdde67b508a6261aa195"},
{file = "winrt_runtime-3.2.1-cp313-cp313-win32.whl", hash = "sha256:44e2733bc709b76c554aee6c7fe079443b8306b2e661e82eecfebe8b9d71e4d1"},
{file = "winrt_runtime-3.2.1-cp313-cp313-win_amd64.whl", hash = "sha256:3c1fdcaeedeb2920dc3b9039db64089a6093cad2be56a3e64acc938849245a6d"},
{file = "winrt_runtime-3.2.1-cp313-cp313-win_arm64.whl", hash = "sha256:28f3dab083412625ff4d2b46e81246932e6bebddf67bea7f05e01712f54e6159"},
{file = "winrt_runtime-3.2.1-cp314-cp314-win32.whl", hash = "sha256:9b6298375468ac2f6815d0c008a059fc16508c8f587e824c7936ed9216480dad"},
{file = "winrt_runtime-3.2.1-cp314-cp314-win_amd64.whl", hash = "sha256:e36e587ab5fd681ee472cd9a5995743f75107a1a84d749c64f7e490bc86bc814"},
{file = "winrt_runtime-3.2.1-cp314-cp314-win_arm64.whl", hash = "sha256:35d6241a2ebd5598e4788e69768b8890ee1eee401a819865767a1fbdd3e9a650"},
{file = "winrt_runtime-3.2.1-cp39-cp39-win32.whl", hash = "sha256:07c0cb4a53a4448c2cb7597b62ae8c94343c289eeebd8f83f946eb2c817bde01"},
{file = "winrt_runtime-3.2.1-cp39-cp39-win_amd64.whl", hash = "sha256:1856325ca3354b45e0789cf279be9a882134085d34214946db76110d98391efa"},
{file = "winrt_runtime-3.2.1-cp39-cp39-win_arm64.whl", hash = "sha256:cf237858de1d62e4c9b132c66b52028a7a3e8534e8ab90b0e29a68f24f7be39d"},
{file = "winrt_runtime-3.2.1.tar.gz", hash = "sha256:c8dca19e12b234ae6c3dadf1a4d0761b51e708457492c13beb666556958801ea"},
]
[package.dependencies]
typing_extensions = ">=4.12.2"
[[package]]
name = "winrt-windows-devices-bluetooth"
version = "3.2.1"
description = "Python projection of Windows Runtime (WinRT) APIs"
optional = false
python-versions = ">=3.9"
groups = ["main"]
markers = "platform_system == \"Windows\""
files = [
{file = "winrt_windows_devices_bluetooth-3.2.1-cp310-cp310-win32.whl", hash = "sha256:49489351037094a088a08fbdf0f99c94e3299b574edb211f717c4c727770af78"},
{file = "winrt_windows_devices_bluetooth-3.2.1-cp310-cp310-win_amd64.whl", hash = "sha256:20f6a21029034c18ea6a6b6df399671813b071102a0d6d8355bb78cf4f547cdb"},
{file = "winrt_windows_devices_bluetooth-3.2.1-cp310-cp310-win_arm64.whl", hash = "sha256:69c523814eab795bc1bf913292309cb1025ef0a67d5fc33863a98788995e551d"},
{file = "winrt_windows_devices_bluetooth-3.2.1-cp311-cp311-win32.whl", hash = "sha256:f4082a00b834c1e34b961e0612f3e581356bdb38c5798bd6842f88ec02e5152b"},
{file = "winrt_windows_devices_bluetooth-3.2.1-cp311-cp311-win_amd64.whl", hash = "sha256:44277a3f2cc5ac32ce9b4b2d96c5c5f601d394ac5f02cc71bcd551f738660e2d"},
{file = "winrt_windows_devices_bluetooth-3.2.1-cp311-cp311-win_arm64.whl", hash = "sha256:0803a417403a7d225316b9b0c4fe3f8446579d6a22f2f729a2c21f4befc74a80"},
{file = "winrt_windows_devices_bluetooth-3.2.1-cp312-cp312-win32.whl", hash = "sha256:18c833ec49e7076127463679e85efc59f61785ade0dc185c852586b21be1f31c"},
{file = "winrt_windows_devices_bluetooth-3.2.1-cp312-cp312-win_amd64.whl", hash = "sha256:9b6702c462b216c91e32388023a74d0f87210cef6fd5d93b7191e9427ce2faca"},
{file = "winrt_windows_devices_bluetooth-3.2.1-cp312-cp312-win_arm64.whl", hash = "sha256:419fd1078c7749119f6b4bbf6be4e586e03a0ed544c03b83178f1d85f1b3d148"},
{file = "winrt_windows_devices_bluetooth-3.2.1-cp313-cp313-win32.whl", hash = "sha256:12b0a16fb36ce0b42243ca81f22a6b53fbb344ed7ea07a6eeec294604f0505e4"},
{file = "winrt_windows_devices_bluetooth-3.2.1-cp313-cp313-win_amd64.whl", hash = "sha256:6703dfbe444ee22426738830fb305c96a728ea9ccce905acfdf811d81045fdb3"},
{file = "winrt_windows_devices_bluetooth-3.2.1-cp313-cp313-win_arm64.whl", hash = "sha256:2cf8a0bfc9103e32dc7237af15f84be06c791f37711984abdca761f6318bbdb2"},
{file = "winrt_windows_devices_bluetooth-3.2.1-cp314-cp314-win32.whl", hash = "sha256:de36ded53ca3ba12fc6dd4deb14b779acc391447726543815df4800348aad63a"},
{file = "winrt_windows_devices_bluetooth-3.2.1-cp314-cp314-win_amd64.whl", hash = "sha256:3295d932cc93259d5ccb23a41e3a3af4c78ce5d6a6223b2b7638985f604fa34c"},
{file = "winrt_windows_devices_bluetooth-3.2.1-cp314-cp314-win_arm64.whl", hash = "sha256:1f61c178766a1bbce0669f44790c6161ff4669404c477b4aedaa576348f9e102"},
{file = "winrt_windows_devices_bluetooth-3.2.1-cp39-cp39-win32.whl", hash = "sha256:32fc355bfdc5d6b3b1875df16eaf12f9b9fc0445e01177833c27d9a4fc0d50b6"},
{file = "winrt_windows_devices_bluetooth-3.2.1-cp39-cp39-win_amd64.whl", hash = "sha256:b886ef1fc0ed49163ae6c2422dd5cb8dd4709da7972af26c8627e211872818d0"},
{file = "winrt_windows_devices_bluetooth-3.2.1-cp39-cp39-win_arm64.whl", hash = "sha256:8643afa53f9fb8fe3b05967227f86f0c8e1d7b822289e60a848c6368acc977d2"},
{file = "winrt_windows_devices_bluetooth-3.2.1.tar.gz", hash = "sha256:db496d2d92742006d5a052468fc355bf7bb49e795341d695c374746113d74505"},
]
[package.dependencies]
winrt-runtime = ">=3.2.1.0,<3.3.0.0"
[package.extras]
all = ["winrt-Windows.Devices.Bluetooth.GenericAttributeProfile[all] (>=3.2.1.0,<3.3.0.0)", "winrt-Windows.Devices.Bluetooth.Rfcomm[all] (>=3.2.1.0,<3.3.0.0)", "winrt-Windows.Devices.Enumeration[all] (>=3.2.1.0,<3.3.0.0)", "winrt-Windows.Devices.Radios[all] (>=3.2.1.0,<3.3.0.0)", "winrt-Windows.Foundation.Collections[all] (>=3.2.1.0,<3.3.0.0)", "winrt-Windows.Foundation[all] (>=3.2.1.0,<3.3.0.0)", "winrt-Windows.Networking[all] (>=3.2.1.0,<3.3.0.0)", "winrt-Windows.Storage.Streams[all] (>=3.2.1.0,<3.3.0.0)"]
[[package]]
name = "winrt-windows-devices-bluetooth-advertisement"
version = "3.2.1"
description = "Python projection of Windows Runtime (WinRT) APIs"
optional = false
python-versions = ">=3.9"
groups = ["main"]
markers = "platform_system == \"Windows\""
files = [
{file = "winrt_windows_devices_bluetooth_advertisement-3.2.1-cp310-cp310-win32.whl", hash = "sha256:a758c5f81a98cc38347fdfb024ce62720969480e8c5b98e402b89d2b09b32866"},
{file = "winrt_windows_devices_bluetooth_advertisement-3.2.1-cp310-cp310-win_amd64.whl", hash = "sha256:f982ef72e729ddd60cdb975293866e84bb838798828933012a57ee4bf12b0ea1"},
{file = "winrt_windows_devices_bluetooth_advertisement-3.2.1-cp310-cp310-win_arm64.whl", hash = "sha256:e88a72e1e09c7ccc899a9e6d2ab3fc0f43b5dd4509bcc49ec4abf65b55ab015f"},
{file = "winrt_windows_devices_bluetooth_advertisement-3.2.1-cp311-cp311-win32.whl", hash = "sha256:fe17c2cf63284646622e8b2742b064bf7970bbf53cfab02062136c67fa6b06c9"},
{file = "winrt_windows_devices_bluetooth_advertisement-3.2.1-cp311-cp311-win_amd64.whl", hash = "sha256:78e99dd48b4d89b71b7778c5085fdba64e754dd3ebc54fd09c200fe5222c6e09"},
{file = "winrt_windows_devices_bluetooth_advertisement-3.2.1-cp311-cp311-win_arm64.whl", hash = "sha256:6d5d2295474deab444fc4311580c725a2ca8a814b0f3344d0779828891d75401"},
{file = "winrt_windows_devices_bluetooth_advertisement-3.2.1-cp312-cp312-win32.whl", hash = "sha256:901933cc40de5eb7e5f4188897c899dd0b0f577cb2c13eab1a63c7dfe89b08c4"},
{file = "winrt_windows_devices_bluetooth_advertisement-3.2.1-cp312-cp312-win_amd64.whl", hash = "sha256:e6c66e7d4f4ca86d2c801d30efd2b9673247b59a2b4c365d9e11650303d68d89"},
{file = "winrt_windows_devices_bluetooth_advertisement-3.2.1-cp312-cp312-win_arm64.whl", hash = "sha256:447d19defd8982d39944642eb7ebe89e4e20259ec9734116cf88879fb2c514ff"},
{file = "winrt_windows_devices_bluetooth_advertisement-3.2.1-cp313-cp313-win32.whl", hash = "sha256:4122348ea525a914e85615647a0b54ae8b2f42f92cdbf89c5a12eea53ef6ed90"},
{file = "winrt_windows_devices_bluetooth_advertisement-3.2.1-cp313-cp313-win_amd64.whl", hash = "sha256:b66410c04b8dae634a7e4b615c3b7f8adda9c7d4d6902bcad5b253da1a684943"},
{file = "winrt_windows_devices_bluetooth_advertisement-3.2.1-cp313-cp313-win_arm64.whl", hash = "sha256:07af19b1d252ddb9dd3eb2965118bc2b7cabff4dda6e499341b765e5038ca61d"},
{file = "winrt_windows_devices_bluetooth_advertisement-3.2.1-cp314-cp314-win32.whl", hash = "sha256:2985565c265b3f9eab625361b0e40e88c94b03d89f5171f36146f2e88b3ee214"},
{file = "winrt_windows_devices_bluetooth_advertisement-3.2.1-cp314-cp314-win_amd64.whl", hash = "sha256:d102f3fac64fde32332e370969dfbc6f37b405d8cc055d9da30d14d07449a3c2"},
{file = "winrt_windows_devices_bluetooth_advertisement-3.2.1-cp314-cp314-win_arm64.whl", hash = "sha256:ffeb5e946cd42c32c6999a62e240d6730c653cdfb7b49c7839afba375e20a62a"},
{file = "winrt_windows_devices_bluetooth_advertisement-3.2.1-cp39-cp39-win32.whl", hash = "sha256:6c4747d2e5b0e2ef24e9b84a848cf8fc50fb5b268a2086b5ee8680206d1e0197"},
{file = "winrt_windows_devices_bluetooth_advertisement-3.2.1-cp39-cp39-win_amd64.whl", hash = "sha256:18d4c5d8b80ee2d29cc13c2fc1353fdb3c0f620c8083701c9b9ecf5e6c503c8d"},
{file = "winrt_windows_devices_bluetooth_advertisement-3.2.1-cp39-cp39-win_arm64.whl", hash = "sha256:75dd856611d847299078d56aee60e319df52975b931c992cd1d32ad5143fe772"},
{file = "winrt_windows_devices_bluetooth_advertisement-3.2.1.tar.gz", hash = "sha256:0223852a7b7fa5c8dea3c6a93473bd783df4439b1ed938d9871f947933e574cc"},
]
[package.dependencies]
winrt-runtime = ">=3.2.1.0,<3.3.0.0"
[package.extras]
all = ["winrt-Windows.Devices.Bluetooth[all] (>=3.2.1.0,<3.3.0.0)", "winrt-Windows.Foundation.Collections[all] (>=3.2.1.0,<3.3.0.0)", "winrt-Windows.Foundation[all] (>=3.2.1.0,<3.3.0.0)", "winrt-Windows.Storage.Streams[all] (>=3.2.1.0,<3.3.0.0)"]
[[package]]
name = "winrt-windows-devices-bluetooth-genericattributeprofile"
version = "3.2.1"
description = "Python projection of Windows Runtime (WinRT) APIs"
optional = false
python-versions = ">=3.9"
groups = ["main"]
markers = "platform_system == \"Windows\""
files = [
{file = "winrt_windows_devices_bluetooth_genericattributeprofile-3.2.1-cp310-cp310-win32.whl", hash = "sha256:af4914d7b30b49232092cd3b934e3ed6f5d3b1715ba47238541408ee595b7f46"},
{file = "winrt_windows_devices_bluetooth_genericattributeprofile-3.2.1-cp310-cp310-win_amd64.whl", hash = "sha256:0e557dd52fc80392b8bd7c237e1153a50a164b3983838b4ac674551072efc9ed"},
{file = "winrt_windows_devices_bluetooth_genericattributeprofile-3.2.1-cp310-cp310-win_arm64.whl", hash = "sha256:64cff62baa6b7aadd6c206e61d149113fdcda17360feb6e9d05bc8bbda4b9fde"},
{file = "winrt_windows_devices_bluetooth_genericattributeprofile-3.2.1-cp311-cp311-win32.whl", hash = "sha256:832cf65d035a11e6dbfef4fd66abdcc46be7e911ec96e2e72e98e12d8d5b9d3c"},
{file = "winrt_windows_devices_bluetooth_genericattributeprofile-3.2.1-cp311-cp311-win_amd64.whl", hash = "sha256:8179638a6c721b0bbf04ba251ef98d5e02d9a17f0cce377398e42c4fbb441415"},
{file = "winrt_windows_devices_bluetooth_genericattributeprofile-3.2.1-cp311-cp311-win_arm64.whl", hash = "sha256:70b7edfca3190b89ae38bf60972b11978311b6d933d3142ae45560c955dbf5c7"},
{file = "winrt_windows_devices_bluetooth_genericattributeprofile-3.2.1-cp312-cp312-win32.whl", hash = "sha256:ef894d21e0a805f3e114940254636a8045335fa9de766c7022af5d127dfad557"},
{file = "winrt_windows_devices_bluetooth_genericattributeprofile-3.2.1-cp312-cp312-win_amd64.whl", hash = "sha256:db05de95cd1b24a51abb69cb936a8b17e9214e015757d0b37e3a5e207ddceb3d"},
{file = "winrt_windows_devices_bluetooth_genericattributeprofile-3.2.1-cp312-cp312-win_arm64.whl", hash = "sha256:8d4e131cf3d15fc5ad81c1bcde3509ac171298217381abed6bdf687f29871984"},
{file = "winrt_windows_devices_bluetooth_genericattributeprofile-3.2.1-cp313-cp313-win32.whl", hash = "sha256:b1879c8dcf46bd2110b9ad4b0b185f4e2a5f95170d014539203a5fee2b2115f0"},
{file = "winrt_windows_devices_bluetooth_genericattributeprofile-3.2.1-cp313-cp313-win_amd64.whl", hash = "sha256:8d8d89f01e9b6931fb48217847caac3227a0aeb38a5b7782af71c2e7b262ec30"},
{file = "winrt_windows_devices_bluetooth_genericattributeprofile-3.2.1-cp313-cp313-win_arm64.whl", hash = "sha256:4e71207bb89798016b1795bb15daf78afe45529f2939b3b9e78894cfe650b383"},
{file = "winrt_windows_devices_bluetooth_genericattributeprofile-3.2.1-cp314-cp314-win32.whl", hash = "sha256:d5f83739ca370f0baf52b0400aebd6240ab80150081fbfba60fd6e7b2e7b4c5f"},
{file = "winrt_windows_devices_bluetooth_genericattributeprofile-3.2.1-cp314-cp314-win_amd64.whl", hash = "sha256:13786a5853a933de140d456cd818696e1121c7c296ae7b7af262fc5d2cffb851"},
{file = "winrt_windows_devices_bluetooth_genericattributeprofile-3.2.1-cp314-cp314-win_arm64.whl", hash = "sha256:5140682da2860f6a55eb6faf9e980724dc457c2e4b4b35a10e1cebd8fc97d892"},
{file = "winrt_windows_devices_bluetooth_genericattributeprofile-3.2.1-cp39-cp39-win32.whl", hash = "sha256:963339a0161f9970b577a6193924be783978d11693da48b41a025f61b3c5562a"},
{file = "winrt_windows_devices_bluetooth_genericattributeprofile-3.2.1-cp39-cp39-win_amd64.whl", hash = "sha256:d43615c5dfa939dd30fe80dc0649434a13cc7cf0294ad0d7283d5a9f48c6ce86"},
{file = "winrt_windows_devices_bluetooth_genericattributeprofile-3.2.1-cp39-cp39-win_arm64.whl", hash = "sha256:8e70fa970997e2e67a8a4172bc00b0b2a79b5ff5bb2668f79cf10b3fd63d3974"},
{file = "winrt_windows_devices_bluetooth_genericattributeprofile-3.2.1.tar.gz", hash = "sha256:cdf6ddc375e9150d040aca67f5a17c41ceaf13a63f3668f96608bc1d045dde71"},
]
[package.dependencies]
winrt-runtime = ">=3.2.1.0,<3.3.0.0"
[package.extras]
all = ["winrt-Windows.Devices.Bluetooth[all] (>=3.2.1.0,<3.3.0.0)", "winrt-Windows.Devices.Enumeration[all] (>=3.2.1.0,<3.3.0.0)", "winrt-Windows.Foundation.Collections[all] (>=3.2.1.0,<3.3.0.0)", "winrt-Windows.Foundation[all] (>=3.2.1.0,<3.3.0.0)", "winrt-Windows.Storage.Streams[all] (>=3.2.1.0,<3.3.0.0)"]
[[package]]
name = "winrt-windows-devices-enumeration"
version = "3.2.1"
description = "Python projection of Windows Runtime (WinRT) APIs"
optional = false
python-versions = ">=3.9"
groups = ["main"]
markers = "platform_system == \"Windows\""
files = [
{file = "winrt_windows_devices_enumeration-3.2.1-cp310-cp310-win32.whl", hash = "sha256:40dac777d8f45b41449f3ff1ae70f0d457f1ede53f53962a6e2521b651533db5"},
{file = "winrt_windows_devices_enumeration-3.2.1-cp310-cp310-win_amd64.whl", hash = "sha256:a101ec3e0ad0a0783032fdcd5dc48e7cd68ee034cbde4f903a8c7b391532c71a"},
{file = "winrt_windows_devices_enumeration-3.2.1-cp310-cp310-win_arm64.whl", hash = "sha256:3296a3863ac086928ff3f3dc872b2a2fb971dab728817424264f3ca547504e9e"},
{file = "winrt_windows_devices_enumeration-3.2.1-cp311-cp311-win32.whl", hash = "sha256:9f29465a6c6b0456e4330d4ad09eccdd53a17e1e97695c2e57db0d4666cc0011"},
{file = "winrt_windows_devices_enumeration-3.2.1-cp311-cp311-win_amd64.whl", hash = "sha256:2a725d04b4cb43aa0e2af035f73a60d16a6c0ff165fcb6b763383e4e33a975fd"},
{file = "winrt_windows_devices_enumeration-3.2.1-cp311-cp311-win_arm64.whl", hash = "sha256:6365ef5978d4add26678827286034acf474b6b133aa4054e76567d12194e6817"},
{file = "winrt_windows_devices_enumeration-3.2.1-cp312-cp312-win32.whl", hash = "sha256:1db22b0292b93b0688d11ad932ad1f3629d4f471310281a2fbfe187530c2c1f3"},
{file = "winrt_windows_devices_enumeration-3.2.1-cp312-cp312-win_amd64.whl", hash = "sha256:a73bc88d7f510af454f2b392985501c96f39b89fd987140708ccaec1588ceebc"},
{file = "winrt_windows_devices_enumeration-3.2.1-cp312-cp312-win_arm64.whl", hash = "sha256:2853d687803f0dd76ae1afe3648abc0453e09dff0e7eddbb84b792eddb0473ca"},
{file = "winrt_windows_devices_enumeration-3.2.1-cp313-cp313-win32.whl", hash = "sha256:14a71cdcc84f624c209cbb846ed6bd9767a9a9437b2bf26b48ac9a91599da6e9"},
{file = "winrt_windows_devices_enumeration-3.2.1-cp313-cp313-win_amd64.whl", hash = "sha256:6ca40d334734829e178ad46375275c4f7b5d6d2d4fc2e8879690452cbfb36015"},
{file = "winrt_windows_devices_enumeration-3.2.1-cp313-cp313-win_arm64.whl", hash = "sha256:2d14d187f43e4409c7814b7d1693c03a270e77489b710d92fcbbaeca5de260d4"},
{file = "winrt_windows_devices_enumeration-3.2.1-cp314-cp314-win32.whl", hash = "sha256:e087364273ed7c717cd0191fed4be9def6fdf229fe9b536a4b8d0228f7814106"},
{file = "winrt_windows_devices_enumeration-3.2.1-cp314-cp314-win_amd64.whl", hash = "sha256:0da1ddb8285d97a6775c36265d7157acf1bbcb88bcc9a7ce9a4549906c822472"},
{file = "winrt_windows_devices_enumeration-3.2.1-cp314-cp314-win_arm64.whl", hash = "sha256:09bf07e74e897e97a49a9275d0a647819254ddb74142806bbbcf4777ed240a22"},
{file = "winrt_windows_devices_enumeration-3.2.1-cp39-cp39-win32.whl", hash = "sha256:986e8d651b769a0e60d2834834bdd3f6959f6a88caa0c9acb917797e6b43a588"},
{file = "winrt_windows_devices_enumeration-3.2.1-cp39-cp39-win_amd64.whl", hash = "sha256:10da7d403ac4afd385fe13bd5808c9a5dd616a8ef31ca5c64cea3f87673661c1"},
{file = "winrt_windows_devices_enumeration-3.2.1-cp39-cp39-win_arm64.whl", hash = "sha256:679e471d21ac22cb50de1bf4dfc4c0c3f5da9f3e3fbc7f08dcacfe9de9d6dd58"},
{file = "winrt_windows_devices_enumeration-3.2.1.tar.gz", hash = "sha256:df316899e39bfc0ffc1f3cb0f5ee54d04e1d167fbbcc1484d2d5121449a935cf"},
]
[package.dependencies]
winrt-runtime = ">=3.2.1.0,<3.3.0.0"
[package.extras]
all = ["winrt-Windows.ApplicationModel.Background[all] (>=3.2.1.0,<3.3.0.0)", "winrt-Windows.Foundation.Collections[all] (>=3.2.1.0,<3.3.0.0)", "winrt-Windows.Foundation[all] (>=3.2.1.0,<3.3.0.0)", "winrt-Windows.Security.Credentials[all] (>=3.2.1.0,<3.3.0.0)", "winrt-Windows.Storage.Streams[all] (>=3.2.1.0,<3.3.0.0)", "winrt-Windows.UI.Popups[all] (>=3.2.1.0,<3.3.0.0)", "winrt-Windows.UI[all] (>=3.2.1.0,<3.3.0.0)"]
[[package]]
name = "winrt-windows-foundation"
version = "3.2.1"
description = "Python projection of Windows Runtime (WinRT) APIs"
optional = false
python-versions = ">=3.9"
groups = ["main"]
markers = "platform_system == \"Windows\""
files = [
{file = "winrt_windows_foundation-3.2.1-cp310-cp310-win32.whl", hash = "sha256:677e98165dcbbf7a2367f905bc61090ef2c568b6e465f87cf7276df4734f3b0b"},
{file = "winrt_windows_foundation-3.2.1-cp310-cp310-win_amd64.whl", hash = "sha256:a8f27b4f0fdb73ccc4a3e24bc8010a6607b2bdd722fa799eafce7daa87d19d39"},
{file = "winrt_windows_foundation-3.2.1-cp310-cp310-win_arm64.whl", hash = "sha256:d900c6165fab4ea589811efa2feed27b532e1b6f505f63bf63e2052b8cb6bdc4"},
{file = "winrt_windows_foundation-3.2.1-cp311-cp311-win32.whl", hash = "sha256:d1b5970241ccd61428f7330d099be75f4f52f25e510d82c84dbbdaadd625e437"},
{file = "winrt_windows_foundation-3.2.1-cp311-cp311-win_amd64.whl", hash = "sha256:f3762be2f6e0f2aedf83a0742fd727290b397ffe3463d963d29211e4ebb53a7e"},
{file = "winrt_windows_foundation-3.2.1-cp311-cp311-win_arm64.whl", hash = "sha256:806c77818217b3476e6c617293b3d5b0ff8a9901549dc3417586f6799938d671"},
{file = "winrt_windows_foundation-3.2.1-cp312-cp312-win32.whl", hash = "sha256:867642ccf629611733db482c4288e17b7919f743a5873450efb6d69ae09fdc2b"},
{file = "winrt_windows_foundation-3.2.1-cp312-cp312-win_amd64.whl", hash = "sha256:45550c5b6c2125cde495c409633e6b1ea5aa1677724e3b95eb8140bfccbe30c9"},
{file = "winrt_windows_foundation-3.2.1-cp312-cp312-win_arm64.whl", hash = "sha256:94f4661d71cb35ebc52be7af112f2eeabdfa02cb05e0243bf9d6bd2cafaa6f37"},
{file = "winrt_windows_foundation-3.2.1-cp313-cp313-win32.whl", hash = "sha256:3998dc58ed50ecbdbabace1cdef3a12920b725e32a5806d648ad3f4829d5ba46"},
{file = "winrt_windows_foundation-3.2.1-cp313-cp313-win_amd64.whl", hash = "sha256:6e98617c1e46665c7a56ce3f5d28e252798416d1ebfee3201267a644a4e3c479"},
{file = "winrt_windows_foundation-3.2.1-cp313-cp313-win_arm64.whl", hash = "sha256:2a8c1204db5c352f6a563130a5a41d25b887aff7897bb677d4ff0b660315aad4"},
{file = "winrt_windows_foundation-3.2.1-cp314-cp314-win32.whl", hash = "sha256:35e973ab3c77c2a943e139302256c040e017fd6ff1a75911c102964603bba1da"},
{file = "winrt_windows_foundation-3.2.1-cp314-cp314-win_amd64.whl", hash = "sha256:a22a7ebcec0d262e60119cff728f32962a02df60471ded8b2735a655eccc0ef5"},
{file = "winrt_windows_foundation-3.2.1-cp314-cp314-win_arm64.whl", hash = "sha256:3be7fbae829b98a6a946db4fbaf356b11db1fbcbb5d4f37e7a73ac6b25de8b87"},
{file = "winrt_windows_foundation-3.2.1-cp39-cp39-win32.whl", hash = "sha256:14d5191725301498e4feb744d91f5b46ce317bf3d28370efda407d5c87f4423b"},
{file = "winrt_windows_foundation-3.2.1-cp39-cp39-win_amd64.whl", hash = "sha256:de5e4f61d253a91ba05019dbf4338c43f962bdad935721ced5e7997933994af5"},
{file = "winrt_windows_foundation-3.2.1-cp39-cp39-win_arm64.whl", hash = "sha256:ebbf6e8168398c9ed0c72c8bdde95a406b9fbb9a23e3705d4f0fe28e5a209705"},
{file = "winrt_windows_foundation-3.2.1.tar.gz", hash = "sha256:ad2f1fcaa6c34672df45527d7c533731fdf65b67c4638c2b4aca949f6eec0656"},
]
[package.dependencies]
winrt-runtime = ">=3.2.1.0,<3.3.0.0"
[package.extras]
all = ["winrt-Windows.Foundation.Collections[all] (>=3.2.1.0,<3.3.0.0)"]
[[package]]
name = "winrt-windows-foundation-collections"
version = "3.2.1"
description = "Python projection of Windows Runtime (WinRT) APIs"
optional = false
python-versions = ">=3.9"
groups = ["main"]
markers = "platform_system == \"Windows\""
files = [
{file = "winrt_windows_foundation_collections-3.2.1-cp310-cp310-win32.whl", hash = "sha256:46948484addfc4db981dab35688d4457533ceb54d4954922af41503fddaa8389"},
{file = "winrt_windows_foundation_collections-3.2.1-cp310-cp310-win_amd64.whl", hash = "sha256:899eaa3a93c35bfb1857d649e8dd60c38b978dda7cedd9725fcdbcebba156fd6"},
{file = "winrt_windows_foundation_collections-3.2.1-cp310-cp310-win_arm64.whl", hash = "sha256:c36eb49ad1eba1b32134df768bb47af13cabb9b59f974a3cea37843e2d80e0e6"},
{file = "winrt_windows_foundation_collections-3.2.1-cp311-cp311-win32.whl", hash = "sha256:9b272d9936e7db4840881c5dcf921eb26789ae4ef23fb6ec15e13e19a16254e7"},
{file = "winrt_windows_foundation_collections-3.2.1-cp311-cp311-win_amd64.whl", hash = "sha256:c646a5d442dd6540ade50890081ca118b41f073356e19032d0a5d7d0d38fbc89"},
{file = "winrt_windows_foundation_collections-3.2.1-cp311-cp311-win_arm64.whl", hash = "sha256:2c4630027c93cdd518b0cf4cc726b8fbdbc3388e36d02aa1de190a0fc18ca523"},
{file = "winrt_windows_foundation_collections-3.2.1-cp312-cp312-win32.whl", hash = "sha256:15704eef3125788f846f269cf54a3d89656fa09a1dc8428b70871f717d595ad6"},
{file = "winrt_windows_foundation_collections-3.2.1-cp312-cp312-win_amd64.whl", hash = "sha256:550dfb8c82fe74d9e0728a2a16a9175cc9e34ca2b8ef758d69b2a398894b698b"},
{file = "winrt_windows_foundation_collections-3.2.1-cp312-cp312-win_arm64.whl", hash = "sha256:810ad4bd11ab4a74fdbcd3ed33b597ef7c0b03af73fc9d7986c22bcf3bd24f84"},
{file = "winrt_windows_foundation_collections-3.2.1-cp313-cp313-win32.whl", hash = "sha256:4267a711b63476d36d39227883aeb3fb19ac92b88a9fc9973e66fbce1fd4aed9"},
{file = "winrt_windows_foundation_collections-3.2.1-cp313-cp313-win_amd64.whl", hash = "sha256:5e12a6e75036ee90484c33e204b85fb6785fcc9e7c8066ad65097301f48cdd10"},
{file = "winrt_windows_foundation_collections-3.2.1-cp313-cp313-win_arm64.whl", hash = "sha256:34b556255562f1b36d07fba933c2bcd9f0db167fa96727a6cbb4717b152ad7a2"},
{file = "winrt_windows_foundation_collections-3.2.1-cp314-cp314-win32.whl", hash = "sha256:33188ed2d63e844c8adfbb82d1d3d461d64aaf78d225ce9c5930421b413c45ab"},
{file = "winrt_windows_foundation_collections-3.2.1-cp314-cp314-win_amd64.whl", hash = "sha256:d4cfece7e9c0ead2941e55a1da82f20d2b9c8003bb7a8853bb7f999b539f80a4"},
{file = "winrt_windows_foundation_collections-3.2.1-cp314-cp314-win_arm64.whl", hash = "sha256:3884146fea13727510458f6a14040b7632d5d90127028b9bfd503c6c655d0c01"},
{file = "winrt_windows_foundation_collections-3.2.1-cp39-cp39-win32.whl", hash = "sha256:20610f098b84c87765018cbc71471092197881f3b92e5d06158fad3bfcea2563"},
{file = "winrt_windows_foundation_collections-3.2.1-cp39-cp39-win_amd64.whl", hash = "sha256:e9739775320ac4c0238e1775d94a54e886d621f9995977e65d4feb8b3778c111"},
{file = "winrt_windows_foundation_collections-3.2.1-cp39-cp39-win_arm64.whl", hash = "sha256:e4c6bddb1359d5014ceb45fe2ecd838d4afeb1184f2ea202c2d21037af0d08a3"},
{file = "winrt_windows_foundation_collections-3.2.1.tar.gz", hash = "sha256:0eff1ad0d8d763ad17e9e7bbd0c26a62b27215016393c05b09b046d6503ae6d5"},
]
[package.dependencies]
winrt-runtime = ">=3.2.1.0,<3.3.0.0"
[package.extras]
all = ["winrt-Windows.Foundation[all] (>=3.2.1.0,<3.3.0.0)"]
[[package]]
name = "winrt-windows-storage-streams"
version = "3.2.1"
description = "Python projection of Windows Runtime (WinRT) APIs"
optional = false
python-versions = ">=3.9"
groups = ["main"]
markers = "platform_system == \"Windows\""
files = [
{file = "winrt_windows_storage_streams-3.2.1-cp310-cp310-win32.whl", hash = "sha256:89bb2d667ebed6861af36ed2710757456e12921ee56347946540320dacf6c003"},
{file = "winrt_windows_storage_streams-3.2.1-cp310-cp310-win_amd64.whl", hash = "sha256:48a78e5dc7d3488eb77e449c278bc6d6ac28abcdda7df298462c4112d7635d00"},
{file = "winrt_windows_storage_streams-3.2.1-cp310-cp310-win_arm64.whl", hash = "sha256:da71231d4a554f9f15f1249b4990c6431176f6dfb0e3385c7caa7896f4ca24d6"},
{file = "winrt_windows_storage_streams-3.2.1-cp311-cp311-win32.whl", hash = "sha256:7dace2f9e364422255d0e2f335f741bfe7abb1f4d4f6003622b2450b87c91e69"},
{file = "winrt_windows_storage_streams-3.2.1-cp311-cp311-win_amd64.whl", hash = "sha256:b02fa251a7eef6081eca1a5f64ecf349cfd1ac0ac0c5a5a30be52897d060bed5"},
{file = "winrt_windows_storage_streams-3.2.1-cp311-cp311-win_arm64.whl", hash = "sha256:efdf250140340a75647e8e8ad002782d91308e9fdd1e19470a5b9cc969ae4780"},
{file = "winrt_windows_storage_streams-3.2.1-cp312-cp312-win32.whl", hash = "sha256:77c1f0e004b84347b5bd705e8f0fc63be8cd29a6093be13f1d0869d0d97b7d78"},
{file = "winrt_windows_storage_streams-3.2.1-cp312-cp312-win_amd64.whl", hash = "sha256:e4508ee135af53e4fc142876abbf4bc7c2a95edfc7d19f52b291a8499cacd6dc"},
{file = "winrt_windows_storage_streams-3.2.1-cp312-cp312-win_arm64.whl", hash = "sha256:040cb94e6fb26b0d00a00e8b88b06fadf29dfe18cf24ed6cb3e69709c3613307"},
{file = "winrt_windows_storage_streams-3.2.1-cp313-cp313-win32.whl", hash = "sha256:401bb44371720dc43bd1e78662615a2124372e7d5d9d65dfa8f77877bbcb8163"},
{file = "winrt_windows_storage_streams-3.2.1-cp313-cp313-win_amd64.whl", hash = "sha256:202c5875606398b8bfaa2a290831458bb55f2196a39c1d4e5fa88a03d65ef915"},
{file = "winrt_windows_storage_streams-3.2.1-cp313-cp313-win_arm64.whl", hash = "sha256:ca3c5ec0aab60895006bf61053a1aca6418bc7f9a27a34791ba3443b789d230d"},
{file = "winrt_windows_storage_streams-3.2.1-cp314-cp314-win32.whl", hash = "sha256:5cd0dbad86fcc860366f6515fce97177b7eaa7069da261057be4813819ba37ee"},
{file = "winrt_windows_storage_streams-3.2.1-cp314-cp314-win_amd64.whl", hash = "sha256:3c5bf41d725369b9986e6d64bad7079372b95c329897d684f955d7028c7f27a0"},
{file = "winrt_windows_storage_streams-3.2.1-cp314-cp314-win_arm64.whl", hash = "sha256:293e09825559d0929bbe5de01e1e115f7a6283d8996ab55652e5af365f032987"},
{file = "winrt_windows_storage_streams-3.2.1-cp39-cp39-win32.whl", hash = "sha256:1c630cfdece58fcf82e4ed86c826326123529836d6d4d855ae8e9ceeff67b627"},
{file = "winrt_windows_storage_streams-3.2.1-cp39-cp39-win_amd64.whl", hash = "sha256:d7ff22434a4829d616a04b068a191ac79e008f6c27541bb178c1f6f1fe7a1657"},
{file = "winrt_windows_storage_streams-3.2.1-cp39-cp39-win_arm64.whl", hash = "sha256:fa90244191108f85f6f7afb43a11d365aca4e0722fe8adc62fb4d2c678d0993d"},
{file = "winrt_windows_storage_streams-3.2.1.tar.gz", hash = "sha256:476f522722751eb0b571bc7802d85a82a3cae8b1cce66061e6e758f525e7b80f"},
]
[package.dependencies]
winrt-runtime = ">=3.2.1.0,<3.3.0.0"
[package.extras]
all = ["winrt-Windows.Foundation.Collections[all] (>=3.2.1.0,<3.3.0.0)", "winrt-Windows.Foundation[all] (>=3.2.1.0,<3.3.0.0)", "winrt-Windows.Storage[all] (>=3.2.1.0,<3.3.0.0)", "winrt-Windows.System[all] (>=3.2.1.0,<3.3.0.0)"]
[metadata]
lock-version = "2.1"
python-versions = ">=3.11,<3.14"
content-hash = "7e059ac8fdc292fe66a9e24050870b4f780f9f8ac01fc0b572aca849df52f4f0"
Bluetooth-Devices-habluetooth-21accde/pyproject.toml 0000664 0000000 0000000 00000010023 15070247161 0023032 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 = "5.7.0"
license = "Apache-2.0"
description = "High availability Bluetooth"
authors = [{ name = "J. Nick Koston", email = "bluetooth@koston.org" }]
readme = "README.md"
requires-python = ">=3.11"
[project.urls]
"Repository" = "https://github.com/bluetooth-devices/habluetooth"
"Documentation" = "https://habluetooth.readthedocs.io"
"Bug Tracker" = "https://github.com/bluetooth-devices/habluetooth/issues"
"Changelog" = "https://github.com/bluetooth-devices/habluetooth/blob/main/CHANGELOG.md"
[tool.poetry]
classifiers = [
"Development Status :: 2 - Pre-Alpha",
"Intended Audience :: Developers",
"Natural Language :: English",
"Operating System :: OS Independent",
"Topic :: Software Development :: Libraries",
]
packages = [
{ include = "habluetooth", from = "src" },
]
[tool.poetry.build]
generate-setup-file = true
script = "build_ext.py"
[tool.poetry.dependencies]
python = ">=3.11,<3.14"
bleak = ">=1.0.1"
bleak-retry-connector = ">=4.2.0"
bluetooth-data-tools = ">=1.28.0"
bluetooth-adapters = ">=2.1.0"
bluetooth-auto-recovery = ">=1.5.1"
async-interrupt = ">=1.1.1"
dbus-fast = { version = ">=2.30.2", markers = "platform_system == 'Linux'" }
btsocket = ">=0.3.0"
[tool.poetry.group.dev.dependencies]
pytest = ">=7,<9"
pytest-cov = ">=3,<8"
pytest-asyncio = ">=0.23.6,<1.3.0"
pytest-codspeed = ">=2.2.1,<5.0.0"
freezegun = "^1.5.5"
dbus-fast = ">=2.30.2"
[tool.poetry.group.docs]
optional = true
[tool.poetry.group.docs.dependencies]
myst-parser = ">=0.16"
sphinx = ">=4.0"
furo = ">=2023.5.20"
sphinx-autobuild = ">=2021.3.14"
[tool.semantic_release]
version_toml = ["pyproject.toml:project.version"]
version_variables = [
"src/habluetooth/__init__.py:__version__",
"docs/conf.py:release",
]
build_command = "pip install poetry && poetry build"
[tool.semantic_release.changelog]
exclude_commit_patterns = [
"chore*",
"ci*",
]
[tool.semantic_release.changelog.environment]
keep_trailing_newline = true
[tool.semantic_release.branches.main]
match = "main"
[tool.semantic_release.branches.noop]
match = "(?!main$)"
prerelease = true
[tool.pytest.ini_options]
addopts = "-v -Wdefault --cov=habluetooth --cov-report=term-missing:skip-covered"
pythonpath = ["src"]
log_cli="true"
log_level="NOTSET"
[tool.coverage.run]
branch = true
[tool.coverage.report]
exclude_lines = [
"pragma: no cover",
"@overload",
"if TYPE_CHECKING",
"raise NotImplementedError",
'if __name__ == "__main__":',
]
[tool.ruff]
target-version = "py311"
line-length = 88
[tool.ruff.lint]
ignore = [
"E721", # type checks for cython
"D203", # 1 blank line required before class docstring
"D212", # Multi-line docstring summary should start at the first line
"D100", # Missing docstring in public module
"D104", # Missing docstring in public package
"D107", # Missing docstring in `__init__`
"D401", # First line of docstring should be in imperative mood
]
select = [
"B", # flake8-bugbear
"D", # flake8-docstrings
"C4", # flake8-comprehensions
"S", # flake8-bandit
"F", # pyflake
"E", # pycodestyle
"W", # pycodestyle
"UP", # pyupgrade
"I", # isort
"RUF", # ruff specific
"RET", # return
"SIM", # simplify
]
[tool.ruff.lint.per-file-ignores]
"tests/**/*" = [
"D100",
"D101",
"D102",
"D103",
"D104",
"S101",
]
"setup.py" = ["D100"]
"conftest.py" = ["D100"]
"docs/conf.py" = ["D100"]
[tool.ruff.lint.isort]
known-first-party = ["habluetooth", "tests"]
[tool.mypy]
check_untyped_defs = true
disallow_any_generics = true
disallow_incomplete_defs = true
disallow_untyped_defs = true
mypy_path = "src/"
no_implicit_optional = true
show_error_codes = true
warn_unreachable = true
warn_unused_ignores = true
exclude = [
'docs/.*',
'setup.py',
]
[[tool.mypy.overrides]]
module = "tests.*"
allow_untyped_defs = true
[[tool.mypy.overrides]]
module = "docs.*"
ignore_errors = true
Bluetooth-Devices-habluetooth-21accde/renovate.json 0000664 0000000 0000000 00000000101 15070247161 0022630 0 ustar 00root root 0000000 0000000 {
"extends": ["github>browniebroke/renovate-configs:python"]
}
Bluetooth-Devices-habluetooth-21accde/src/ 0000775 0000000 0000000 00000000000 15070247161 0020711 5 ustar 00root root 0000000 0000000 Bluetooth-Devices-habluetooth-21accde/src/habluetooth/ 0000775 0000000 0000000 00000000000 15070247161 0023227 5 ustar 00root root 0000000 0000000 Bluetooth-Devices-habluetooth-21accde/src/habluetooth/__init__.py 0000664 0000000 0000000 00000004600 15070247161 0025340 0 ustar 00root root 0000000 0000000 __version__ = "5.7.0"
from bleak_retry_connector import Allocations
from .advertisement_tracker import (
TRACKER_BUFFERING_WOBBLE_SECONDS,
AdvertisementTracker,
)
from .base_scanner import BaseHaRemoteScanner, BaseHaScanner
from .central_manager import get_manager, set_manager
from .const import (
CONNECTABLE_FALLBACK_MAXIMUM_STALE_ADVERTISEMENT_SECONDS,
FALLBACK_MAXIMUM_STALE_ADVERTISEMENT_SECONDS,
SCANNER_WATCHDOG_INTERVAL,
SCANNER_WATCHDOG_TIMEOUT,
UNAVAILABLE_TRACK_SECONDS,
)
from .manager import BluetoothManager
from .models import (
BluetoothServiceInfo,
BluetoothServiceInfoBleak,
HaBluetoothConnector,
HaBluetoothSlotAllocations,
HaScannerDetails,
HaScannerModeChange,
HaScannerRegistration,
HaScannerRegistrationEvent,
HaScannerType,
)
from .scanner import BluetoothScanningMode, HaScanner, ScannerStartError
from .scanner_device import BluetoothScannerDevice
from .storage import (
DiscoveredDeviceAdvertisementData,
DiscoveredDeviceAdvertisementDataDict,
DiscoveryStorageType,
discovered_device_advertisement_data_from_dict,
discovered_device_advertisement_data_to_dict,
expire_stale_scanner_discovered_device_advertisement_data,
)
from .wrappers import HaBleakClientWrapper, HaBleakScannerWrapper
__all__ = [
"CONNECTABLE_FALLBACK_MAXIMUM_STALE_ADVERTISEMENT_SECONDS",
"FALLBACK_MAXIMUM_STALE_ADVERTISEMENT_SECONDS",
"SCANNER_WATCHDOG_INTERVAL",
"SCANNER_WATCHDOG_TIMEOUT",
"TRACKER_BUFFERING_WOBBLE_SECONDS",
"UNAVAILABLE_TRACK_SECONDS",
"AdvertisementTracker",
"Allocations",
"BaseHaRemoteScanner",
"BaseHaScanner",
"BluetoothManager",
"BluetoothScannerDevice",
"BluetoothScanningMode",
"BluetoothServiceInfo",
"BluetoothServiceInfoBleak",
"DiscoveredDeviceAdvertisementData",
"DiscoveredDeviceAdvertisementDataDict",
"DiscoveryStorageType",
"HaBleakClientWrapper",
"HaBleakScannerWrapper",
"HaBluetoothConnector",
"HaBluetoothSlotAllocations",
"HaScanner",
"HaScannerDetails",
"HaScannerModeChange",
"HaScannerRegistration",
"HaScannerRegistrationEvent",
"HaScannerType",
"ScannerStartError",
"discovered_device_advertisement_data_from_dict",
"discovered_device_advertisement_data_to_dict",
"expire_stale_scanner_discovered_device_advertisement_data",
"get_manager",
"set_manager",
]
Bluetooth-Devices-habluetooth-21accde/src/habluetooth/advertisement_tracker.pxd 0000664 0000000 0000000 00000000757 15070247161 0030342 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-21accde/src/habluetooth/advertisement_tracker.py 0000664 0000000 0000000 00000007207 15070247161 0030174 0 ustar 00root root 0000000 0000000 """The advertisement tracker."""
from __future__ import annotations
from typing import Any
from .models import BluetoothServiceInfoBleak
ADVERTISING_TIMES_NEEDED = 16
_ADVERTISING_TIMES_NEEDED = ADVERTISING_TIMES_NEEDED
# Each scanner may buffer incoming packets so
# we need to give a bit of leeway before we
# mark a device unavailable
TRACKER_BUFFERING_WOBBLE_SECONDS = 5
_str = str
class AdvertisementTracker:
"""Tracker to determine the interval that a device is advertising."""
__slots__ = ("_timings", "fallback_intervals", "intervals", "sources")
def __init__(self) -> None:
"""Initialize the tracker."""
self.intervals: dict[str, float] = {}
self.fallback_intervals: dict[str, float] = {}
self.sources: dict[str, str] = {}
self._timings: dict[str, list[float]] = {}
def async_diagnostics(self) -> dict[str, dict[str, Any]]:
"""Return diagnostics."""
return {
"intervals": self.intervals,
"fallback_intervals": self.fallback_intervals,
"sources": self.sources,
"timings": self._timings,
}
def async_collect(self, service_info: BluetoothServiceInfoBleak) -> None:
"""
Collect timings for the tracker.
For performance reasons, it is the responsibility of the
caller to check if the device already has an interval set or
the source has changed before calling this function.
"""
self.sources[service_info.address] = service_info.source
if not (timings := self._timings.get(service_info.address)):
self._timings[service_info.address] = [service_info.time]
return
timings.append(service_info.time)
if len(timings) != _ADVERTISING_TIMES_NEEDED:
return
max_time_between_advertisements = timings[1] - timings[0]
for i in range(2, len(timings)):
time_between_advertisements = timings[i] - timings[i - 1]
if time_between_advertisements > max_time_between_advertisements:
max_time_between_advertisements = time_between_advertisements
# We now know the maximum time between advertisements
self.intervals[service_info.address] = max_time_between_advertisements
del self._timings[service_info.address]
def async_remove_address(self, address: _str) -> None:
"""Remove the tracker."""
self.intervals.pop(address, None)
self.sources.pop(address, None)
self._timings.pop(address, None)
def async_remove_fallback_interval(self, address: str) -> None:
"""Remove fallback interval."""
self.fallback_intervals.pop(address, None)
def async_remove_source(self, source: str) -> None:
"""Remove the tracker."""
for address, tracked_source in list(self.sources.items()):
if tracked_source == source:
self.async_remove_address(address)
def async_scanner_paused(self, source: str) -> None:
"""
Clear timing collection data when scanner is paused.
When a scanner pauses to establish a connection, it stops listening
for advertisements. If we don't clear the timing data, the next
advertisement after the connection attempt will create an incorrectly
large interval measurement (time_after_connection - time_before_connection)
which doesn't represent the actual advertising interval of the device.
"""
# Only iterate through timing data (typically much smaller than sources)
for address in list(self._timings):
if self.sources.get(address) == source:
del self._timings[address]
Bluetooth-Devices-habluetooth-21accde/src/habluetooth/base_scanner.pxd 0000664 0000000 0000000 00000007322 15070247161 0026373 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
cpdef void _clear_connection_history(self) except *
cpdef void _finished_connecting(self, str address, bint connected) except *
cdef void _increase_count(self, dict target, str address) except *
cdef void _add_connect_failure(self, str address) except *
cpdef void _add_connecting(self, str address) except *
cdef void _remove_connecting(self, str address) except *
cdef void _clear_connect_failure(self, str address) except *
@cython.locals(
in_progress=Py_ssize_t,
count=Py_ssize_t
)
cpdef _connections_in_progress(self)
cpdef _connection_failures(self, str address)
@cython.locals(
score=double,
scanner_connections_in_progress=Py_ssize_t,
previous_failures=Py_ssize_t
)
cpdef _score_connection_paths(self, int rssi_diff, object scanner_device)
cpdef tuple get_discovered_device_advertisement_data(self, str address)
cpdef float time_since_last_detection(self)
@cython.locals(info=BluetoothServiceInfoBleak)
cdef dict _build_discovered_device_advertisement_datas(self)
@cython.locals(info=BluetoothServiceInfoBleak)
cdef dict _build_discovered_device_timestamps(self)
@cython.locals(parsed=tuple)
cpdef void _async_on_raw_advertisement(
self,
str address,
int rssi,
bytes raw,
dict details,
double advertisement_monotonic_time
)
@cython.locals(
prev_name=str,
prev_discovery=tuple,
has_local_name=bint,
has_manufacturer_data=bint,
has_service_data=bint,
has_service_uuids=bint,
sub_value=bytes,
super_value=bytes,
info=BluetoothServiceInfoBleak,
prev_info=BluetoothServiceInfoBleak
)
cdef void _async_on_advertisement_internal(
self,
str address,
int rssi,
str local_name,
list service_uuids,
dict service_data,
dict manufacturer_data,
object tx_power,
dict details,
double advertisement_monotonic_time,
bytes raw
)
cpdef void _async_on_advertisement(
self,
str address,
int rssi,
str local_name,
list service_uuids,
dict service_data,
dict manufacturer_data,
object tx_power,
dict details,
double advertisement_monotonic_time
)
@cython.locals(now=double, timestamp=double, info=BluetoothServiceInfoBleak)
cpdef void _async_expire_devices(self)
cpdef void _schedule_expire_devices(self)
cdef class BaseHaRemoteScanner(BaseHaScanner):
@cython.locals(info=BluetoothServiceInfoBleak)
cpdef tuple get_discovered_device_advertisement_data(self, str address)
Bluetooth-Devices-habluetooth-21accde/src/habluetooth/base_scanner.py 0000664 0000000 0000000 00000064511 15070247161 0026233 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 collections.abc import Generator, Iterable
from contextlib import contextmanager
from typing import TYPE_CHECKING, Any, Final, final
from bleak.backends.device import BLEDevice
from bleak.backends.scanner import AdvertisementData
from bleak_retry_connector import NO_RSSI_VALUE, Allocations
from bluetooth_adapters import adapter_human_name
from bluetooth_data_tools import monotonic_time_coarse, parse_advertisement_data_bytes
from .central_manager import get_manager
from .const import (
CALLBACK_TYPE,
CONNECTABLE_FALLBACK_MAXIMUM_STALE_ADVERTISEMENT_SECONDS,
SCANNER_WATCHDOG_INTERVAL,
SCANNER_WATCHDOG_TIMEOUT,
)
from .models import (
BluetoothScanningMode,
BluetoothServiceInfoBleak,
HaBluetoothConnector,
HaScannerDetails,
HaScannerType,
)
from .scanner_device import BluetoothScannerDevice
from .storage import DiscoveredDeviceAdvertisementData
SCANNER_WATCHDOG_INTERVAL_SECONDS: Final = SCANNER_WATCHDOG_INTERVAL.total_seconds()
_LOGGER = logging.getLogger(__name__)
_bytes = bytes
_float = float
_int = int
_str = str
class BaseHaScanner:
"""Base class for high availability BLE scanners."""
__slots__ = (
"_cancel_track",
"_cancel_watchdog",
"_connect_failures",
"_connect_in_progress",
"_connecting",
"_details",
"_expire_seconds",
"_last_detection",
"_loop",
"_manager",
"_previous_service_info",
"_start_time",
"adapter",
"connectable",
"connector",
"current_mode",
"details",
"name",
"requested_mode",
"scanning",
"source",
)
def __init__(
self,
source: str,
adapter: str,
connector: HaBluetoothConnector | None = None,
connectable: bool = False,
requested_mode: BluetoothScanningMode | None = None,
current_mode: BluetoothScanningMode | None = None,
) -> None:
"""Initialize the scanner."""
self.connectable = connectable
self.source = source
self.connector = connector
self._connecting = 0
self.adapter = adapter
self.name = adapter_human_name(adapter, source) if adapter != source else source
self.scanning: bool = True
self.requested_mode = requested_mode
self.current_mode = current_mode
self._last_detection = 0.0
self._start_time = 0.0
self._cancel_watchdog: asyncio.TimerHandle | None = None
self._loop: asyncio.AbstractEventLoop | None = None
self._manager = get_manager()
# Determine scanner type based on class type
scanner_type = HaScannerType.UNKNOWN
if isinstance(self, BaseHaRemoteScanner):
scanner_type = HaScannerType.REMOTE
# Try to get adapter type from manager's cached adapters
elif (
(adapters := self._manager.get_cached_bluetooth_adapters())
and (adapter_details := adapters.get(adapter))
and (adapter_type := adapter_details.get("adapter_type"))
):
if adapter_type == "usb":
scanner_type = HaScannerType.USB
elif adapter_type == "uart":
scanner_type = HaScannerType.UART
self.details = HaScannerDetails(
source=self.source,
connectable=self.connectable,
name=self.name,
adapter=self.adapter,
scanner_type=scanner_type,
)
self._previous_service_info: dict[str, BluetoothServiceInfoBleak] = {}
# Scanners only care about connectable devices. The manager
# will handle taking care of availability for non-connectable devices
self._expire_seconds = CONNECTABLE_FALLBACK_MAXIMUM_STALE_ADVERTISEMENT_SECONDS
self._details: dict[str, str | HaBluetoothConnector] = {"source": source}
self._cancel_track: asyncio.TimerHandle | None = None
self._connect_failures: dict[str, int] = {}
self._connect_in_progress: dict[str, int] = {}
def _on_start_success(self) -> None:
"""
Called when the scanner successfully starts.
Notifies the manager that this scanner has started.
"""
if self._manager:
self._manager.on_scanner_start(self)
def _clear_connection_history(self) -> None:
"""Clear the connection history for a scanner."""
self._connect_failures.clear()
self._connect_in_progress.clear()
def _finished_connecting(self, address: str, connected: bool) -> None:
"""Finished connecting."""
self._remove_connecting(address)
if connected:
self._clear_connect_failure(address)
else:
self._add_connect_failure(address)
def _increase_count(self, target: dict[str, int], address: str) -> None:
"""Increase the reference count."""
if address in target:
target[address] += 1
else:
target[address] = 1
def _add_connect_failure(self, address: str) -> None:
"""Add a connect failure."""
self._increase_count(self._connect_failures, address)
def _add_connecting(self, address: str) -> None:
"""Add a connecting."""
self._increase_count(self._connect_in_progress, address)
# Clear timing collection data when scanner pauses for connection
# to prevent collecting invalid advertising interval data
self._manager._advertisement_tracker.async_scanner_paused(self.source)
def _remove_connecting(self, address: str) -> None:
"""Remove a connecting."""
if address not in self._connect_in_progress:
_LOGGER.warning(
"Removing a non-existing connecting %s %s", self.name, address
)
return
self._connect_in_progress[address] -= 1
if not self._connect_in_progress[address]:
del self._connect_in_progress[address]
def _clear_connect_failure(self, address: str) -> None:
"""Clear a connect failure."""
self._connect_failures.pop(address, None)
def get_allocations(self) -> Allocations | None:
"""
Get current connection slot allocations for this scanner.
Returns:
Allocations object with free/limit/allocated info, or None if not available.
Note:
Subclasses should override this method to provide their allocation info.
For local adapters, this will be overridden in HaScanner to query
BleakSlotManager.
For remote scanners, they should override to return their own tracking.
"""
return None
def _score_connection_paths(
self, rssi_diff: _int, scanner_device: BluetoothScannerDevice
) -> float:
"""Score the connection paths considering slot availability."""
address = scanner_device.ble_device.address
score = scanner_device.advertisement.rssi or NO_RSSI_VALUE
scanner_connections_in_progress = len(self._connect_in_progress)
previous_failures = self._connect_failures.get(address, 0)
# Use a minimum rssi_diff of 1 to ensure penalties are meaningful
# even when scanners have identical RSSI
effective_rssi_diff = max(rssi_diff, 1)
# Penalize scanners with connections in progress
if scanner_connections_in_progress:
# Very large penalty for multiple connections in progress
# to avoid overloading the adapter
score -= effective_rssi_diff * scanner_connections_in_progress * 1.01
# Penalize based on previous failures
if previous_failures:
score -= effective_rssi_diff * previous_failures * 0.51
# Consider connection slot availability
allocation = self.get_allocations()
if allocation and allocation.slots > 0:
if allocation.free == 0:
# No slots available - return NO_RSSI_VALUE to indicate unavailable
return NO_RSSI_VALUE
if allocation.free == 1:
# Last slot available - small penalty to prefer adapters with more slots
score -= effective_rssi_diff * 0.76
return score
def _connections_in_progress(self) -> int:
"""Return if the connection is in progress."""
in_progress = 0
for count in self._connect_in_progress.values():
in_progress += count
return in_progress
def _connection_failures(self, address: str) -> int:
"""Return the number of failures."""
return self._connect_failures.get(address, 0)
def time_since_last_detection(self) -> float:
"""Return the time since the last detection."""
return monotonic_time_coarse() - self._last_detection
@property
def adapter_idx(self) -> int | None:
"""Return the adapter index if this is an hci adapter, None otherwise."""
if self.adapter and self.adapter.startswith("hci"):
return int(self.adapter.removeprefix("hci"))
return None
def async_setup(self) -> CALLBACK_TYPE:
"""Set up the scanner."""
self._loop = asyncio.get_running_loop()
self._schedule_expire_devices()
return self._unsetup
def _async_stop_scanner_watchdog(self) -> None:
"""Stop the scanner watchdog."""
if self._cancel_watchdog:
self._cancel_watchdog.cancel()
self._cancel_watchdog = None
def _async_setup_scanner_watchdog(self) -> None:
"""If something has restarted or updated, we need to restart the scanner."""
self._start_time = self._last_detection = monotonic_time_coarse()
if not self._cancel_watchdog:
self._schedule_watchdog()
def _schedule_watchdog(self) -> None:
"""Schedule the watchdog."""
loop = self._loop
if TYPE_CHECKING:
assert loop is not None
self._cancel_watchdog = loop.call_at(
loop.time() + SCANNER_WATCHDOG_INTERVAL_SECONDS,
self._async_call_scanner_watchdog,
)
@final
def _async_call_scanner_watchdog(self) -> None:
"""Call the scanner watchdog and schedule the next one."""
self._async_scanner_watchdog()
self._schedule_watchdog()
def _async_watchdog_triggered(self) -> bool:
"""Check if the watchdog has been triggered."""
time_since_last_detection = self.time_since_last_detection()
_LOGGER.debug(
"%s: Scanner watchdog time_since_last_detection: %s",
self.name,
time_since_last_detection,
)
return time_since_last_detection > SCANNER_WATCHDOG_TIMEOUT
def _async_scanner_watchdog(self) -> None:
"""
Check if the scanner is running.
Override this method if you need to do something else when the watchdog
is triggered.
"""
if self._async_watchdog_triggered():
_LOGGER.debug(
(
"%s: Bluetooth scanner has gone quiet for %ss, check logs on the"
" scanner device for more information"
),
self.name,
self.time_since_last_detection(),
)
self.scanning = False
return
self.scanning = not self._connecting
def _unsetup(self) -> None:
"""Unset up the scanner."""
self._cancel_expire_devices()
@contextmanager
def connecting(self) -> Generator[None, None, None]:
"""Context manager to track connecting state."""
self._connecting += 1
self.scanning = not self._connecting
try:
yield
finally:
self._connecting -= 1
self.scanning = not self._connecting
@property
def discovered_devices(self) -> list[BLEDevice]:
"""Return a list of discovered devices."""
raise NotImplementedError
@property
def discovered_devices_and_advertisement_data(
self,
) -> dict[str, tuple[BLEDevice, AdvertisementData]]:
"""Return a list of discovered devices and their advertisement data."""
raise NotImplementedError
@property
def discovered_addresses(self) -> Iterable[str]:
"""Return an iterable of discovered devices."""
raise NotImplementedError
def get_discovered_device_advertisement_data(
self, address: str
) -> tuple[BLEDevice, AdvertisementData] | None:
"""Return the advertisement data for a discovered device."""
raise NotImplementedError
async def async_diagnostics(self) -> dict[str, Any]:
"""Return diagnostic information about the scanner."""
device_adv_datas = self.discovered_devices_and_advertisement_data.values()
return {
"name": self.name,
"connectable": self.connectable,
"start_time": self._start_time,
"source": self.source,
"scanning": self.scanning,
"requested_mode": self.requested_mode,
"current_mode": self.current_mode,
"type": self.__class__.__name__,
"last_detection": self._last_detection,
"monotonic_time": monotonic_time_coarse(),
"discovered_devices_and_advertisement_data": [
{
"name": device.name,
"address": device.address,
"rssi": advertisement_data.rssi,
"advertisement_data": advertisement_data,
"details": device.details,
}
for device, advertisement_data in device_adv_datas
],
}
def restore_discovered_devices(
self, history: DiscoveredDeviceAdvertisementData
) -> None:
"""Restore discovered devices from a previous run."""
discovered_device_timestamps = history.discovered_device_timestamps
self._previous_service_info = {
address: BluetoothServiceInfoBleak(
device.name or address,
address,
adv.rssi,
adv.manufacturer_data,
adv.service_data,
adv.service_uuids,
self.source,
device,
adv,
self.connectable,
discovered_device_timestamps[address],
adv.tx_power,
history.discovered_device_raw.get(address),
)
for address, (
device,
adv,
) in history.discovered_device_advertisement_datas.items()
}
# Expire anything that is too old
self._async_expire_devices()
def serialize_discovered_devices(
self,
) -> DiscoveredDeviceAdvertisementData:
"""Serialize discovered devices to be stored."""
return DiscoveredDeviceAdvertisementData(
self.connectable,
self._expire_seconds,
self._build_discovered_device_advertisement_datas(),
self._build_discovered_device_timestamps(),
self._build_discovered_device_raw(),
)
@property
def _discovered_device_timestamps(self) -> dict[str, float]:
"""Return a dict of discovered device timestamps."""
warnings.warn(
"BaseHaRemoteScanner._discovered_device_timestamps is deprecated "
"and will be removed in a future version of habluetooth, use "
"BaseHaRemoteScanner.discovered_device_timestamps instead",
FutureWarning,
stacklevel=2,
)
return self._build_discovered_device_timestamps()
@property
def discovered_device_timestamps(self) -> dict[str, float]:
"""Return a dict of discovered device timestamps."""
return self._build_discovered_device_timestamps()
def _build_discovered_device_advertisement_datas(
self,
) -> dict[str, tuple[BLEDevice, AdvertisementData]]:
"""Return a list of discovered devices and advertisement data."""
return {
address: (info.device, info._advertisement_internal())
for address, info in self._previous_service_info.items()
}
def _build_discovered_device_timestamps(self) -> dict[str, float]:
"""Return a dict of discovered device timestamps."""
return {
address: info.time for address, info in self._previous_service_info.items()
}
def _build_discovered_device_raw(self) -> dict[str, bytes | None]:
"""Return a dict of discovered device raw advertisement data."""
return {
address: info.raw for address, info in self._previous_service_info.items()
}
def _async_on_raw_advertisement(
self,
address: _str,
rssi: _int,
raw: _bytes,
details: dict[str, Any],
advertisement_monotonic_time: _float,
) -> None:
parsed = parse_advertisement_data_bytes(raw)
self._async_on_advertisement_internal(
address,
rssi,
parsed[0],
parsed[1],
parsed[2],
parsed[3],
parsed[4],
details,
advertisement_monotonic_time,
raw,
)
def _async_on_advertisement(
self,
address: _str,
rssi: _int,
local_name: _str | None,
service_uuids: list[str],
service_data: dict[str, bytes],
manufacturer_data: dict[int, bytes],
tx_power: _int | None,
details: dict[Any, Any],
advertisement_monotonic_time: _float,
) -> None:
self._async_on_advertisement_internal(
address,
rssi,
local_name,
service_uuids,
service_data,
manufacturer_data,
tx_power,
details,
advertisement_monotonic_time,
None,
)
def _async_on_advertisement_internal(
self,
address: _str,
rssi: _int,
local_name: _str | None,
service_uuids: list[str],
service_data: dict[str, bytes],
manufacturer_data: dict[int, bytes],
tx_power: _int | None,
details: dict[Any, Any],
advertisement_monotonic_time: _float,
raw: _bytes | None,
) -> None:
"""Call the registered callback."""
self.scanning = not self._connecting
self._last_detection = advertisement_monotonic_time
info = BluetoothServiceInfoBleak.__new__(BluetoothServiceInfoBleak)
if (prev_info := self._previous_service_info.get(address)) is None:
# We expect this is the rare case and since py3.11+ has
# near zero cost try on success, and we can avoid .get()
# which is slower than [] we use the try/except pattern.
info.device = BLEDevice(
address,
local_name,
{**self._details, **details},
)
info.manufacturer_data = manufacturer_data
info.service_data = service_data
info.service_uuids = service_uuids
info.name = local_name or address
else:
# Merge the new data with the old data
# to function the same as BlueZ which
# merges the dicts on PropertiesChanged
info.device = prev_info.device
prev_name = prev_info.device.name
#
# Bleak updates the BLEDevice via create_or_update_device.
# We need to do the same to ensure integrations that already
# have the BLEDevice object get the updated details when they
# change.
#
# https://github.com/hbldh/bleak/blob/222618b7747f0467dbb32bd3679f8cfaa19b1668/bleak/backends/scanner.py#L203
if prev_name is not None and (
prev_name is local_name
or not local_name
or len(prev_name) > len(local_name)
):
info.name = prev_name
else:
info.device.name = local_name
info.name = local_name if local_name else address
has_service_uuids = bool(service_uuids)
if (
has_service_uuids
and service_uuids is not prev_info.service_uuids
and service_uuids != prev_info.service_uuids
):
info.service_uuids = list({*service_uuids, *prev_info.service_uuids})
elif not has_service_uuids:
info.service_uuids = prev_info.service_uuids
else:
info.service_uuids = service_uuids
has_service_data = bool(service_data)
if has_service_data and service_data is not prev_info.service_data:
for uuid, sub_value in service_data.items():
if (
super_value := prev_info.service_data.get(uuid)
) is None or super_value != sub_value:
info.service_data = {
**prev_info.service_data,
**service_data,
}
break
else:
info.service_data = prev_info.service_data
elif not has_service_data:
info.service_data = prev_info.service_data
else:
info.service_data = service_data
has_manufacturer_data = bool(manufacturer_data)
if (
has_manufacturer_data
and manufacturer_data is not prev_info.manufacturer_data
):
for id_, sub_value in manufacturer_data.items():
if (
super_value := prev_info.manufacturer_data.get(id_)
) is None or super_value != sub_value:
info.manufacturer_data = {
**prev_info.manufacturer_data,
**manufacturer_data,
}
break
else:
info.manufacturer_data = prev_info.manufacturer_data
elif not has_manufacturer_data:
info.manufacturer_data = prev_info.manufacturer_data
else:
info.manufacturer_data = manufacturer_data
info.address = address
info.rssi = rssi
info.source = self.source
info._advertisement = None
info.connectable = self.connectable
info.time = advertisement_monotonic_time
info.tx_power = tx_power
info.raw = raw
self._previous_service_info[address] = info
self._manager.scanner_adv_received(info)
def _async_expire_devices(self) -> None:
"""Expire old devices."""
now = monotonic_time_coarse()
expired = [
address
for address, info in self._previous_service_info.items()
if now - info.time > self._expire_seconds
]
for address in expired:
del self._previous_service_info[address]
def _cancel_expire_devices(self) -> None:
"""Cancel the expiration of old devices."""
if self._cancel_track:
self._cancel_track.cancel()
self._cancel_track = None
def _schedule_expire_devices(self) -> None:
"""Schedule the expiration of old devices."""
loop = self._loop
if TYPE_CHECKING:
assert loop is not None
self._cancel_expire_devices()
self._cancel_track = loop.call_at(
loop.time() + 30, self._async_expire_devices_schedule_next
)
def _async_expire_devices_schedule_next(self) -> None:
"""Expire old devices and schedule the next expiration."""
self._async_expire_devices()
self._schedule_expire_devices()
def set_requested_mode(self, mode: BluetoothScanningMode | None) -> None:
"""Set the requested scanning mode and notify the manager."""
if self.requested_mode != mode:
self.requested_mode = mode
self._manager.scanner_mode_changed(self)
def set_current_mode(self, mode: BluetoothScanningMode | None) -> None:
"""Set the current scanning mode and notify the manager."""
if self.current_mode != mode:
self.current_mode = mode
self._manager.scanner_mode_changed(self)
class BaseHaRemoteScanner(BaseHaScanner):
"""Base class for a high availability remote BLE scanner."""
def _unsetup(self) -> None:
"""Unset up the scanner."""
super()._unsetup()
self._async_stop_scanner_watchdog()
def async_setup(self) -> CALLBACK_TYPE:
"""Set up the scanner."""
super().async_setup()
self._async_setup_scanner_watchdog()
return self._unsetup
@property
def discovered_devices(self) -> list[BLEDevice]:
"""Return a list of discovered devices."""
infos = self._previous_service_info.values()
return [device_advertisement_data.device for device_advertisement_data in infos]
@property
def discovered_devices_and_advertisement_data(
self,
) -> dict[str, tuple[BLEDevice, AdvertisementData]]:
"""Return a list of discovered devices and advertisement data."""
return self._build_discovered_device_advertisement_datas()
@property
def discovered_addresses(self) -> Iterable[str]:
"""Return an iterable of discovered devices."""
return self._previous_service_info
def get_discovered_device_advertisement_data(
self, address: str
) -> tuple[BLEDevice, AdvertisementData] | None:
"""Return the advertisement data for a discovered device."""
if (info := self._previous_service_info.get(address)) is not None:
return info.device, info.advertisement
return None
async def async_diagnostics(self) -> dict[str, Any]:
"""Return diagnostic information about the scanner."""
now = monotonic_time_coarse()
discovered_device_timestamps = self._build_discovered_device_timestamps()
return await super().async_diagnostics() | {
"discovered_device_timestamps": discovered_device_timestamps,
"raw_advertisement_data": {
address: info.raw
for address, info in self._previous_service_info.items()
},
"time_since_last_device_detection": {
address: now - timestamp
for address, timestamp in discovered_device_timestamps.items()
},
}
Bluetooth-Devices-habluetooth-21accde/src/habluetooth/central_manager.py 0000664 0000000 0000000 00000001213 15070247161 0026720 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:
raise RuntimeError("BluetoothManager has not been set")
return CentralBluetoothManager.manager
def set_manager(manager: BluetoothManager) -> None:
"""Set the BluetoothManager."""
CentralBluetoothManager.manager = manager
Bluetooth-Devices-habluetooth-21accde/src/habluetooth/channels/ 0000775 0000000 0000000 00000000000 15070247161 0025022 5 ustar 00root root 0000000 0000000 Bluetooth-Devices-habluetooth-21accde/src/habluetooth/channels/bluez.pxd 0000664 0000000 0000000 00000002752 15070247161 0026666 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
@cython.locals(bytes_data=bytes)
cdef void _add_to_buffer(self, object data) except *
@cython.locals(end_of_frame_pos="unsigned int", cstr="const unsigned char *")
cdef void _remove_from_buffer(self) except *
@cython.locals(
header="const unsigned char *",
event_code="unsigned short",
controller_idx="unsigned short",
param_len="unsigned short",
rssi="short",
flags="unsigned int",
data="bytes",
parse_offset="unsigned short",
scanner=HaScanner,
opcode="unsigned short",
status="unsigned char",
param_offset="unsigned short",
param_count="unsigned short"
)
cpdef void data_received(self, object data) except *
cdef void _handle_load_conn_param_response(
self, unsigned char status, unsigned short controller_idx
) except *
Bluetooth-Devices-habluetooth-21accde/src/habluetooth/channels/bluez.py 0000664 0000000 0000000 00000051047 15070247161 0026524 0 ustar 00root root 0000000 0000000 from __future__ import annotations
import asyncio
import logging
import socket
from asyncio import timeout as asyncio_timeout
from collections.abc import AsyncIterator, Callable
from contextlib import asynccontextmanager
from struct import Struct
from typing import TYPE_CHECKING, cast
from btsocket import btmgmt_socket
from btsocket.btmgmt_socket import BluetoothSocketError
from ..const import (
FAST_CONN_LATENCY,
FAST_CONN_TIMEOUT,
FAST_MAX_CONN_INTERVAL,
FAST_MIN_CONN_INTERVAL,
MEDIUM_CONN_LATENCY,
MEDIUM_CONN_TIMEOUT,
MEDIUM_MAX_CONN_INTERVAL,
MEDIUM_MIN_CONN_INTERVAL,
ConnectParams,
)
from ..scanner import HaScanner
_LOGGER = logging.getLogger(__name__)
_int = int
_bytes = bytes
# Everything is little endian
HEADER_SIZE = 6
# Header is event_code (2 bytes), controller_idx (2 bytes), param_len (2 bytes)
DEVICE_FOUND = 0x0012
ADV_MONITOR_DEVICE_FOUND = 0x002F
# Management commands
MGMT_OP_GET_CONNECTIONS = 0x0015
MGMT_OP_LOAD_CONN_PARAM = 0x0035
# Management events
MGMT_EV_CMD_COMPLETE = 0x0001
MGMT_EV_CMD_STATUS = 0x0002
# Pre-compiled struct formats for performance
COMMAND_HEADER = Struct(" None:
"""Set the future result if not done."""
if future is not None and not future.done():
future.set_result(None)
class BluetoothMGMTProtocol:
"""Bluetooth MGMT protocol."""
def __init__(
self,
connection_made_future: asyncio.Future[None],
scanners: dict[int, HaScanner],
on_connection_lost: Callable[[], None],
is_shutting_down: Callable[[], bool],
sock: socket.socket,
) -> None:
"""Initialize the protocol."""
self.transport: asyncio.Transport | None = None
self.connection_made_future = connection_made_future
self._buffer: bytes | None = None
self._buffer_len = 0
self._pos = 0
self._scanners = scanners
self._on_connection_lost = on_connection_lost
self._is_shutting_down = is_shutting_down
self._pending_commands: dict[int, asyncio.Future[tuple[int, bytes]]] = {}
self._sock = sock
def connection_made(self, transport: asyncio.BaseTransport) -> None:
"""Handle connection made."""
_set_future_if_not_done(self.connection_made_future)
self.transport = cast(asyncio.Transport, transport)
def _write_to_socket(self, data: bytes) -> None:
"""
Write data directly to the socket, bypassing asyncio transport.
This works around a kernel bug where sendto() on Bluetooth management
sockets returns 0 instead of the number of bytes sent on some platforms
(e.g., Odroid M1 with kernel 6.12.43). When asyncio sees 0, it thinks
the send failed and retries forever.
Since mgmt sockets are SOCK_RAW, sends are atomic - either the entire
packet is sent or nothing is sent.
"""
try:
n = self._sock.send(data)
# On buggy kernels, n might be 0 even though the data was sent
# We treat 0 as success for mgmt sockets
if n == 0 and len(data) > 0:
# Kernel bug: returned 0 but data was actually sent
_LOGGER.debug(
"Bluetooth mgmt socket returned 0 for %d bytes (kernel bug fix)",
len(data),
)
except Exception as exc:
_LOGGER.error("Failed to write to mgmt socket: %s", exc)
raise
@asynccontextmanager
async def command_response(
self, opcode: int
) -> AsyncIterator[asyncio.Future[tuple[int, bytes]]]:
"""
Context manager for handling command responses.
Usage:
async with protocol.command_response(opcode) as future:
transport.write(command)
status, data = await future
"""
future: asyncio.Future[tuple[int, bytes]] = (
asyncio.get_running_loop().create_future()
)
self._pending_commands[opcode] = future
try:
yield future
finally:
# Clean up if the future wasn't resolved
self._pending_commands.pop(opcode, None)
def _add_to_buffer(self, data: bytes | bytearray | memoryview) -> None:
"""Add data to the buffer."""
# Protractor sends a bytearray, so we need to convert it to bytes
# https://github.com/esphome/issues/issues/5117
# type(data) should not be isinstance(data, bytes) because we want to
# to explicitly check for bytes and not for subclasses of bytes
bytes_data = bytes(data) if type(data) is not bytes else data
if self._buffer_len == 0:
# This is the best case scenario, we don't have to copy the data
# and can just use the buffer directly. This is the most common
# case as well.
self._buffer = bytes_data
else:
if TYPE_CHECKING:
assert self._buffer is not None, "Buffer should be set"
# This is the worst case scenario, we have to copy the bytes_data
# and can't just use the buffer directly. This is also very
# uncommon since we usually read the entire frame at once.
self._buffer += bytes_data
self._buffer_len += len(bytes_data)
def _remove_from_buffer(self) -> None:
"""Remove data from the buffer."""
end_of_frame_pos = self._pos
self._buffer_len -= end_of_frame_pos
if self._buffer_len == 0:
# This is the best case scenario, we can just set the buffer to None
# and don't have to copy the data. This is the most common case as well.
self._buffer = None
return
if TYPE_CHECKING:
assert self._buffer is not None, "Buffer should be set"
# This is the worst case scenario, we have to copy the data
# and can't just use the buffer directly. This should only happen
# when we read multiple frames at once because the event loop
# is blocked and we cannot pull the data out of the buffer fast enough.
cstr = self._buffer
# Important: we must use the explicit length for the slice
# since Cython will stop at any '\0' character if we don't
self._buffer = cstr[end_of_frame_pos : self._buffer_len + end_of_frame_pos]
def data_received(self, data: _bytes) -> None:
"""Handle data received."""
self._add_to_buffer(data)
while self._buffer_len >= 6:
if TYPE_CHECKING:
assert self._buffer is not None, "Buffer should be set"
self._pos = 6
header = self._buffer
event_code = header[0] | (header[1] << 8)
controller_idx = header[2] | (header[3] << 8)
param_len = header[4] | (header[5] << 8)
if self._buffer_len < self._pos + param_len:
# We don't have the entire frame yet, so we need to wait
# for more data to arrive.
return
self._pos += param_len
if event_code == DEVICE_FOUND:
parse_offset = 6
elif event_code == ADV_MONITOR_DEVICE_FOUND:
parse_offset = 8
elif event_code in {MGMT_EV_CMD_COMPLETE, MGMT_EV_CMD_STATUS}:
# Handle management command responses
if param_len >= 3:
opcode = header[6] | (header[7] << 8)
status = header[8]
if opcode == MGMT_OP_LOAD_CONN_PARAM:
self._handle_load_conn_param_response(status, controller_idx)
elif (
opcode == MGMT_OP_GET_CONNECTIONS
and opcode in self._pending_commands
):
# Handle GET_CONNECTIONS response for capability check
future = self._pending_commands.pop(opcode)
if not future.done():
# Return status and any response data
response_data = (
header[9 : self._pos] if param_len > 3 else b""
)
future.set_result((status, response_data))
self._remove_from_buffer()
continue
else:
self._remove_from_buffer()
continue
address = header[parse_offset : parse_offset + 6]
address_type = header[parse_offset + 6]
rssi = header[parse_offset + 7]
if rssi > 128:
rssi -= 256
flags = (
header[parse_offset + 8]
| (header[parse_offset + 9] << 8)
| (header[parse_offset + 10] << 16)
| (header[parse_offset + 11] << 24)
)
# Skip AD_Data_Length (2 bytes) at parse_offset+12 and +13
data = header[parse_offset + 14 : self._pos]
self._remove_from_buffer()
if (scanner := self._scanners.get(controller_idx)) is not None:
# We have a scanner for this controller, so we can
# pass the data to it.
scanner._async_on_raw_bluez_advertisement(
address,
address_type,
rssi,
flags,
data,
)
def _handle_load_conn_param_response(
self, status: _int, controller_idx: _int
) -> None:
"""Handle MGMT_OP_LOAD_CONN_PARAM response."""
if status != 0:
_LOGGER.warning(
"hci%u: Failed to load conn params: status=%d",
controller_idx,
status,
)
else:
_LOGGER.debug(
"hci%u: Connection parameters loaded successfully",
controller_idx,
)
def connection_lost(self, exc: Exception | None) -> None:
"""Handle connection lost."""
# Only suppress warnings during shutdown, not info messages
if exc:
if not self._is_shutting_down():
_LOGGER.warning("Bluetooth management socket connection lost: %s", exc)
else:
_LOGGER.info("Bluetooth management socket connection closed")
self.transport = None
self._on_connection_lost()
class MGMTBluetoothCtl:
"""Class to control interfaces using the BlueZ management API."""
def __init__(self, timeout: float, scanners: dict[int, HaScanner]) -> None:
"""Initialize the control class."""
# Internal state
self.timeout = timeout
self.protocol: BluetoothMGMTProtocol | None = None
self.sock: socket.socket | None = None
self.scanners = scanners
self._reconnect_task: asyncio.Task[None] | None = None
self._on_connection_lost_future: asyncio.Future[None] | None = None
self._shutting_down = False
def close(self) -> None:
"""Close the management interface."""
self._shutting_down = True
if self._reconnect_task:
self._reconnect_task.cancel()
if self.protocol and self.protocol.transport:
self.protocol.transport.close()
self.protocol = None
btmgmt_socket.close(self.sock)
def _on_connection_lost(self) -> None:
"""Handle connection lost."""
if self._shutting_down:
_LOGGER.debug("Bluetooth management socket connection lost during shutdown")
else:
_LOGGER.debug("Bluetooth management socket connection lost, reconnecting")
_set_future_if_not_done(self._on_connection_lost_future)
self._on_connection_lost_future = None
async def reconnect_task(self) -> None:
"""Monitor the connection and reconnect if needed."""
while not self._shutting_down:
if self._on_connection_lost_future:
await self._on_connection_lost_future
if self._shutting_down:
break # type: ignore[unreachable]
_LOGGER.debug("Reconnecting to Bluetooth management socket")
try:
await self._establish_connection()
except CONNECTION_ERRORS:
_LOGGER.debug("Bluetooth management socket connection timed out")
# If we get a timeout, we should try to reconnect
# after a short delay
await asyncio.sleep(1)
async def _establish_connection(self) -> None:
"""Establish a connection to the Bluetooth management socket."""
_LOGGER.debug("Establishing Bluetooth management socket connection")
self.sock = btmgmt_socket.open()
loop = asyncio.get_running_loop()
connection_made_future: asyncio.Future[None] = loop.create_future()
try:
async with asyncio_timeout(self.timeout):
# _create_connection_transport accessed
# directly to avoid SOCK_STREAM check
# see https://bugs.python.org/issue38285
_, protocol = await loop._create_connection_transport( # type: ignore[attr-defined]
self.sock,
lambda: BluetoothMGMTProtocol(
connection_made_future,
self.scanners,
self._on_connection_lost,
lambda: self._shutting_down,
self.sock,
),
None,
None,
)
await connection_made_future
except TimeoutError:
btmgmt_socket.close(self.sock)
raise
_LOGGER.debug("Bluetooth management socket connection established")
self.protocol = cast(BluetoothMGMTProtocol, protocol)
self._on_connection_lost_future = loop.create_future()
def _has_mgmt_capabilities_from_status(self, status: int) -> bool:
"""
Check if a MGMT command status indicates we have capabilities.
Returns True if we have capabilities, False otherwise.
Status codes:
- 0x00 = Success (we have permissions)
- 0x01 = Unknown Command (might happen if kernel is too old)
- 0x0D = Invalid Parameters
- 0x10 = Not Powered (for some operations)
- 0x11 = Invalid Index (adapter doesn't exist but we have permissions)
- 0x14 = Permission Denied (missing NET_ADMIN/NET_RAW)
"""
if status == 0x14: # Permission denied
_LOGGER.debug(
"MGMT capability check failed with permission denied - "
"missing NET_ADMIN/NET_RAW"
)
return False
if status in (0x00, 0x11): # Success or Invalid Index
_LOGGER.debug("MGMT capability check passed (status: %#x)", status)
return True
# Unknown status - log it and assume no permissions to be safe
_LOGGER.debug(
"MGMT capability check returned unexpected status %#x - "
"assuming missing permissions",
status,
)
return False
async def _check_capabilities(self) -> bool:
"""
Check if we have the necessary capabilities to use MGMT.
Returns True if we have capabilities, False otherwise.
"""
if not self.protocol or not self.protocol.transport:
return False
# Try GET_CONNECTIONS for adapter 0 - this is a read-only command
# that requires NET_ADMIN privileges but doesn't change any state
header = COMMAND_HEADER_PACK(
MGMT_OP_GET_CONNECTIONS, # opcode
0, # controller index 0 (hci0)
0, # no parameters
)
try:
return await self._do_mgmt_op_get_connections(header)
except (TimeoutError, OSError) as ex:
_LOGGER.debug(
"MGMT capability check failed: %s - "
"likely missing NET_ADMIN/NET_RAW",
ex,
)
return False
async def _do_mgmt_op_get_connections(self, header: bytes) -> bool:
"""Send a MGMT_OP_GET_CONNECTIONS command and check capabilities."""
if TYPE_CHECKING:
assert self.protocol is not None
assert self.protocol.transport is not None
async with self.protocol.command_response(
MGMT_OP_GET_CONNECTIONS
) as response_future:
self.protocol._write_to_socket(header)
# Wait for response with timeout
async with asyncio_timeout(5.0):
status, _ = await response_future
return self._has_mgmt_capabilities_from_status(status)
async def setup(self) -> None:
"""Set up management interface."""
await self._establish_connection()
# Check if we actually have the capabilities to use MGMT
if not await self._check_capabilities():
# Mark as shutting down to prevent reconnection attempts
self._shutting_down = True
# Close the connection and raise an error to trigger fallback
if self.protocol and self.protocol.transport:
self.protocol.transport.close()
btmgmt_socket.close(self.sock)
raise PermissionError(
"Missing NET_ADMIN/NET_RAW capabilities for Bluetooth management"
)
self._reconnect_task = asyncio.create_task(self.reconnect_task())
def load_conn_params(
self,
adapter_idx: int,
address: str,
address_type: int,
params: ConnectParams,
) -> bool:
"""
Load connection parameters for a specific device.
Args:
adapter_idx: Adapter index (e.g., 0 for hci0)
address: Device MAC address (e.g., "AA:BB:CC:DD:EE:FF")
address_type: BDADDR_LE_PUBLIC (1) or BDADDR_LE_RANDOM (2)
params: Connection parameters to load (ConnectParams.FAST or
ConnectParams.MEDIUM)
Returns:
True if command was sent successfully
"""
if not self.protocol or not self.protocol.transport:
_LOGGER.error("Cannot load conn params: no connection")
return False
# Parse MAC address
addr_bytes = bytes.fromhex(address.replace(":", ""))
if len(addr_bytes) != 6:
_LOGGER.error("Invalid MAC address: %s", address)
return False
# Build command structure
# struct mgmt_cp_load_conn_param {
# uint16_t param_count;
# struct mgmt_conn_param params[0];
# }
# struct mgmt_conn_param {
# struct mgmt_addr_info addr;
# uint16_t min_interval;
# uint16_t max_interval;
# uint16_t latency;
# uint16_t timeout;
# }
# struct mgmt_addr_info {
# bdaddr_t bdaddr;
# uint8_t type;
# }
# Get the appropriate connection parameters based on the enum
if params is ConnectParams.FAST:
min_interval = FAST_MIN_CONN_INTERVAL
max_interval = FAST_MAX_CONN_INTERVAL
latency = FAST_CONN_LATENCY
timeout = FAST_CONN_TIMEOUT
else: # params is ConnectParams.MEDIUM:
min_interval = MEDIUM_MIN_CONN_INTERVAL
max_interval = MEDIUM_MAX_CONN_INTERVAL
latency = MEDIUM_CONN_LATENCY
timeout = MEDIUM_CONN_TIMEOUT
# Pack the command
cmd_data = CONN_PARAM_PACK(
1, # param_count = 1
addr_bytes[::-1], # bdaddr (reversed for little endian)
address_type, # address type
min_interval, # min_interval
max_interval, # max_interval
latency, # latency
timeout, # timeout
)
# Send the command
try:
header = COMMAND_HEADER_PACK(
MGMT_OP_LOAD_CONN_PARAM, # opcode
adapter_idx, # controller index
len(cmd_data), # parameter length
)
self.protocol._write_to_socket(header + cmd_data)
_LOGGER.debug(
"Loaded conn params for %s: interval=%d-%d, latency=%d, timeout=%d",
address,
min_interval,
max_interval,
latency,
timeout,
)
return True
except Exception:
_LOGGER.exception("Failed to load conn params")
return False
Bluetooth-Devices-habluetooth-21accde/src/habluetooth/const.py 0000664 0000000 0000000 00000005672 15070247161 0024741 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
FAILED_ADAPTER_MAC = "00:00:00:00:00:00"
ADV_RSSI_SWITCH_THRESHOLD: Final = 16
# The switch threshold for the rssi value
# to switch to a different adapter for advertisements
# Note that this does not affect the connection
# selection that uses RSSI_SWITCH_THRESHOLD from
# bleak_retry_connector
# Connection parameter constants (units of 1.25ms for intervals)
# Fast connection parameters for initial connection and service discovery
FAST_MIN_CONN_INTERVAL: Final = 0x06 # 6 * 1.25ms = 7.5ms (BLE minimum)
FAST_MAX_CONN_INTERVAL: Final = 0x06 # 6 * 1.25ms = 7.5ms
FAST_CONN_LATENCY: Final = 0 # No latency for fast response
FAST_CONN_TIMEOUT: Final = 1000 # 1000 * 10ms = 10s
# Medium connection parameters for standard operation
# Balanced for stability with WiFi-based BLE proxies
MEDIUM_MIN_CONN_INTERVAL: Final = 0x07 # 7 * 1.25ms = 8.75ms
MEDIUM_MAX_CONN_INTERVAL: Final = 0x09 # 9 * 1.25ms = 11.25ms
MEDIUM_CONN_LATENCY: Final = 0 # No latency
MEDIUM_CONN_TIMEOUT: Final = 800 # 800 * 10ms = 8s
# Bluetooth address types
BDADDR_BREDR: Final = 0x00
BDADDR_LE_PUBLIC: Final = 0x01
BDADDR_LE_RANDOM: Final = 0x02
class ConnectParams(Enum):
"""Connection parameter presets."""
FAST = "fast"
MEDIUM = "medium"
Bluetooth-Devices-habluetooth-21accde/src/habluetooth/manager.pxd 0000664 0000000 0000000 00000005626 15070247161 0025367 0 ustar 00root root 0000000 0000000 import cython
from .advertisement_tracker cimport AdvertisementTracker
from .base_scanner cimport BaseHaScanner
from .models cimport BluetoothServiceInfoBleak
cdef int NO_RSSI_VALUE
cdef int ADV_RSSI_SWITCH_THRESHOLD
cdef double TRACKER_BUFFERING_WOBBLE_SECONDS
cdef double FALLBACK_MAXIMUM_STALE_ADVERTISEMENT_SECONDS
cdef object FILTER_UUIDS
cdef object AdvertisementData
cdef object BLEDevice
cdef bint TYPE_CHECKING
cdef set APPLE_START_BYTES_WANTED
cdef unsigned char APPLE_IBEACON_START_BYTE
cdef unsigned char APPLE_HOMEKIT_START_BYTE
cdef unsigned char APPLE_HOMEKIT_NOTIFY_START_BYTE
cdef unsigned char APPLE_DEVICE_ID_START_BYTE
cdef unsigned char APPLE_FINDMY_START_BYTE
cdef object APPLE_MFR_ID
@cython.locals(uuids=set)
cdef _dispatch_bleak_callback(
BleakCallback bleak_callback,
object device,
object advertisement_data
)
cdef class BleakCallback:
cdef public object callback
cdef public dict filters
cdef class BluetoothManager:
cdef public object _cancel_unavailable_tracking
cdef public AdvertisementTracker _advertisement_tracker
cdef public dict _fallback_intervals
cdef public dict _intervals
cdef public dict _unavailable_callbacks
cdef public dict _connectable_unavailable_callbacks
cdef public set _bleak_callbacks
cdef public dict _all_history
cdef public dict _connectable_history
cdef public set _non_connectable_scanners
cdef public set _connectable_scanners
cdef public dict _adapters
cdef public dict _sources
cdef public object _bluetooth_adapters
cdef public object slot_manager
cdef public bint _debug
cdef public bint shutdown
cdef public object _loop
cdef public object _adapter_refresh_future
cdef public object _recovery_lock
cdef public set _disappeared_callbacks
cdef public dict _allocations_callbacks
cdef public object _cancel_allocation_callbacks
cdef public dict _adapter_sources
cdef public dict _allocations
cdef public dict _scanner_registration_callbacks
cdef public dict _scanner_mode_change_callbacks
cdef public object _subclass_discover_info
cdef public bint has_advertising_side_channel
cdef public dict _side_channel_scanners
cdef public object _mgmt_ctl
@cython.locals(stale_seconds=double)
cdef bint _prefer_previous_adv_from_different_source(
self,
BluetoothServiceInfoBleak old,
BluetoothServiceInfoBleak new
)
@cython.locals(
old_service_info=BluetoothServiceInfoBleak,
old_connectable_service_info=BluetoothServiceInfoBleak,
source=str,
connectable=bint,
scanner=BaseHaScanner,
connectable_scanner=BaseHaScanner,
apple_cstr="const unsigned char *",
bleak_callback=BleakCallback
)
cpdef void scanner_adv_received(self, BluetoothServiceInfoBleak service_info)
cpdef _async_describe_source(self, BluetoothServiceInfoBleak service_info)
Bluetooth-Devices-habluetooth-21accde/src/habluetooth/manager.py 0000664 0000000 0000000 00000121000 15070247161 0025205 0 ustar 00root root 0000000 0000000 """The bluetooth integration."""
from __future__ import annotations
import asyncio
import itertools
import logging
import platform
from collections.abc import Callable, Iterable
from dataclasses import asdict
from functools import partial
from typing import TYPE_CHECKING, Any, Final
from bleak.backends.scanner import AdvertisementDataCallback
from bleak_retry_connector import (
NO_RSSI_VALUE,
AllocationChangeEvent,
Allocations,
BleakSlotManager,
)
from bluetooth_adapters import (
ADAPTER_ADDRESS,
ADAPTER_PASSIVE_SCAN,
AdapterDetails,
BluetoothAdapters,
get_adapters,
)
from bluetooth_data_tools import monotonic_time_coarse
from .advertisement_tracker import (
TRACKER_BUFFERING_WOBBLE_SECONDS,
AdvertisementTracker,
)
from .channels.bluez import CONNECTION_ERRORS, MGMTBluetoothCtl
from .const import (
ADV_RSSI_SWITCH_THRESHOLD,
CALLBACK_TYPE,
FAILED_ADAPTER_MAC,
FALLBACK_MAXIMUM_STALE_ADVERTISEMENT_SECONDS,
UNAVAILABLE_TRACK_SECONDS,
)
from .models import (
BluetoothServiceInfoBleak,
HaBluetoothSlotAllocations,
HaScannerModeChange,
HaScannerRegistration,
HaScannerRegistrationEvent,
)
from .scanner_device import BluetoothScannerDevice
from .usage import install_multiple_bleak_catcher, uninstall_multiple_bleak_catcher
from .util import async_reset_adapter
if TYPE_CHECKING:
from bleak.backends.device import BLEDevice
from bleak.backends.scanner import AdvertisementData
from .base_scanner import BaseHaScanner
from .scanner import HaScanner
SYSTEM = platform.system()
IS_LINUX = SYSTEM == "Linux"
FILTER_UUIDS: Final = "UUIDs"
APPLE_MFR_ID: Final = 76
APPLE_IBEACON_START_BYTE: Final = 0x02 # iBeacon (tilt_ble)
APPLE_HOMEKIT_START_BYTE: Final = 0x06 # homekit_controller
APPLE_DEVICE_ID_START_BYTE: Final = 0x10 # bluetooth_le_tracker
APPLE_HOMEKIT_NOTIFY_START_BYTE: Final = 0x11 # homekit_controller
APPLE_FINDMY_START_BYTE: Final = 0x12 # FindMy network advertisements
_str = str
_int = int
_LOGGER = logging.getLogger(__name__)
def _dispatch_bleak_callback(
bleak_callback: BleakCallback,
device: BLEDevice,
advertisement_data: AdvertisementData,
) -> None:
"""Dispatch the callback."""
if (
uuids := bleak_callback.filters.get(FILTER_UUIDS)
) is not None and not uuids.intersection(advertisement_data.service_uuids):
return
try:
bleak_callback.callback(device, advertisement_data)
except Exception: # pylint: disable=broad-except
_LOGGER.exception("Error in callback: %s", bleak_callback.callback)
class BleakCallback:
"""Bleak callback."""
__slots__ = ("callback", "filters")
def __init__(
self, callback: AdvertisementDataCallback, filters: dict[str, set[str]]
) -> None:
"""Init bleak callback."""
self.callback = callback
self.filters = filters
class BluetoothManager:
"""Manage Bluetooth."""
__slots__ = (
"_adapter_refresh_future",
"_adapter_sources",
"_adapters",
"_advertisement_tracker",
"_all_history",
"_allocations",
"_allocations_callbacks",
"_bleak_callbacks",
"_bluetooth_adapters",
"_cancel_allocation_callbacks",
"_cancel_unavailable_tracking",
"_connectable_history",
"_connectable_scanners",
"_connectable_unavailable_callbacks",
"_connection_history",
"_debug",
"_disappeared_callbacks",
"_fallback_intervals",
"_intervals",
"_loop",
"_mgmt_ctl",
"_non_connectable_scanners",
"_recovery_lock",
"_scanner_mode_change_callbacks",
"_scanner_registration_callbacks",
"_side_channel_scanners",
"_sources",
"_subclass_discover_info",
"_unavailable_callbacks",
"has_advertising_side_channel",
"shutdown",
"slot_manager",
)
def __init__(
self,
bluetooth_adapters: BluetoothAdapters | None = None,
slot_manager: BleakSlotManager | None = None,
) -> None:
"""Init bluetooth manager."""
self._cancel_unavailable_tracking: asyncio.TimerHandle | None = None
self._advertisement_tracker = AdvertisementTracker()
self._fallback_intervals = self._advertisement_tracker.fallback_intervals
self._intervals = self._advertisement_tracker.intervals
self._unavailable_callbacks: dict[
str, set[Callable[[BluetoothServiceInfoBleak], None]]
] = {}
self._connectable_unavailable_callbacks: dict[
str, set[Callable[[BluetoothServiceInfoBleak], None]]
] = {}
self._bleak_callbacks: set[BleakCallback] = set()
self._all_history: dict[str, BluetoothServiceInfoBleak] = {}
self._connectable_history: dict[str, BluetoothServiceInfoBleak] = {}
self._non_connectable_scanners: set[BaseHaScanner] = set()
self._connectable_scanners: set[BaseHaScanner] = set()
self._adapters: dict[str, AdapterDetails] = {}
self._adapter_sources: dict[str, str] = {}
self._allocations: dict[str, HaBluetoothSlotAllocations] = {}
self._sources: dict[str, BaseHaScanner] = {}
self._bluetooth_adapters = bluetooth_adapters or get_adapters()
self.slot_manager = slot_manager or BleakSlotManager()
self._cancel_allocation_callbacks = (
self.slot_manager.register_allocation_callback(
self._async_slot_manager_changed
)
)
self._debug = _LOGGER.isEnabledFor(logging.DEBUG)
self.shutdown = False
self.has_advertising_side_channel = False
self._side_channel_scanners: dict[int, HaScanner] = {}
self._loop: asyncio.AbstractEventLoop | None = None
self._adapter_refresh_future: asyncio.Future[None] | None = None
self._recovery_lock: asyncio.Lock = asyncio.Lock()
self._disappeared_callbacks: set[Callable[[str], None]] = set()
self._allocations_callbacks: dict[
str | None, set[Callable[[HaBluetoothSlotAllocations], None]]
] = {}
self._scanner_registration_callbacks: dict[
str | None, set[Callable[[HaScannerRegistration], None]]
] = {}
self._scanner_mode_change_callbacks: dict[
str | None, set[Callable[[HaScannerModeChange], None]]
] = {}
self._subclass_discover_info = self._discover_service_info
self._mgmt_ctl: MGMTBluetoothCtl | None = None
if (
self._discover_service_info.__func__ # type: ignore[attr-defined]
is BluetoothManager._discover_service_info
):
_LOGGER.warning(
"%s: does not implement _discover_service_info, "
"subclasses must implement this method to consume "
"discovery data",
type(self).__name__,
)
@property
def supports_passive_scan(self) -> bool:
"""Return if passive scan is supported."""
return any(adapter[ADAPTER_PASSIVE_SCAN] for adapter in self._adapters.values())
def is_operating_degraded(self) -> bool:
"""
Return if the manager is operating in degraded mode.
On Linux, we're in degraded mode if mgmt control is not available.
This typically means we don't have NET_ADMIN/NET_RAW capabilities.
"""
return IS_LINUX and self._mgmt_ctl is None
def on_scanner_start(self, scanner: BaseHaScanner) -> None:
"""
Called when a scanner starts.
Subclasses can override this to perform custom actions when a scanner starts.
"""
def async_scanner_count(self, connectable: bool = True) -> int:
"""Return the number of scanners."""
if connectable:
return len(self._connectable_scanners)
return len(self._connectable_scanners) + len(self._non_connectable_scanners)
async def async_diagnostics(self) -> dict[str, Any]:
"""Diagnostics for the manager."""
scanner_diagnostics = await asyncio.gather(
*[
scanner.async_diagnostics()
for scanner in itertools.chain(
self._non_connectable_scanners, self._connectable_scanners
)
]
)
return {
"adapters": self._adapters,
"slot_manager": self.slot_manager.diagnostics(),
"allocations": {
source: asdict(allocations)
for source, allocations in self._allocations.items()
},
"scanners": scanner_diagnostics,
"connectable_history": [
service_info.as_dict()
for service_info in self._connectable_history.values()
],
"all_history": [
service_info.as_dict() for service_info in self._all_history.values()
],
"advertisement_tracker": self._advertisement_tracker.async_diagnostics(),
}
def _find_adapter_by_address(self, address: str) -> str | None:
for adapter, details in self._adapters.items():
if details[ADAPTER_ADDRESS] == address:
return adapter
return None
def async_scanner_by_source(self, source: str) -> BaseHaScanner | None:
"""Return the scanner for a source."""
return self._sources.get(source)
def async_register_disappeared_callback(
self, callback: Callable[[str], None]
) -> CALLBACK_TYPE:
"""Register a callback to be called when an address disappears."""
self._disappeared_callbacks.add(callback)
return partial(self._disappeared_callbacks.discard, callback)
async def _async_refresh_adapters(self) -> None:
"""Refresh the adapters."""
if self._adapter_refresh_future:
await self._adapter_refresh_future
return
if TYPE_CHECKING:
assert self._loop is not None
self._adapter_refresh_future = self._loop.create_future()
try:
await self._bluetooth_adapters.refresh()
self._adapters = self._bluetooth_adapters.adapters
finally:
self._adapter_refresh_future.set_result(None)
self._adapter_refresh_future = None
def get_cached_bluetooth_adapters(self) -> dict[str, AdapterDetails] | None:
"""Get cached bluetooth adapters synchronously."""
return self._adapters
async def async_get_bluetooth_adapters(
self, cached: bool = True
) -> dict[str, AdapterDetails]:
"""Get bluetooth adapters."""
if not self._adapters or not cached:
if not cached:
await self._async_refresh_adapters()
self._adapters = self._bluetooth_adapters.adapters
return self._adapters
async def async_get_adapter_from_address(self, address: str) -> str | None:
"""Get adapter from address."""
if adapter := self._find_adapter_by_address(address):
return adapter
await self._async_refresh_adapters()
return self._find_adapter_by_address(address)
async def async_get_adapter_from_address_or_recover(
self, address: str
) -> str | None:
"""Get adapter from address or recover."""
if adapter := self._find_adapter_by_address(address):
return adapter
await self._async_recover_failed_adapters()
return self._find_adapter_by_address(address)
async def _async_recover_failed_adapters(self) -> None:
"""Recover failed adapters."""
if self._recovery_lock.locked():
# Already recovering, no need to
# start another recovery
return
async with self._recovery_lock:
adapters = await self.async_get_bluetooth_adapters()
for adapter in [
adapter
for adapter, details in adapters.items()
if details[ADAPTER_ADDRESS] == FAILED_ADAPTER_MAC
]:
await async_reset_adapter(adapter, FAILED_ADAPTER_MAC, False)
await self._async_refresh_adapters()
async def async_setup(self) -> None:
"""Set up the bluetooth manager."""
from .central_manager import CentralBluetoothManager
if CentralBluetoothManager.manager is None:
CentralBluetoothManager.manager = self
self._loop = asyncio.get_running_loop()
await self._async_refresh_adapters()
install_multiple_bleak_catcher()
self.async_setup_unavailable_tracking()
if not IS_LINUX:
return
self._mgmt_ctl = MGMTBluetoothCtl(10.0, self._side_channel_scanners)
try:
await self._mgmt_ctl.setup()
except PermissionError as ex:
_LOGGER.error(
"Missing required permissions for Bluetooth management: %s. "
"Automatic adapter recovery is unavailable. "
"Add NET_ADMIN and NET_RAW capabilities to the container to enable it",
ex,
)
self._mgmt_ctl = None
except CONNECTION_ERRORS as ex:
_LOGGER.debug("Cannot start Bluetooth Management API: %s", ex)
self._mgmt_ctl = None
else:
self.has_advertising_side_channel = True
def async_stop(self) -> None:
"""Stop the Bluetooth integration at shutdown."""
_LOGGER.debug("Stopping bluetooth manager")
self.shutdown = True
if self._cancel_unavailable_tracking:
self._cancel_unavailable_tracking.cancel()
self._cancel_unavailable_tracking = None
uninstall_multiple_bleak_catcher()
self._cancel_allocation_callbacks()
if self._mgmt_ctl:
self._mgmt_ctl.close()
self._mgmt_ctl = None
def async_scanner_devices_by_address(
self, address: str, connectable: bool
) -> list[BluetoothScannerDevice]:
"""Get BluetoothScannerDevice by address."""
if not connectable:
scanners: Iterable[BaseHaScanner] = itertools.chain(
self._connectable_scanners, self._non_connectable_scanners
)
else:
scanners = self._connectable_scanners
return [
BluetoothScannerDevice(scanner, *device_adv)
for scanner in scanners
if (device_adv := scanner.get_discovered_device_advertisement_data(address))
]
def _async_all_discovered_addresses(self, connectable: bool) -> Iterable[str]:
"""
Return all of discovered addresses.
Include addresses from all the scanners including duplicates.
"""
yield from itertools.chain.from_iterable(
scanner.discovered_addresses for scanner in self._connectable_scanners
)
if not connectable:
yield from itertools.chain.from_iterable(
scanner.discovered_addresses
for scanner in self._non_connectable_scanners
)
def async_discovered_devices(self, connectable: bool) -> list[BLEDevice]:
"""Return all of combined best path to discovered from all the scanners."""
histories = self._connectable_history if connectable else self._all_history
return [history.device for history in histories.values()]
def async_setup_unavailable_tracking(self) -> None:
"""Set up the unavailable tracking."""
self._schedule_unavailable_tracking()
def _schedule_unavailable_tracking(self) -> None:
"""Schedule the unavailable tracking."""
if TYPE_CHECKING:
assert self._loop is not None
loop = self._loop
self._cancel_unavailable_tracking = loop.call_at(
loop.time() + UNAVAILABLE_TRACK_SECONDS, self._async_check_unavailable
)
def _async_check_unavailable(self) -> None:
"""Watch for unavailable devices and cleanup state history."""
monotonic_now = monotonic_time_coarse()
connectable_history = self._connectable_history
all_history = self._all_history
tracker = self._advertisement_tracker
intervals = tracker.intervals
for connectable in (True, False):
if connectable:
unavailable_callbacks = self._connectable_unavailable_callbacks
else:
unavailable_callbacks = self._unavailable_callbacks
history = connectable_history if connectable else all_history
disappeared = set(history).difference(
self._async_all_discovered_addresses(connectable)
)
for address in disappeared:
if not connectable:
#
# For non-connectable devices we also check the device has exceeded
# the advertising interval before we mark it as unavailable
# since it may have gone to sleep and since we do not need an active
# connection to it we can only determine its availability
# by the lack of advertisements
if advertising_interval := (
intervals.get(address) or self._fallback_intervals.get(address)
):
advertising_interval += TRACKER_BUFFERING_WOBBLE_SECONDS
else:
advertising_interval = (
FALLBACK_MAXIMUM_STALE_ADVERTISEMENT_SECONDS
)
time_since_seen = monotonic_now - all_history[address].time
if time_since_seen <= advertising_interval:
continue
# The second loop (connectable=False) is responsible for removing
# the device from all the interval tracking since it is no longer
# available for both connectable and non-connectable
tracker.async_remove_fallback_interval(address)
tracker.async_remove_address(address)
for disappear_callback in self._disappeared_callbacks:
try:
disappear_callback(address)
except Exception:
_LOGGER.exception("Error in disappeared callback")
self._address_disappeared(address)
service_info = history.pop(address)
if not (callbacks := unavailable_callbacks.get(address)):
continue
for callback in callbacks.copy():
try:
callback(service_info)
except Exception: # pylint: disable=broad-except
_LOGGER.exception("Error in unavailable callback")
self._schedule_unavailable_tracking()
def _address_disappeared(self, address: str) -> None:
"""
Call when an address disappears from the stack.
This method is intended to be overridden by subclasses.
"""
def _prefer_previous_adv_from_different_source(
self,
old: BluetoothServiceInfoBleak,
new: BluetoothServiceInfoBleak,
) -> bool:
"""Prefer previous advertisement from a different source if it is better."""
if stale_seconds := self._intervals.get(
new.address, self._fallback_intervals.get(new.address, 0)
):
stale_seconds += TRACKER_BUFFERING_WOBBLE_SECONDS
else:
stale_seconds = FALLBACK_MAXIMUM_STALE_ADVERTISEMENT_SECONDS
if new.time - old.time > stale_seconds:
# If the old advertisement is stale, any new advertisement is preferred
if self._debug:
_LOGGER.debug(
"%s (%s): Switching from %s to %s (time elapsed:%s > stale"
" seconds:%s)",
new.name,
new.address,
self._async_describe_source(old),
self._async_describe_source(new),
new.time - old.time,
stale_seconds,
)
return False
if (new.rssi or NO_RSSI_VALUE) - ADV_RSSI_SWITCH_THRESHOLD > (
old.rssi or NO_RSSI_VALUE
):
# If new advertisement is ADV_RSSI_SWITCH_THRESHOLD more,
# the new one is preferred.
if self._debug:
_LOGGER.debug(
"%s (%s): Switching from %s to %s (new rssi:%s - threshold:%s >"
" old rssi:%s)",
new.name,
new.address,
self._async_describe_source(old),
self._async_describe_source(new),
new.rssi,
ADV_RSSI_SWITCH_THRESHOLD,
old.rssi,
)
return False
return True
def get_bluez_mgmt_ctl(self) -> MGMTBluetoothCtl | None:
"""
Get the BlueZ management controller if available.
Returns:
The MGMTBluetoothCtl instance or None if not available
"""
return self._mgmt_ctl
def scanner_adv_received(self, service_info: BluetoothServiceInfoBleak) -> None:
"""
Handle a new advertisement from any scanner.
Callbacks from all the scanners arrive here.
"""
# Pre-filter noisy apple devices as they can account for 20-35% of the
# traffic on a typical network.
if (
not service_info.service_data
and len(service_info.manufacturer_data) == 1
and (apple_data := service_info.manufacturer_data.get(APPLE_MFR_ID))
):
apple_cstr = apple_data
if apple_cstr[0] not in {
APPLE_IBEACON_START_BYTE,
APPLE_HOMEKIT_START_BYTE,
APPLE_HOMEKIT_NOTIFY_START_BYTE,
APPLE_DEVICE_ID_START_BYTE,
APPLE_FINDMY_START_BYTE,
}:
return
if service_info.connectable:
old_connectable_service_info = self._connectable_history.get(
service_info.address
)
else:
old_connectable_service_info = None
# This logic is complex due to the many combinations of scanners
# that are supported.
#
# We need to handle multiple connectable and non-connectable scanners
# and we need to handle the case where a device is connectable on one scanner
# but not on another.
#
# The device may also be connectable only by a scanner that has worse
# signal strength than a non-connectable scanner.
#
# all_history - the history of all advertisements from all scanners with the
# best advertisement from each scanner
# connectable_history - the history of all connectable advertisements from all
# scanners with the best advertisement from each
# connectable scanner
#
if (
(old_service_info := self._all_history.get(service_info.address))
is not None
and service_info.source is not old_service_info.source
and service_info.source != old_service_info.source
and (scanner := self._sources.get(old_service_info.source)) is not None
and scanner.scanning
and self._prefer_previous_adv_from_different_source(
old_service_info, service_info
)
):
# If we are rejecting the new advertisement and the device is connectable
# but not in the connectable history or the connectable source is the same
# as the new source, we need to add it to the connectable history
if service_info.connectable:
if old_connectable_service_info is not None and (
# If its the same as the preferred source, we are done
# as we know we prefer the old advertisement
# from the check above
(old_connectable_service_info is old_service_info)
# If the old connectable source is different from the preferred
# source, we need to check it as well to see if we prefer
# the old connectable advertisement
or (
old_connectable_service_info.source is not service_info.source
and old_connectable_service_info.source != service_info.source
and (
connectable_scanner := self._sources.get(
old_connectable_service_info.source
)
)
is not None
and connectable_scanner.scanning
and self._prefer_previous_adv_from_different_source(
old_connectable_service_info,
service_info,
)
)
):
return
self._connectable_history[service_info.address] = service_info
return
if service_info.connectable:
self._connectable_history[service_info.address] = service_info
self._all_history[service_info.address] = service_info
# Track advertisement intervals to determine when we need to
# switch adapters or mark a device as unavailable
if (
(
last_source := self._advertisement_tracker.sources.get(
service_info.address
)
)
is not None
and last_source is not service_info.source
and last_source != service_info.source
):
# Source changed, remove the old address from the tracker
self._advertisement_tracker.async_remove_address(service_info.address)
if service_info.address not in self._advertisement_tracker.intervals:
self._advertisement_tracker.async_collect(service_info)
# If the advertisement data is the same as the last time we saw it, we
# don't need to do anything else unless its connectable and we are missing
# connectable history for the device so we can make it available again
# after unavailable callbacks.
if (
# Ensure its not a connectable device missing from connectable history
not (service_info.connectable and old_connectable_service_info is None)
# Than check if advertisement data is the same
and old_service_info is not None
# This is a bit complex because we want to skip all the
# PyObject_RichCompare overhead as its can be upwards of
# 65% of the time spent in this method. The common case
# is that its the same object for remote scanners.
and not (
(
service_info.manufacturer_data
is not old_service_info.manufacturer_data
and service_info.manufacturer_data
!= old_service_info.manufacturer_data
)
or (
service_info.service_data is not old_service_info.service_data
and service_info.service_data != old_service_info.service_data
)
or (
service_info.service_uuids is not old_service_info.service_uuids
and service_info.service_uuids != old_service_info.service_uuids
)
or (
service_info.name is not old_service_info.name
and service_info.name != old_service_info.name
)
)
):
return
if not service_info.connectable and old_connectable_service_info is not None:
# Since we have a connectable path and our BleakClient will
# route any connection attempts to the connectable path, we
# mark the service_info as connectable so that the callbacks
# will be called and the device can be discovered.
service_info = service_info._as_connectable()
if (
service_info.connectable or old_connectable_service_info is not None
) and self._bleak_callbacks:
# Bleak callbacks must get a connectable device
advertisement_data = service_info._advertisement_internal()
for bleak_callback in self._bleak_callbacks:
_dispatch_bleak_callback(
bleak_callback, service_info.device, advertisement_data
)
self._subclass_discover_info(service_info)
def _discover_service_info(self, service_info: BluetoothServiceInfoBleak) -> None:
"""
Discover a new service info.
This method is intended to be overridden by subclasses.
"""
def _async_describe_source(self, service_info: BluetoothServiceInfoBleak) -> str:
"""Describe a source."""
if scanner := self._sources.get(service_info.source):
description = scanner.name
else:
description = service_info.source
if service_info.connectable:
description += " [connectable]"
return description
def _async_remove_unavailable_callback_internal(
self,
unavailable_callbacks: dict[
str, set[Callable[[BluetoothServiceInfoBleak], None]]
],
address: str,
callbacks: set[Callable[[BluetoothServiceInfoBleak], None]],
callback: Callable[[BluetoothServiceInfoBleak], None],
) -> None:
"""Remove a callback."""
callbacks.remove(callback)
if not callbacks:
del unavailable_callbacks[address]
def async_track_unavailable(
self,
callback: Callable[[BluetoothServiceInfoBleak], None],
address: str,
connectable: bool,
) -> Callable[[], None]:
"""Register a callback."""
if connectable:
unavailable_callbacks = self._connectable_unavailable_callbacks
else:
unavailable_callbacks = self._unavailable_callbacks
callbacks = unavailable_callbacks.setdefault(address, set())
callbacks.add(callback)
return partial(
self._async_remove_unavailable_callback_internal,
unavailable_callbacks,
address,
callbacks,
callback,
)
def async_ble_device_from_address(
self, address: str, connectable: bool
) -> BLEDevice | None:
"""Return the BLEDevice if present."""
histories = self._connectable_history if connectable else self._all_history
if history := histories.get(address):
return history.device
return None
def async_address_present(self, address: str, connectable: bool) -> bool:
"""Return if the address is present."""
histories = self._connectable_history if connectable else self._all_history
return address in histories
def async_discovered_service_info(
self, connectable: bool
) -> Iterable[BluetoothServiceInfoBleak]:
"""Return all the discovered services info."""
histories = self._connectable_history if connectable else self._all_history
return histories.values()
def async_last_service_info(
self, address: str, connectable: bool
) -> BluetoothServiceInfoBleak | None:
"""Return the last service info for an address."""
histories = self._connectable_history if connectable else self._all_history
return histories.get(address)
def _async_unregister_scanner_internal(
self,
scanners: set[BaseHaScanner],
scanner: BaseHaScanner,
connection_slots: int | None,
) -> None:
"""Unregister a scanner."""
_LOGGER.debug("Unregistering scanner %s", scanner.name)
self._advertisement_tracker.async_remove_source(scanner.source)
scanners.remove(scanner)
scanner._clear_connection_history()
del self._sources[scanner.source]
del self._adapter_sources[scanner.adapter]
self._allocations.pop(scanner.source, None)
if connection_slots:
self.slot_manager.remove_adapter(scanner.adapter)
if (idx := scanner.adapter_idx) is not None:
self._side_channel_scanners.pop(idx, None)
self._async_on_scanner_registration(scanner, HaScannerRegistrationEvent.REMOVED)
def async_register_scanner(
self,
scanner: BaseHaScanner,
connection_slots: int | None = None,
) -> CALLBACK_TYPE:
"""Register a new scanner."""
_LOGGER.debug("Registering scanner %s", scanner.name)
if scanner.connectable:
scanners = self._connectable_scanners
else:
scanners = self._non_connectable_scanners
self._allocations[scanner.source] = HaBluetoothSlotAllocations(
source=scanner.source, slots=0, free=0, allocated=[]
)
scanners.add(scanner)
scanner._clear_connection_history()
self._sources[scanner.source] = scanner
self._adapter_sources[scanner.adapter] = scanner.source
if (idx := scanner.adapter_idx) is not None:
self._side_channel_scanners[idx] = scanner # type: ignore[assignment]
if connection_slots:
self.slot_manager.register_adapter(scanner.adapter, connection_slots)
self.async_on_allocation_changed(
self.slot_manager.get_allocations(scanner.adapter)
)
self._async_on_scanner_registration(scanner, HaScannerRegistrationEvent.ADDED)
return partial(
self._async_unregister_scanner_internal, scanners, scanner, connection_slots
)
def async_register_bleak_callback(
self, callback: AdvertisementDataCallback, filters: dict[str, set[str]]
) -> CALLBACK_TYPE:
"""Register a callback."""
callback_entry = BleakCallback(callback, filters)
self._bleak_callbacks.add(callback_entry)
# Replay the history since otherwise we miss devices
# that were already discovered before the callback was registered
# or we are in passive mode
for history in self._connectable_history.values():
_dispatch_bleak_callback(
callback_entry, history.device, history.advertisement
)
return partial(self._bleak_callbacks.remove, callback_entry)
def async_release_connection_slot(self, device: BLEDevice) -> None:
"""Release a connection slot."""
self.slot_manager.release_slot(device)
def async_allocate_connection_slot(self, device: BLEDevice) -> bool:
"""Allocate a connection slot."""
return self.slot_manager.allocate_slot(device)
def async_get_learned_advertising_interval(self, address: str) -> float | None:
"""Get the learned advertising interval for a MAC address."""
return self._intervals.get(address)
def async_get_fallback_availability_interval(self, address: str) -> float | None:
"""Get the fallback availability timeout for a MAC address."""
return self._fallback_intervals.get(address)
def async_set_fallback_availability_interval(
self, address: str, interval: float
) -> None:
"""Override the fallback availability timeout for a MAC address."""
self._fallback_intervals[address] = interval
def _async_slot_manager_changed(self, event: AllocationChangeEvent) -> None:
"""Handle slot manager changes."""
self.async_on_allocation_changed(
self.slot_manager.get_allocations(event.adapter)
)
def async_on_allocation_changed(self, allocations: Allocations) -> None:
"""Call allocation callbacks."""
source = self._adapter_sources.get(allocations.adapter, allocations.adapter)
ha_slot_allocations = HaBluetoothSlotAllocations(
source=source,
slots=allocations.slots,
free=allocations.free,
allocated=allocations.allocated,
)
self._allocations[source] = ha_slot_allocations
for source_key in (source, None):
if not (
allocation_callbacks := self._allocations_callbacks.get(source_key)
):
continue
for callback_ in allocation_callbacks:
try:
callback_(ha_slot_allocations)
except Exception:
_LOGGER.exception("Error in allocation callback")
def _async_on_scanner_registration(
self, scanner: BaseHaScanner, event: HaScannerRegistrationEvent
) -> None:
"""Call scanner callbacks."""
for source_key in (scanner.source, None):
if not (
scanner_callbacks := self._scanner_registration_callbacks.get(
source_key
)
):
continue
for callback_ in scanner_callbacks:
try:
callback_(HaScannerRegistration(event, scanner))
except Exception:
_LOGGER.exception("Error in scanner callback")
def async_current_allocations(
self, source: str | None = None
) -> list[HaBluetoothSlotAllocations] | None:
"""Return the current allocations."""
if source:
if allocations := self._allocations.get(source):
return [allocations]
return []
return list(self._allocations.values())
def async_register_allocation_callback(
self,
callback: Callable[[HaBluetoothSlotAllocations], None],
source: str | None = None,
) -> CALLBACK_TYPE:
"""Register a callback to be called when an allocations change."""
self._allocations_callbacks.setdefault(source, set()).add(callback)
return partial(self._async_unregister_allocation_callback, callback, source)
def _async_unregister_allocation_callback(
self, callback: Callable[[HaBluetoothSlotAllocations], None], source: str | None
) -> None:
if (callbacks := self._allocations_callbacks.get(source)) is not None:
callbacks.discard(callback)
if not callbacks:
del self._allocations_callbacks[source]
def async_register_scanner_registration_callback(
self, callback: Callable[[HaScannerRegistration], None], source: str | None
) -> CALLBACK_TYPE:
"""Register a callback to be called when a scanner is added or removed."""
self._scanner_registration_callbacks.setdefault(source, set()).add(callback)
return partial(
self._async_unregister_scanner_registration_callback, callback, source
)
def _async_unregister_scanner_registration_callback(
self, callback: Callable[[HaScannerRegistration], None], source: str | None
) -> None:
if (callbacks := self._scanner_registration_callbacks.get(source)) is not None:
callbacks.discard(callback)
if not callbacks:
del self._scanner_registration_callbacks[source]
def async_current_scanners(self) -> list[BaseHaScanner]:
"""Return the current scanners."""
return list(self._sources.values())
def async_register_scanner_mode_change_callback(
self, callback: Callable[[HaScannerModeChange], None], source: str | None
) -> CALLBACK_TYPE:
"""Register a callback to be called when a scanner mode changes."""
self._scanner_mode_change_callbacks.setdefault(source, set()).add(callback)
return partial(
self._async_unregister_scanner_mode_change_callback, callback, source
)
def _async_unregister_scanner_mode_change_callback(
self, callback: Callable[[HaScannerModeChange], None], source: str | None
) -> None:
"""Unregister a scanner mode change callback."""
if (callbacks := self._scanner_mode_change_callbacks.get(source)) is not None:
callbacks.discard(callback)
if not callbacks:
del self._scanner_mode_change_callbacks[source]
def scanner_mode_changed(self, scanner: BaseHaScanner) -> None:
"""Notify callbacks that a scanner's mode has changed."""
mode_change = HaScannerModeChange(
scanner=scanner,
requested_mode=scanner.requested_mode,
current_mode=scanner.current_mode,
)
for source_key in (scanner.source, None):
if not (
mode_callbacks := self._scanner_mode_change_callbacks.get(source_key)
):
continue
for callback_ in mode_callbacks:
try:
callback_(mode_change)
except Exception: # pylint: disable=broad-except
_LOGGER.exception("Error in scanner mode change callback")
Bluetooth-Devices-habluetooth-21accde/src/habluetooth/models.pxd 0000664 0000000 0000000 00000001757 15070247161 0025241 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-21accde/src/habluetooth/models.py 0000664 0000000 0000000 00000024703 15070247161 0025072 0 ustar 00root root 0000000 0000000 """Models for bluetooth."""
from __future__ import annotations
from collections.abc import Callable
from dataclasses import dataclass
from enum import Enum
from typing import TYPE_CHECKING, Any, Final, TypeVar
from bleak import BaseBleakClient
from bleak.backends.device import BLEDevice
from bleak.backends.scanner import AdvertisementData
from bleak_retry_connector import NO_RSSI_VALUE
if TYPE_CHECKING:
from .base_scanner import BaseHaScanner
_BluetoothServiceInfoSelfT = TypeVar(
"_BluetoothServiceInfoSelfT", bound="BluetoothServiceInfo"
)
_BluetoothServiceInfoBleakSelfT = TypeVar(
"_BluetoothServiceInfoBleakSelfT", bound="BluetoothServiceInfoBleak"
)
SOURCE_LOCAL: Final = "local"
TUPLE_NEW: Final = tuple.__new__
_float = float # avoid cython conversion since we always want a pyfloat
_str = str # avoid cython conversion since we always want a pystr
_int = int # avoid cython conversion since we always want a pyint
@dataclass(slots=True, frozen=True)
class HaBluetoothSlotAllocations:
"""Data for how to allocate slots for BLEDevice connections."""
source: str # Adapter MAC
slots: int # Number of slots
free: int # Number of free slots
allocated: list[str] # Addresses of connected devices
class HaScannerRegistrationEvent(Enum):
"""Events for scanner registration."""
ADDED = "added"
REMOVED = "removed"
UPDATED = "updated"
@dataclass(slots=True, frozen=True)
class HaScannerRegistration:
"""Data for a scanner event."""
event: HaScannerRegistrationEvent
scanner: BaseHaScanner
@dataclass(slots=True, frozen=True)
class HaScannerModeChange:
"""Data for a scanner mode change event."""
scanner: BaseHaScanner
requested_mode: BluetoothScanningMode | None
current_mode: BluetoothScanningMode | None
@dataclass(slots=True)
class HaBluetoothConnector:
"""Data for how to connect a BLEDevice from a given scanner."""
client: type[BaseBleakClient]
source: str
can_connect: Callable[[], bool]
@dataclass(slots=True, frozen=True)
class HaScannerDetails:
"""Details for a scanner."""
source: str
connectable: bool
name: str
adapter: str
scanner_type: HaScannerType
class HaScannerType(Enum):
"""The type of scanner."""
USB = "usb"
UART = "uart"
REMOTE = "remote"
UNKNOWN = "unknown"
class BluetoothScanningMode(Enum):
"""The mode of scanning for bluetooth devices."""
PASSIVE = "passive"
ACTIVE = "active"
class BluetoothServiceInfo:
"""Prepared info from bluetooth entries."""
__slots__ = (
"address",
"manufacturer_data",
"name",
"rssi",
"service_data",
"service_uuids",
"source",
)
def __init__(
self,
name: _str, # may be a pyobjc object
address: _str, # may be a pyobjc object
rssi: _int, # may be a pyobjc object
manufacturer_data: dict[_int, bytes],
service_data: dict[_str, bytes],
service_uuids: list[_str],
source: _str,
) -> None:
"""Initialize a bluetooth service info."""
self.name = name
self.address = address
self.rssi = rssi
self.manufacturer_data = manufacturer_data
self.service_data = service_data
self.service_uuids = service_uuids
self.source = source
@classmethod
def from_advertisement(
cls: type[_BluetoothServiceInfoSelfT],
device: BLEDevice,
advertisement_data: AdvertisementData,
source: str,
) -> _BluetoothServiceInfoSelfT:
"""Create a BluetoothServiceInfo from an advertisement."""
return cls(
advertisement_data.local_name or device.name or device.address,
device.address,
advertisement_data.rssi,
advertisement_data.manufacturer_data,
advertisement_data.service_data,
advertisement_data.service_uuids,
source,
)
@property
def manufacturer(self) -> str | None:
"""Convert manufacturer data to a string."""
from bleak.backends._manufacturers import (
MANUFACTURERS, # pylint: disable=import-outside-toplevel
)
for manufacturer in self.manufacturer_data:
if manufacturer in MANUFACTURERS:
name: str = MANUFACTURERS[manufacturer]
return name
return None
@property
def manufacturer_id(self) -> int | None:
"""Get the first manufacturer id."""
for manufacturer in self.manufacturer_data:
return manufacturer
return None
class BluetoothServiceInfoBleak(BluetoothServiceInfo):
"""
BluetoothServiceInfo with bleak data.
Integrations may need BLEDevice and AdvertisementData
to connect to the device without having bleak trigger
another scan to translate the address to the system's
internal details.
"""
__slots__ = ("_advertisement", "connectable", "device", "raw", "time", "tx_power")
def __init__(
self,
name: _str, # may be a pyobjc object
address: _str, # may be a pyobjc object
rssi: _int, # may be a pyobjc object
manufacturer_data: dict[_int, bytes],
service_data: dict[_str, bytes],
service_uuids: list[_str],
source: _str,
device: BLEDevice,
advertisement: AdvertisementData | None,
connectable: bool,
time: _float,
tx_power: _int | None,
raw: bytes | None = None,
) -> None:
self.name = name
self.address = address
self.rssi = rssi
self.manufacturer_data = manufacturer_data
self.service_data = service_data
self.service_uuids = service_uuids
self.source = source
self.device = device
self._advertisement = advertisement
self.connectable = connectable
self.time = time
self.tx_power = tx_power
self.raw = raw
def __repr__(self) -> str:
"""Return the representation of the object."""
return (
f"<{self.__class__.__name__} "
f"name={self.name} "
f"address={self.address} "
f"rssi={self.rssi} "
f"manufacturer_data={self.manufacturer_data} "
f"service_data={self.service_data} "
f"service_uuids={self.service_uuids} "
f"source={self.source} "
f"connectable={self.connectable} "
f"time={self.time} "
f"tx_power={self.tx_power} "
f"raw={self.raw!r}>"
)
def _advertisement_internal(self) -> AdvertisementData:
"""
Get the advertisement data.
Internal method only to be used by this library.
"""
if self._advertisement is None:
self._advertisement = TUPLE_NEW(
AdvertisementData,
(
None if self.name == "" or self.name == self.address else self.name,
self.manufacturer_data,
self.service_data,
self.service_uuids,
NO_RSSI_VALUE if self.tx_power is None else self.tx_power,
self.rssi,
(),
),
)
return self._advertisement
@property
def advertisement(self) -> AdvertisementData:
"""Get the advertisement data."""
return self._advertisement_internal()
def as_dict(self) -> dict[str, Any]:
"""
Return as dict.
The dataclass asdict method is not used because
it will try to deepcopy pyobjc data which will fail.
"""
return {
"name": self.name,
"address": self.address,
"rssi": self.rssi,
"manufacturer_data": self.manufacturer_data,
"service_data": self.service_data,
"service_uuids": self.service_uuids,
"source": self.source,
"advertisement": self.advertisement,
"device": self.device,
"connectable": self.connectable,
"time": self.time,
"tx_power": self.tx_power,
"raw": self.raw,
}
@classmethod
def from_scan(
cls: type[_BluetoothServiceInfoBleakSelfT],
source: str,
device: BLEDevice,
advertisement_data: AdvertisementData,
monotonic_time: _float,
connectable: bool,
) -> _BluetoothServiceInfoBleakSelfT:
"""Create a BluetoothServiceInfoBleak from a scanner."""
return cls(
advertisement_data.local_name or device.name or device.address,
device.address,
advertisement_data.rssi,
advertisement_data.manufacturer_data,
advertisement_data.service_data,
advertisement_data.service_uuids,
source,
device,
advertisement_data,
connectable,
monotonic_time,
advertisement_data.tx_power,
)
@classmethod
def from_device_and_advertisement_data(
cls: type[_BluetoothServiceInfoBleakSelfT],
device: BLEDevice,
advertisement_data: AdvertisementData,
source: str,
time: _float,
connectable: bool,
) -> _BluetoothServiceInfoBleakSelfT:
"""Create a BluetoothServiceInfoBleak from a device and advertisement."""
return cls(
advertisement_data.local_name or device.name or device.address,
device.address,
advertisement_data.rssi,
advertisement_data.manufacturer_data,
advertisement_data.service_data,
advertisement_data.service_uuids,
source,
device,
advertisement_data,
connectable,
time,
advertisement_data.tx_power,
)
def _as_connectable(self) -> BluetoothServiceInfoBleak:
"""Return a connectable version of this object."""
new_obj = BluetoothServiceInfoBleak.__new__(BluetoothServiceInfoBleak)
new_obj.name = self.name
new_obj.address = self.address
new_obj.rssi = self.rssi
new_obj.manufacturer_data = self.manufacturer_data
new_obj.service_data = self.service_data
new_obj.service_uuids = self.service_uuids
new_obj.source = self.source
new_obj.device = self.device
new_obj._advertisement = self._advertisement
new_obj.connectable = True
new_obj.time = self.time
new_obj.tx_power = self.tx_power
new_obj.raw = self.raw
return new_obj
Bluetooth-Devices-habluetooth-21accde/src/habluetooth/py.typed 0000664 0000000 0000000 00000000000 15070247161 0024714 0 ustar 00root root 0000000 0000000 Bluetooth-Devices-habluetooth-21accde/src/habluetooth/scanner.pxd 0000664 0000000 0000000 00000001521 15070247161 0025374 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
@cython.locals(service_info=BluetoothServiceInfoBleak)
cpdef void _async_detection_callback(
self,
object device,
object advertisement_data
)
cpdef void _async_on_raw_bluez_advertisement(
self,
bytes address,
unsigned short address_type,
short rssi,
unsigned int flags,
bytes data,
) except *
Bluetooth-Devices-habluetooth-21accde/src/habluetooth/scanner.py 0000664 0000000 0000000 00000056745 15070247161 0025253 0 ustar 00root root 0000000 0000000 """A local bleak scanner."""
from __future__ import annotations
import asyncio
import logging
import platform
from collections.abc import Coroutine, Iterable
from functools import lru_cache
from typing import Any, no_type_check
import async_interrupt
import bleak
from bleak import BleakError
from bleak.assigned_numbers import AdvertisementDataType
from bleak.backends.device import BLEDevice
from bleak.backends.scanner import AdvertisementData, AdvertisementDataCallback
from bleak_retry_connector import Allocations, restore_discoveries
from bleak_retry_connector.bluez import stop_discovery
from bluetooth_adapters import DEFAULT_ADDRESS
from bluetooth_data_tools import monotonic_time_coarse
from .base_scanner import BaseHaScanner
from .const import (
CALLBACK_TYPE,
SCANNER_WATCHDOG_INTERVAL,
SCANNER_WATCHDOG_TIMEOUT,
SOURCE_LOCAL,
START_TIMEOUT,
STOP_TIMEOUT,
)
from .models import BluetoothScanningMode, BluetoothServiceInfoBleak
from .util import async_reset_adapter, is_docker_env
int_ = int
SYSTEM = platform.system()
IS_LINUX = SYSTEM == "Linux"
IS_MACOS = SYSTEM == "Darwin"
if IS_LINUX:
from bleak.args.bluez import BlueZScannerArgs, OrPattern
from bleak.backends.bluezdbus.advertisement_monitor import (
AdvertisementMonitor,
)
from dbus_fast import InvalidMessageError
from dbus_fast.service import method
# or_patterns is a workaround for the fact that passive scanning
# needs at least one matcher to be set. The below matcher
# will match all devices.
PASSIVE_SCANNER_ARGS = BlueZScannerArgs(
or_patterns=[
OrPattern(0, AdvertisementDataType.FLAGS, b"\x02"),
OrPattern(0, AdvertisementDataType.FLAGS, b"\x06"),
OrPattern(0, AdvertisementDataType.FLAGS, b"\x1a"),
]
)
class HaAdvertisementMonitor(AdvertisementMonitor):
"""Implementation of the org.bluez.AdvertisementMonitor1 D-Bus interface."""
@method()
@no_type_check
def DeviceFound(self, device: o): # noqa: F821
"""Device found."""
@method()
@no_type_check
def DeviceLost(self, device: o): # noqa: F821
"""Device lost."""
AdvertisementMonitor.DeviceFound = HaAdvertisementMonitor.DeviceFound
AdvertisementMonitor.DeviceLost = HaAdvertisementMonitor.DeviceLost
else:
class InvalidMessageError(Exception): # type: ignore[no-redef]
"""Invalid message error."""
OriginalBleakScanner = bleak.BleakScanner
_LOGGER = logging.getLogger(__name__)
IN_PROGRESS_ERROR = "org.bluez.Error.InProgress"
# If the adapter is in a stuck state the following errors are raised:
NEED_RESET_ERRORS = [
"org.bluez.Error.Failed",
IN_PROGRESS_ERROR,
"org.bluez.Error.NotReady",
"not found",
]
# When the adapter is still initializing, the scanner will raise an exception
# with org.freedesktop.DBus.Error.UnknownObject
WAIT_FOR_ADAPTER_TO_INIT_ERRORS = ["org.freedesktop.DBus.Error.UnknownObject"]
ADAPTER_INIT_TIME = 1.5
START_ATTEMPTS = 4
SCANNING_MODE_TO_BLEAK = {
BluetoothScanningMode.ACTIVE: "active",
BluetoothScanningMode.PASSIVE: "passive",
}
# The minimum number of seconds to know
# the adapter has not had advertisements
# and we already tried to restart the scanner
# without success when the first time the watch
# dog hit the failure path.
SCANNER_WATCHDOG_MULTIPLE = (
SCANNER_WATCHDOG_TIMEOUT + SCANNER_WATCHDOG_INTERVAL.total_seconds()
)
class _AbortStartError(Exception):
"""Error to indicate that the start should be aborted."""
class ScannerStartError(Exception):
"""Error to indicate that the scanner failed to start."""
def create_bleak_scanner(
detection_callback: AdvertisementDataCallback | None,
scanning_mode: BluetoothScanningMode,
adapter: str | None,
) -> bleak.BleakScanner:
"""Create a Bleak scanner."""
scanner_kwargs: dict[str, Any] = {
"scanning_mode": SCANNING_MODE_TO_BLEAK[scanning_mode],
}
if detection_callback:
scanner_kwargs["detection_callback"] = detection_callback
if IS_LINUX:
# Only Linux supports multiple adapters
if adapter:
scanner_kwargs["adapter"] = adapter
if scanning_mode == BluetoothScanningMode.PASSIVE:
scanner_kwargs["bluez"] = PASSIVE_SCANNER_ARGS
elif IS_MACOS:
# We want mac address on macOS
scanner_kwargs["cb"] = {"use_bdaddr": True}
_LOGGER.debug("Initializing bluetooth scanner with %s", scanner_kwargs)
try:
return OriginalBleakScanner(**scanner_kwargs)
except (FileNotFoundError, BleakError) as ex:
raise RuntimeError(f"Failed to initialize Bluetooth: {ex}") from ex
def _error_indicates_reset_needed(error_str: str) -> bool:
"""Return if the error indicates a reset is needed."""
return any(
needs_reset_error in error_str for needs_reset_error in NEED_RESET_ERRORS
)
def _error_indicates_wait_for_adapter_to_init(error_str: str) -> bool:
"""Return if the error indicates the adapter is still initializing."""
return any(
wait_error in error_str for wait_error in WAIT_FOR_ADAPTER_TO_INIT_ERRORS
)
@lru_cache(maxsize=512)
def bytes_mac_to_str(mac: bytes) -> str:
"""Convert a MAC address in bytes to a string in big-endian (MSB-first) order."""
return ":".join(f"{b:02X}" for b in reversed(mac))
@lru_cache(maxsize=512)
def make_bluez_details(address: str, adapter: str) -> dict[str, Any]:
"""Make the details for a bluez advertisement."""
base_path = f"/org/bluez/{adapter}"
return {
"path": f"{base_path}/dev_{address.replace(':', '_')}",
"props": {
"Adapter": base_path,
},
}
class HaScanner(BaseHaScanner):
"""
Operate and automatically recover a BleakScanner.
Multiple BleakScanner can be used at the same time
if there are multiple adapters. This is only useful
if the adapters are not located physically next to each other.
Example use cases are usbip, a long extension cable, usb to bluetooth
over ethernet, usb over ethernet, etc.
"""
__slots__ = (
"_background_tasks",
"_start_future",
"_start_stop_lock",
"mac_address",
"scanner",
)
def __init__(
self,
mode: BluetoothScanningMode,
adapter: str,
address: str,
) -> None:
"""Init bluetooth discovery."""
self.mac_address = address
source = address if address != DEFAULT_ADDRESS else adapter or SOURCE_LOCAL
super().__init__(source, adapter, requested_mode=mode)
self.connectable = True
self._start_stop_lock = asyncio.Lock()
self.scanning = False
self._background_tasks: set[asyncio.Task[Any]] = set()
self.scanner: bleak.BleakScanner | None = None
self._start_future: asyncio.Future[None] | None = None
def _create_background_task(self, coro: Coroutine[Any, Any, None]) -> None:
"""Create a background task and add it to the background tasks set."""
task = asyncio.create_task(coro)
self._background_tasks.add(task)
task.add_done_callback(self._background_tasks.discard)
@property
def discovered_devices(self) -> list[BLEDevice]:
"""Return a list of discovered devices."""
if not self.scanner:
return []
return self.scanner.discovered_devices
@property
def discovered_devices_and_advertisement_data(
self,
) -> dict[str, tuple[BLEDevice, AdvertisementData]]:
"""Return a list of discovered devices and advertisement data."""
if not self.scanner:
return {}
return self.scanner.discovered_devices_and_advertisement_data
@property
def discovered_addresses(self) -> Iterable[str]:
"""Return an iterable of discovered devices."""
return self.discovered_devices_and_advertisement_data
def get_discovered_device_advertisement_data(
self, address: str
) -> tuple[BLEDevice, AdvertisementData] | None:
"""Return the advertisement data for a discovered device."""
return self.discovered_devices_and_advertisement_data.get(address)
def get_allocations(self) -> Allocations | None:
"""Get current connection slot allocations from BleakSlotManager."""
if self._manager and self._manager.slot_manager:
return self._manager.slot_manager.get_allocations(self.adapter)
return None
def async_setup(self) -> CALLBACK_TYPE:
"""Set up the scanner."""
super().async_setup()
return self._unsetup
async def async_diagnostics(self) -> dict[str, Any]:
"""Return diagnostic information about the scanner."""
base_diag = await super().async_diagnostics()
return base_diag | {"adapter": self.adapter}
def _async_on_raw_bluez_advertisement(
self,
address: bytes,
address_type: int_,
rssi: int_,
flags: int_,
data: bytes,
) -> None:
"""Handle raw advertisement data."""
address_str = bytes_mac_to_str(address)
self._async_on_raw_advertisement(
address_str,
rssi,
data,
make_bluez_details(address_str, self.adapter),
monotonic_time_coarse(),
)
def _async_detection_callback(
self,
device: BLEDevice,
advertisement_data: AdvertisementData,
) -> None:
"""
Call the callback when an advertisement is received.
Currently this is used to feed the callbacks into the
central manager.
"""
callback_time = monotonic_time_coarse()
address = device.address
local_name = advertisement_data.local_name
manufacturer_data = advertisement_data.manufacturer_data
service_data = advertisement_data.service_data
service_uuids = advertisement_data.service_uuids
if local_name or manufacturer_data or service_data or service_uuids:
# Don't count empty advertisements
# as the adapter is in a failure
# state if all the data is empty.
self._last_detection = callback_time
name = local_name or device.name or address
if name is not None and type(name) is not str:
name = str(name)
tx_power = advertisement_data.tx_power
if tx_power is not None and type(tx_power) is not int:
tx_power = int(tx_power)
service_info = BluetoothServiceInfoBleak.__new__(BluetoothServiceInfoBleak)
service_info.name = name
service_info.address = address
service_info.rssi = advertisement_data.rssi
service_info.manufacturer_data = manufacturer_data
service_info.service_data = service_data
service_info.service_uuids = service_uuids
service_info.source = self.source
service_info.device = device
service_info._advertisement = advertisement_data
service_info.connectable = True
service_info.time = callback_time
service_info.tx_power = tx_power
service_info.raw = None # not available in bleak.
self._manager.scanner_adv_received(service_info)
async def async_start(self) -> None:
"""Start bluetooth scanner."""
async with self._start_stop_lock:
await self._async_start()
async def _async_start(self) -> None:
"""Start bluetooth scanner under the lock."""
for attempt in range(1, START_ATTEMPTS + 1):
if await self._async_start_attempt(attempt):
# Everything is fine, break out of the loop
break
await self._async_on_successful_start()
async def _async_on_successful_start(self) -> None:
"""Run when the scanner has successfully started."""
self.scanning = True
self._async_setup_scanner_watchdog()
await restore_discoveries(self.scanner, self.adapter)
async def _async_start_attempt(self, attempt: int) -> bool:
"""Start the scanner and handle errors."""
assert ( # noqa: S101
self._loop is not None
), "Loop is not set, call async_setup first"
self.set_current_mode(self.requested_mode)
# 1st attempt - no auto reset
# 2nd attempt - try to reset the adapter and wait a bit
# 3th attempt - no auto reset
# 4th attempt - fallback to passive if available
if (
IS_LINUX
and attempt == START_ATTEMPTS
and self.requested_mode is BluetoothScanningMode.ACTIVE
):
_LOGGER.debug(
"%s: Falling back to passive scanning mode "
"after active scanning failed (%s/%s)",
self.name,
attempt,
START_ATTEMPTS,
)
self.set_current_mode(BluetoothScanningMode.PASSIVE)
assert self.current_mode is not None # noqa: S101
self.scanner = create_bleak_scanner(
(
None
if self._manager.has_advertising_side_channel
else self._async_detection_callback
),
self.current_mode,
self.adapter,
)
# If the scanner is already running, trying to start it again
# can result in a deadlock. So we need to stop it first.
# hci0: Opcode 0x200b failed: -110
# hci0: start background scanning failed: -110
# hci0: Controller not accepting commands anymore: ncmd = 0
# hci0: Injecting HCI hardware error event
# hci0: hardware error 0x00
await self._async_force_stop_discovery()
self._log_start_attempt(attempt)
self._start_future = self._loop.create_future()
try:
async with (
asyncio.timeout(START_TIMEOUT),
async_interrupt.interrupt(self._start_future, _AbortStartError, None),
):
await self.scanner.start()
except _AbortStartError as ex:
await self._async_stop_scanner()
self._raise_for_abort_start(ex)
except InvalidMessageError as ex:
await self._async_stop_scanner()
self._raise_for_invalid_dbus_message(ex)
except BrokenPipeError as ex:
await self._async_stop_scanner()
self._raise_for_broken_pipe_error(ex)
except FileNotFoundError as ex:
await self._async_stop_scanner()
self._raise_for_file_not_found_error(ex)
except TimeoutError as ex:
await self._async_stop_scanner()
if attempt == 2:
await self._async_reset_adapter(False)
if attempt < START_ATTEMPTS:
self._log_start_timeout(attempt)
return False
raise ScannerStartError(
f"{self.name}: Timed out starting Bluetooth after"
f" {START_TIMEOUT} seconds; "
"Try power cycling the Bluetooth hardware."
) from ex
except BleakError as ex:
await self._async_stop_scanner()
error_str = str(ex)
if IN_PROGRESS_ERROR in error_str:
# If discovery is stuck on, try to force stop it
await self._async_force_stop_discovery()
if attempt == 2 and _error_indicates_reset_needed(error_str):
await self._async_reset_adapter(False)
elif (
attempt != START_ATTEMPTS
and _error_indicates_wait_for_adapter_to_init(error_str)
):
# If we are not out of retry attempts, and the
# adapter is still initializing, wait a bit and try again.
self._log_adapter_init_wait(attempt)
await asyncio.sleep(ADAPTER_INIT_TIME)
if attempt < START_ATTEMPTS:
self._log_start_failed(ex, attempt)
return False
raise ScannerStartError(
f"{self.name}: Failed to start Bluetooth: {ex}; "
"Try power cycling the Bluetooth hardware."
) from ex
except BaseException:
await self._async_stop_scanner()
raise
finally:
self._start_future = None
self._log_start_success(attempt)
self._on_start_success()
return True
def _log_adapter_init_wait(self, attempt: int) -> None:
_LOGGER.debug(
"%s: Waiting for adapter to initialize; attempt (%s/%s)",
self.name,
attempt,
START_ATTEMPTS,
)
def _log_start_success(self, attempt: int) -> None:
if self.current_mode is not self.requested_mode:
_LOGGER.warning(
"%s: Successful fall-back to passive scanning mode "
"after active scanning failed (%s/%s)",
self.name,
attempt,
START_ATTEMPTS,
)
_LOGGER.debug(
"%s: Success while starting bluetooth; attempt: (%s/%s)",
self.name,
attempt,
START_ATTEMPTS,
)
def _log_start_timeout(self, attempt: int) -> None:
_LOGGER.debug(
"%s: TimeoutError while starting bluetooth; attempt: (%s/%s)",
self.name,
attempt,
START_ATTEMPTS,
)
def _log_start_failed(self, ex: BleakError, attempt: int) -> None:
_LOGGER.debug(
"%s: BleakError while starting bluetooth; attempt: (%s/%s): %s",
self.name,
attempt,
START_ATTEMPTS,
ex,
exc_info=True,
)
def _log_start_attempt(self, attempt: int) -> None:
_LOGGER.debug(
"%s: Starting bluetooth discovery attempt: (%s/%s)",
self.name,
attempt,
START_ATTEMPTS,
)
def _raise_for_abort_start(self, ex: _AbortStartError) -> None:
_LOGGER.debug(
"%s: Starting bluetooth scanner aborted: %s",
self.name,
ex,
exc_info=True,
)
msg = f"{self.name}: Starting bluetooth scanner aborted"
raise ScannerStartError(msg) from ex
def _raise_for_file_not_found_error(self, ex: FileNotFoundError) -> None:
_LOGGER.debug(
"%s: FileNotFoundError while starting bluetooth: %s",
self.name,
ex,
exc_info=True,
)
if is_docker_env():
raise ScannerStartError(
f"{self.name}: DBus service not found; docker config may "
"be missing `-v /run/dbus:/run/dbus:ro`: {ex}"
) from ex
raise ScannerStartError(
f"{self.name}: DBus service not found; make sure the DBus socket "
f"is available: {ex}"
) from ex
def _raise_for_broken_pipe_error(self, ex: BrokenPipeError) -> None:
"""Raise a ScannerStartError for a BrokenPipeError."""
_LOGGER.debug("%s: DBus connection broken: %s", self.name, ex, exc_info=True)
if is_docker_env():
msg = (
f"{self.name}: DBus connection broken: {ex}; try restarting "
"`bluetooth`, `dbus`, and finally the docker container"
)
else:
msg = (
f"{self.name}: DBus connection broken: {ex}; try restarting "
"`bluetooth` and `dbus`"
)
raise ScannerStartError(msg) from ex
def _raise_for_invalid_dbus_message(self, ex: InvalidMessageError) -> None:
"""Raise a ScannerStartError for an InvalidMessageError."""
_LOGGER.debug(
"%s: Invalid DBus message received: %s",
self.name,
ex,
exc_info=True,
)
msg = (
f"{self.name}: Invalid DBus message received: {ex}; "
"try restarting `dbus`"
)
raise ScannerStartError(msg) from ex
def _async_scanner_watchdog(self) -> None:
"""Check if the scanner is running."""
if not self._async_watchdog_triggered():
return
if self._start_stop_lock.locked():
_LOGGER.debug(
"%s: Scanner is already restarting, deferring restart",
self.name,
)
return
_LOGGER.debug(
"%s: Bluetooth scanner has gone quiet for %ss, restarting",
self.name,
self.time_since_last_detection(),
)
# Immediately mark the scanner as not scanning
# since the restart task will have to wait for the lock
self.scanning = False
self._create_background_task(self._async_restart_scanner())
async def _async_restart_scanner(self) -> None:
"""Restart the scanner."""
async with self._start_stop_lock:
# Stop the scanner but not the watchdog
# since we want to try again later if it's still quiet
await self._async_stop_scanner()
# If there have not been any valid advertisements,
# or the watchdog has hit the failure path multiple times,
# do the reset.
if (
self._start_time == self._last_detection
or self.time_since_last_detection() > SCANNER_WATCHDOG_MULTIPLE
):
await self._async_reset_adapter(True)
try:
await self._async_start()
except ScannerStartError as ex:
_LOGGER.exception(
"%s: Failed to restart Bluetooth scanner: %s",
self.name,
ex,
)
async def _async_reset_adapter(self, gone_silent: bool) -> None:
"""Reset the adapter."""
# There is currently nothing the user can do to fix this
# so we log at debug level. If we later come up with a repair
# strategy, we will change this to raise a repair issue as well.
_LOGGER.debug("%s: adapter stopped responding; executing reset", self.name)
result = await async_reset_adapter(self.adapter, self.mac_address, gone_silent)
_LOGGER.debug("%s: adapter reset result: %s", self.name, result)
async def async_stop(self) -> None:
"""Stop bluetooth scanner."""
if self._start_future is not None and not self._start_future.done():
self._start_future.set_exception(_AbortStartError())
async with self._start_stop_lock:
self._async_stop_scanner_watchdog()
await self._async_stop_scanner()
async def _async_stop_scanner(self) -> None:
"""Stop bluetooth discovery under the lock."""
self.scanning = False
if self.scanner is None:
_LOGGER.debug("%s: Scanner is already stopped", self.name)
return
_LOGGER.debug("%s: Stopping bluetooth discovery", self.name)
try:
async with asyncio.timeout(STOP_TIMEOUT):
await self.scanner.stop()
except (TimeoutError, BleakError) as ex:
# This is not fatal, and they may want to reload
# the config entry to restart the scanner if they
# change the bluetooth dongle.
_LOGGER.error("%s: Error stopping scanner: %s", self.name, ex)
self.scanner = None
async def _async_force_stop_discovery(self) -> None:
"""Force stop discovery."""
_LOGGER.debug("%s: Force stopping bluetooth discovery", self.name)
try:
async with asyncio.timeout(STOP_TIMEOUT):
await stop_discovery(self.adapter)
except TimeoutError as ex:
_LOGGER.error("%s: Timeout force stopping scanner: %s", self.name, ex)
except Exception as ex:
_LOGGER.error("%s: Failed to force stop scanner: %s", self.name, ex)
Bluetooth-Devices-habluetooth-21accde/src/habluetooth/scanner_device.py 0000664 0000000 0000000 00000001334 15070247161 0026552 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
from bleak.backends.device import BLEDevice
from bleak.backends.scanner import AdvertisementData
if TYPE_CHECKING:
from .base_scanner import BaseHaScanner
@dataclass(slots=True)
class BluetoothScannerDevice:
"""Data for a bluetooth device from a given scanner."""
scanner: BaseHaScanner
ble_device: BLEDevice
advertisement: AdvertisementData
def score_connection_path(self, rssi_diff: int) -> float:
"""Return a score for the connection path to this device."""
return self.scanner._score_connection_paths(rssi_diff, self)
Bluetooth-Devices-habluetooth-21accde/src/habluetooth/storage.py 0000664 0000000 0000000 00000025024 15070247161 0025250 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 Exception as err: # pylint: disable=broad-except
_LOGGER.exception(
"Error deserializing discovered_device_advertisement_data"
", adapter startup will be slow: %s",
err,
)
return None
def discovered_device_advertisement_data_to_dict(
data: DiscoveredDeviceAdvertisementData,
) -> DiscoveredDeviceAdvertisementDataDict:
"""Build discovered_device_advertisement_data dict."""
return DiscoveredDeviceAdvertisementDataDict(
connectable=data.connectable,
expire_seconds=data.expire_seconds,
discovered_device_advertisement_datas=_serialize_discovered_device_advertisement_datas(
data.discovered_device_advertisement_datas
),
discovered_device_timestamps=_serialize_discovered_device_timestamps(
data.discovered_device_timestamps
),
discovered_device_raw=_serialize_discovered_device_raw(
data.discovered_device_raw
),
)
def _serialize_discovered_device_advertisement_datas(
discovered_device_advertisement_datas: dict[
str, tuple[BLEDevice, AdvertisementData]
],
) -> dict[str, DiscoveredDeviceDict]:
"""Serialize discovered_device_advertisement_datas."""
return {
address: DiscoveredDeviceDict(
device=_ble_device_to_dict(device, advertisement_data),
advertisement_data=_advertisement_data_to_dict(advertisement_data),
)
for (
address,
(device, advertisement_data),
) in discovered_device_advertisement_datas.items()
}
def _deserialize_discovered_device_advertisement_datas(
discovered_device_advertisement_datas: dict[str, DiscoveredDeviceDict],
) -> dict[str, tuple[BLEDevice, AdvertisementData]]:
"""Deserialize discovered_device_advertisement_datas."""
return {
address: (
_ble_device_from_dict(device_advertisement_data["device"]),
_advertisement_data_from_dict(
device_advertisement_data["advertisement_data"]
),
)
for (
address,
device_advertisement_data,
) in discovered_device_advertisement_datas.items()
}
def _ble_device_from_dict(device_dict: BLEDeviceDict) -> BLEDevice:
"""Deserialize BLEDevice from dict, handling backward compatibility."""
# Remove rssi from dict as BLEDevice no longer accepts it in bleak 1.x
device_data = device_dict.copy()
device_data.pop("rssi", None) # type: ignore[misc] # Remove rssi if present (backward compatibility)
return BLEDevice(**device_data)
def _ble_device_to_dict(
ble_device: BLEDevice, advertisement_data: AdvertisementData
) -> BLEDeviceDict:
"""Serialize ble_device."""
return BLEDeviceDict(
address=ble_device.address,
name=ble_device.name,
rssi=advertisement_data.rssi, # For backward compatibility
details=ble_device.details,
)
def _advertisement_data_from_dict(
advertisement_data: AdvertisementDataDict,
) -> AdvertisementData:
"""Deserialize advertisement_data."""
return AdvertisementData(
local_name=advertisement_data[LOCAL_NAME],
manufacturer_data={
int(manufacturer_id): bytes.fromhex(manufacturer_data)
for manufacturer_id, manufacturer_data in advertisement_data[
MANUFACTURER_DATA
].items()
},
service_data={
service_uuid: bytes.fromhex(service_data)
for service_uuid, service_data in advertisement_data[SERVICE_DATA].items()
},
service_uuids=advertisement_data[SERVICE_UUIDS],
rssi=advertisement_data[RSSI],
tx_power=advertisement_data[TX_POWER],
platform_data=tuple(advertisement_data[PLATFORM_DATA]),
)
def _advertisement_data_to_dict(
advertisement_data: AdvertisementData,
) -> AdvertisementDataDict:
"""Serialize advertisement_data."""
return AdvertisementDataDict(
local_name=advertisement_data.local_name,
manufacturer_data={
str(manufacturer_id): manufacturer_data.hex()
for manufacturer_id, manufacturer_data in advertisement_data.manufacturer_data.items() # noqa: E501
},
service_data={
service_uuid: service_data.hex()
for service_uuid, service_data in advertisement_data.service_data.items()
},
service_uuids=advertisement_data.service_uuids,
rssi=advertisement_data.rssi,
tx_power=advertisement_data.tx_power,
platform_data=list(advertisement_data.platform_data),
)
def _get_monotonic_time_diff() -> float:
"""Get monotonic time diff."""
return time.time() - time.monotonic()
def _deserialize_discovered_device_timestamps(
discovered_device_timestamps: dict[str, float],
) -> dict[str, float]:
"""Deserialize discovered_device_timestamps."""
time_diff = _get_monotonic_time_diff()
return {
address: unix_time - time_diff
for address, unix_time in discovered_device_timestamps.items()
}
def _serialize_discovered_device_timestamps(
discovered_device_timestamps: dict[str, float],
) -> dict[str, float]:
"""Serialize discovered_device_timestamps."""
time_diff = _get_monotonic_time_diff()
return {
address: monotonic_time + time_diff
for address, monotonic_time in discovered_device_timestamps.items()
}
def _deserialize_discovered_device_raw(
discovered_device_raw: dict[str, str | None],
) -> dict[str, bytes | None]:
"""Deserialize discovered_device_timestamps."""
return {
address: None if raw is None else bytes.fromhex(raw)
for address, raw in discovered_device_raw.items()
}
def _serialize_discovered_device_raw(
discovered_device_raw: dict[str, bytes | None],
) -> dict[str, str | None]:
"""Serialize discovered_device_timestamps."""
return {
address: None if raw is None else raw.hex()
for address, raw in discovered_device_raw.items()
}
DiscoveryStorageType = dict[str, DiscoveredDeviceAdvertisementDataDict]
Bluetooth-Devices-habluetooth-21accde/src/habluetooth/usage.py 0000664 0000000 0000000 00000003305 15070247161 0024706 0 ustar 00root root 0000000 0000000 """bluetooth usage utility to handle multiple instances."""
from __future__ import annotations
import bleak
import bleak_retry_connector
from bleak.backends.service import BleakGATTServiceCollection
from .wrappers import HaBleakClientWrapper, HaBleakScannerWrapper
ORIGINAL_BLEAK_SCANNER = bleak.BleakScanner
ORIGINAL_BLEAK_CLIENT = bleak.BleakClient
ORIGINAL_BLEAK_RETRY_CONNECTOR_CLIENT_WITH_SERVICE_CACHE = (
bleak_retry_connector.BleakClientWithServiceCache
)
ORIGINAL_BLEAK_RETRY_CONNECTOR_CLIENT = bleak_retry_connector.BleakClient
def install_multiple_bleak_catcher() -> None:
"""
Wrap the bleak classes to return the shared instance.
In case multiple instances are detected.
"""
bleak.BleakScanner = HaBleakScannerWrapper
bleak.BleakClient = HaBleakClientWrapper
bleak_retry_connector.BleakClientWithServiceCache = HaBleakClientWithServiceCache
bleak_retry_connector.BleakClient = HaBleakClientWrapper
def uninstall_multiple_bleak_catcher() -> None:
"""Unwrap the bleak classes."""
bleak.BleakScanner = ORIGINAL_BLEAK_SCANNER
bleak.BleakClient = ORIGINAL_BLEAK_CLIENT
bleak_retry_connector.BleakClientWithServiceCache = (
ORIGINAL_BLEAK_RETRY_CONNECTOR_CLIENT_WITH_SERVICE_CACHE
)
bleak_retry_connector.BleakClient = ORIGINAL_BLEAK_RETRY_CONNECTOR_CLIENT
class HaBleakClientWithServiceCache(HaBleakClientWrapper):
"""A BleakClient that implements service caching."""
def set_cached_services(self, services: BleakGATTServiceCollection | None) -> None:
"""
Set the cached services.
No longer used since bleak 0.17+ has service caching built-in.
This was only kept for backwards compatibility.
"""
Bluetooth-Devices-habluetooth-21accde/src/habluetooth/util.py 0000664 0000000 0000000 00000001106 15070247161 0024554 0 ustar 00root root 0000000 0000000 """The bluetooth utilities."""
from functools import cache
from pathlib import Path
from bluetooth_auto_recovery import recover_adapter
async def async_reset_adapter(
adapter: str | None, mac_address: str, gone_silent: bool
) -> bool | None:
"""Reset the adapter."""
if adapter and adapter.startswith("hci"):
adapter_id = int(adapter[3:])
return await recover_adapter(adapter_id, mac_address, gone_silent)
return False
@cache
def is_docker_env() -> bool:
"""Return True if we run in a docker env."""
return Path("/.dockerenv").exists()
Bluetooth-Devices-habluetooth-21accde/src/habluetooth/wrappers.py 0000664 0000000 0000000 00000044452 15070247161 0025455 0 ustar 00root root 0000000 0000000 """Bleak wrappers for bluetooth."""
from __future__ import annotations
import asyncio
import contextlib
import inspect
import logging
from collections.abc import Callable
from dataclasses import dataclass
from functools import partial
from typing import TYPE_CHECKING, Any, Final, Literal, overload
from bleak import BleakClient, BleakError, normalize_uuid_str
from bleak.backends.client import BaseBleakClient, get_platform_client_backend_type
from bleak.backends.device import BLEDevice
from bleak.backends.scanner import (
AdvertisementData,
AdvertisementDataCallback,
BaseBleakScanner,
)
from bleak_retry_connector import (
ble_device_description,
clear_cache,
device_source,
)
from .central_manager import get_manager
from .const import BDADDR_LE_PUBLIC, BDADDR_LE_RANDOM, CALLBACK_TYPE, ConnectParams
FILTER_UUIDS: Final = "UUIDs"
_LOGGER = logging.getLogger(__name__)
def _get_device_address_type(device: BLEDevice) -> int:
"""
Get the address type for a BLE device.
Returns:
BDADDR_LE_RANDOM if the device has a random address, BDADDR_LE_PUBLIC otherwise
"""
details: dict[str, dict[str, Any]] = device.details
return (
BDADDR_LE_RANDOM
if details.get("props", {}).get("AddressType") == "random"
else BDADDR_LE_PUBLIC
)
if TYPE_CHECKING:
from .base_scanner import BaseHaScanner
from .manager import BluetoothManager
@dataclass(slots=True)
class _HaWrappedBleakBackend:
"""Wrap bleak backend to make it usable by Home Assistant."""
device: BLEDevice
scanner: BaseHaScanner
client: type[BaseBleakClient]
source: str | None
class HaBleakScannerWrapper(BaseBleakScanner):
"""A wrapper that uses the single instance."""
def __init__(
self,
*args: Any,
detection_callback: AdvertisementDataCallback | None = None,
service_uuids: list[str] | None = None,
**kwargs: Any,
) -> None:
"""Initialize the BleakScanner."""
self._detection_cancel: CALLBACK_TYPE | None = None
self._mapped_filters: dict[str, set[str]] = {}
self._advertisement_data_callback: AdvertisementDataCallback | None = None
self._background_tasks: set[asyncio.Task[Any]] = set()
remapped_kwargs = {
"detection_callback": detection_callback,
"service_uuids": service_uuids or [],
**kwargs,
}
self._map_filters(*args, **remapped_kwargs)
super().__init__(
detection_callback=detection_callback, service_uuids=service_uuids or []
)
@classmethod
async def find_device_by_address(
cls, device_identifier: str, timeout: float = 10.0, **kwargs: Any
) -> BLEDevice | None:
"""Find a device by address."""
manager = get_manager()
return manager.async_ble_device_from_address(
device_identifier, True
) or manager.async_ble_device_from_address(device_identifier, False)
@overload
@classmethod
async def discover(
cls, timeout: float = 5.0, *, return_adv: Literal[False] = False, **kwargs: Any
) -> list[BLEDevice]: ...
@overload
@classmethod
async def discover(
cls, timeout: float = 5.0, *, return_adv: Literal[True], **kwargs: Any
) -> dict[str, tuple[BLEDevice, AdvertisementData]]: ...
@classmethod
async def discover(
cls, timeout: float = 5.0, *, return_adv: bool = False, **kwargs: Any
) -> list[BLEDevice] | dict[str, tuple[BLEDevice, AdvertisementData]]:
"""Discover devices."""
infos = get_manager().async_discovered_service_info(True)
if return_adv:
return {info.address: (info.device, info.advertisement) for info in infos}
return [info.device for info in infos]
async def stop(self, *args: Any, **kwargs: Any) -> None:
"""Stop scanning for devices."""
async def start(self, *args: Any, **kwargs: Any) -> None:
"""Start scanning for devices."""
def _map_filters(self, *args: Any, **kwargs: Any) -> bool:
"""Map the filters."""
mapped_filters = {}
if filters := kwargs.get("filters"):
if filter_uuids := filters.get(FILTER_UUIDS):
mapped_filters[FILTER_UUIDS] = set(filter_uuids)
else:
_LOGGER.warning("Only %s filters are supported", FILTER_UUIDS)
if service_uuids := kwargs.get("service_uuids"):
mapped_filters[FILTER_UUIDS] = set(service_uuids)
if mapped_filters == self._mapped_filters:
return False
self._mapped_filters = mapped_filters
return True
def set_scanning_filter(self, *args: Any, **kwargs: Any) -> None:
"""Set the filters to use."""
if self._map_filters(*args, **kwargs):
self._setup_detection_callback()
def _cancel_callback(self) -> None:
"""Cancel callback."""
if self._detection_cancel:
self._detection_cancel()
self._detection_cancel = None
@property
def discovered_devices(self) -> list[BLEDevice]:
"""Return a list of discovered devices."""
return list(get_manager().async_discovered_devices(True))
def register_detection_callback(
self, callback: AdvertisementDataCallback | None
) -> Callable[[], None]:
"""
Register a detection callback.
The callback is called when a device is discovered or has a property changed.
This method takes the callback and registers it with the long running scanner.
"""
self._advertisement_data_callback = callback
self._setup_detection_callback()
if TYPE_CHECKING:
assert self._detection_cancel is not None
return self._detection_cancel
def _setup_detection_callback(self) -> None:
"""Set up the detection callback."""
if self._advertisement_data_callback is None:
return
callback = self._advertisement_data_callback
self._cancel_callback()
super().register_detection_callback(self._advertisement_data_callback)
manager = get_manager()
if not inspect.iscoroutinefunction(callback):
detection_callback = callback
else:
def detection_callback(
ble_device: BLEDevice, advertisement_data: AdvertisementData
) -> None:
task = asyncio.create_task(callback(ble_device, advertisement_data))
self._background_tasks.add(task)
task.add_done_callback(self._background_tasks.discard)
self._detection_cancel = manager.async_register_bleak_callback(
detection_callback, self._mapped_filters
)
def __del__(self) -> None:
"""Delete the BleakScanner."""
if self._detection_cancel:
# Nothing to do if event loop is already closed
with contextlib.suppress(RuntimeError):
asyncio.get_running_loop().call_soon_threadsafe(self._detection_cancel)
class HaBleakClientWrapper(BleakClient):
"""
Wrap the BleakClient to ensure it does not shutdown our scanner.
If an address is passed into BleakClient instead of a BLEDevice,
bleak will quietly start a new scanner under the hood to resolve
the address. This can cause a conflict with our scanner. We need
to handle translating the address to the BLEDevice in this case
to avoid the whole stack from getting stuck in an in progress state
when an integration does this.
"""
def __init__( # pylint: disable=super-init-not-called
self,
address_or_ble_device: str | BLEDevice,
disconnected_callback: Callable[[BleakClient], None] | None = None,
services: list[str] | None = None,
*,
timeout: float = 10.0,
pair: bool = False,
**kwargs: Any,
) -> None:
"""Initialize the BleakClient."""
if isinstance(address_or_ble_device, BLEDevice):
self.__address = address_or_ble_device.address
else:
# If we are passed an address we need to make sure
# its not a subclassed str
self.__address = str(address_or_ble_device)
self.__disconnected_callback = disconnected_callback
self.__manager = get_manager()
self.__timeout = timeout
self.__services = services
self._backend: BaseBleakClient | None = None
self._pair_before_connect = pair
# Check if this client is being created through establish_connection
# by checking for the '_is_retry_client' marker in kwargs
self._is_retry_client = kwargs.pop("_is_retry_client", False)
@property
def is_connected(self) -> bool:
"""Return True if the client is connected to a device."""
return self._backend is not None and self._backend.is_connected
async def clear_cache(self) -> bool:
"""Clear the GATT cache."""
if self._backend is not None and hasattr(self._backend, "clear_cache"):
return await self._backend.clear_cache()
return await clear_cache(self.__address)
def set_disconnected_callback(
self,
callback: Callable[[BleakClient], None] | None,
**kwargs: Any,
) -> None:
"""Set the disconnect callback."""
self.__disconnected_callback = callback
if self._backend:
self._backend.set_disconnected_callback(
self._make_disconnected_callback(callback),
**kwargs,
)
def _make_disconnected_callback(
self, callback: Callable[[BleakClient], None] | None
) -> Callable[[], None] | None:
"""
Make the disconnected callback.
https://github.com/hbldh/bleak/pull/1256
The disconnected callback needs to get the top level
BleakClientWrapper instance, not the backend instance.
The signature of the callback for the backend is:
Callable[[], None]
To make this work we need to wrap the callback in a partial
that passes the BleakClientWrapper instance as the first
argument.
"""
return None if callback is None else partial(callback, self)
async def connect(self, **kwargs: Any) -> None:
"""Connect to the specified GATT server."""
if self.is_connected:
return
# Warn if not using bleak-retry-connector's establish_connection
if not self._is_retry_client:
_LOGGER.warning(
"%s: BleakClient.connect() called without bleak-retry-connector. "
"For reliable connection establishment, use "
"bleak_retry_connector.establish_connection(). "
"See https://github.com/Bluetooth-Devices/bleak-retry-connector",
self.__address,
)
manager = self.__manager
if manager.shutdown:
raise BleakError("Bluetooth is already shutdown")
if debug_logging := _LOGGER.isEnabledFor(logging.DEBUG):
_LOGGER.debug("%s: Looking for backend to connect", self.__address)
wrapped_backend = self._async_get_best_available_backend_and_device(manager)
device = wrapped_backend.device
scanner = wrapped_backend.scanner
self._backend = wrapped_backend.client(
device,
disconnected_callback=self._make_disconnected_callback(
self.__disconnected_callback
),
services=(
None
if self.__services is None
else set(map(normalize_uuid_str, self.__services))
),
timeout=self.__timeout,
)
description = ""
rssi = None
if debug_logging:
# Only lookup the description if we are going to log it
description = ble_device_description(device)
device_adv = scanner.get_discovered_device_advertisement_data(
device.address
)
if TYPE_CHECKING:
assert device_adv is not None
adv = device_adv[1]
rssi = adv.rssi
_LOGGER.debug(
"%s: Connecting via %s (last rssi: %s)", description, scanner.name, rssi
)
# Load fast connection parameters before connecting if mgmt API is available
self._load_conn_params(
scanner,
device,
ConnectParams.FAST,
debug_logging,
description,
)
connected = False
address = device.address
try:
scanner._add_connecting(address)
await super().connect(**kwargs)
connected = True
except Exception:
# Connection failed, ensure we clean up
self._backend = None
raise
finally:
scanner._finished_connecting(address, connected)
# If we failed to connect and its a local adapter (no source)
# we release the connection slot
if not connected and not wrapped_backend.source:
manager.async_release_connection_slot(device)
# Load medium connection parameters after successful connection
if connected:
self._load_conn_params(
scanner,
device,
ConnectParams.MEDIUM,
debug_logging,
description,
)
if debug_logging:
_LOGGER.debug(
"%s: %s via %s (last rssi: %s)",
description,
"Connected" if connected else "Failed to connect",
scanner.name,
rssi,
)
return
def _load_conn_params(
self,
scanner: BaseHaScanner,
device: BLEDevice,
params: ConnectParams,
debug_logging: bool,
description: str,
) -> None:
"""Load connection parameters for a device."""
if (
(adapter_idx := scanner.adapter_idx) is not None
and (mgmt_ctl := self.__manager.get_bluez_mgmt_ctl())
and mgmt_ctl.load_conn_params(
adapter_idx,
device.address,
_get_device_address_type(device),
params,
)
and debug_logging
):
_LOGGER.debug("%s: Loaded %s connection parameters", description, params)
def _async_get_backend_for_ble_device(
self, manager: BluetoothManager, scanner: BaseHaScanner, ble_device: BLEDevice
) -> _HaWrappedBleakBackend | None:
"""Get the backend for a BLEDevice."""
if not (source := device_source(ble_device)):
# If client is not defined in details
# its the client for this platform
if not manager.async_allocate_connection_slot(ble_device):
return None
cls = get_platform_client_backend_type()
return _HaWrappedBleakBackend(ble_device, scanner, cls, source)
# Make sure the backend can connect to the device
# as some backends have connection limits
if not scanner.connector or not scanner.connector.can_connect():
return None
return _HaWrappedBleakBackend(
ble_device, scanner, scanner.connector.client, source
)
def _async_get_best_available_backend_and_device(
self, manager: BluetoothManager
) -> _HaWrappedBleakBackend:
"""
Get a best available backend and device for the given address.
This method will return the backend with the best rssi
that has a free connection slot.
"""
address = self.__address
sorted_devices = sorted(
manager.async_scanner_devices_by_address(self.__address, True),
key=lambda x: x.advertisement.rssi,
reverse=True,
)
rssi_diff = 0 # Default when there's only one device
if len(sorted_devices) > 1:
rssi_diff = (
sorted_devices[0].advertisement.rssi
- sorted_devices[1].advertisement.rssi
)
sorted_devices = sorted(
sorted_devices,
key=lambda device: device.score_connection_path(rssi_diff),
reverse=True,
)
if sorted_devices and _LOGGER.isEnabledFor(logging.INFO):
_LOGGER.info(
"%s - %s: Found %s connection path(s), preferred order: %s",
address,
sorted_devices[0].ble_device.name,
len(sorted_devices),
", ".join(
(
f"{device.scanner.name} "
f"(RSSI={device.advertisement.rssi}) "
f"(failures={device.scanner._connection_failures(address)}) "
f"(in_progress={device.scanner._connections_in_progress()}) "
+ (
f"(slots={allocations.free}/{allocations.slots} free) "
if (allocations := device.scanner.get_allocations())
else ""
)
+ f"(score={device.score_connection_path(rssi_diff)})"
)
for device in sorted_devices
),
)
for device in sorted_devices:
if backend := self._async_get_backend_for_ble_device(
manager, device.scanner, device.ble_device
):
return backend
# Check if all registered scanners are passive-only
if scanners := manager.async_current_scanners():
has_active_capable_scanner = any(
scanner.connectable for scanner in scanners
)
if not has_active_capable_scanner:
scanner_names = [scanner.name for scanner in scanners]
raise BleakError(
f"{address}: No connectable Bluetooth adapters. "
f"Shelly devices are passive-only and cannot connect. "
f"Need local Bluetooth adapter or ESPHome proxy. "
f"Available: {', '.join(scanner_names)}"
)
raise BleakError(
"No backend with an available connection slot that can reach address"
f" {address} was found"
)
async def disconnect(self) -> None:
"""Disconnect from the device."""
if self._backend is None:
return
await self._backend.disconnect()
Bluetooth-Devices-habluetooth-21accde/templates/ 0000775 0000000 0000000 00000000000 15070247161 0022120 5 ustar 00root root 0000000 0000000 Bluetooth-Devices-habluetooth-21accde/templates/CHANGELOG.md.j2 0000664 0000000 0000000 00000001235 15070247161 0024244 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-21accde/tests/ 0000775 0000000 0000000 00000000000 15070247161 0021264 5 ustar 00root root 0000000 0000000 Bluetooth-Devices-habluetooth-21accde/tests/__init__.py 0000664 0000000 0000000 00000011660 15070247161 0023401 0 ustar 00root root 0000000 0000000 import asyncio
import time
from collections.abc import Generator
from contextlib import contextmanager
from datetime import UTC, datetime
from functools import partial
from typing import Any
from unittest.mock import MagicMock, patch
from bleak.backends.scanner import AdvertisementData, BLEDevice
from habluetooth import get_manager
from habluetooth.models import BluetoothServiceInfoBleak
utcnow = partial(datetime.now, UTC)
HCI0_SOURCE_ADDRESS = "AA:BB:CC:DD:EE:00"
HCI1_SOURCE_ADDRESS = "AA:BB:CC:DD:EE:11"
NON_CONNECTABLE_REMOTE_SOURCE_ADDRESS = "AA:BB:CC:DD:EE:FF"
_MONOTONIC_RESOLUTION = time.get_clock_info("monotonic").resolution
ADVERTISEMENT_DATA_DEFAULTS = {
"local_name": "Unknown",
"manufacturer_data": {},
"service_data": {},
"service_uuids": [],
"rssi": -127,
"platform_data": ((),),
"tx_power": -127,
}
BLE_DEVICE_DEFAULTS = {
"name": None,
"details": None,
}
def generate_advertisement_data(**kwargs: Any) -> AdvertisementData:
"""Generate advertisement data with defaults."""
new = kwargs.copy()
for key, value in ADVERTISEMENT_DATA_DEFAULTS.items():
new.setdefault(key, value)
return AdvertisementData(**new)
def generate_ble_device(
address: str | None = None,
name: str | None = None,
details: Any | None = None,
**kwargs: Any,
) -> BLEDevice:
"""Generate a BLEDevice with defaults."""
new = kwargs.copy()
if address is not None:
new["address"] = address
if name is not None:
new["name"] = name
if details is not None:
new["details"] = details
for key, value in BLE_DEVICE_DEFAULTS.items():
new.setdefault(key, value)
return BLEDevice(**new)
@contextmanager
def patch_bluetooth_time(mock_time: float) -> Generator[Any, None, None]:
"""Patch the bluetooth time."""
with (
patch("habluetooth.base_scanner.monotonic_time_coarse", return_value=mock_time),
patch("habluetooth.manager.monotonic_time_coarse", return_value=mock_time),
patch("habluetooth.scanner.monotonic_time_coarse", return_value=mock_time),
):
yield
def async_fire_time_changed(utc_datetime: datetime) -> None:
timestamp = utc_datetime.timestamp()
loop = asyncio.get_running_loop()
for task in list(loop._scheduled): # type: ignore[attr-defined]
if not isinstance(task, asyncio.TimerHandle):
continue
if task.cancelled():
continue
mock_seconds_into_future = timestamp - time.time()
future_seconds = task.when() - (loop.time() + _MONOTONIC_RESOLUTION)
if mock_seconds_into_future >= future_seconds:
task._run()
task.cancel()
class MockBleakClient:
pass
def inject_advertisement(device: BLEDevice, adv: AdvertisementData) -> None:
"""Inject an advertisement into the manager."""
return inject_advertisement_with_source(device, adv, "local")
def inject_advertisement_with_source(
device: BLEDevice, adv: AdvertisementData, source: str
) -> None:
"""Inject an advertisement into the manager from a specific source."""
inject_advertisement_with_time_and_source(device, adv, time.monotonic(), source)
def inject_advertisement_with_time_and_source(
device: BLEDevice,
adv: AdvertisementData,
time: float,
source: str,
) -> None:
"""Inject an advertisement into the manager from a specific source at a time."""
inject_advertisement_with_time_and_source_connectable(
device, adv, time, source, True
)
def inject_advertisement_with_time_and_source_connectable(
device: BLEDevice,
adv: AdvertisementData,
time: float,
source: str,
connectable: bool,
) -> None:
"""
Inject an advertisement into the manager from a specific source at a time.
As well as and connectable status.
"""
manager = get_manager()
manager.scanner_adv_received(
BluetoothServiceInfoBleak(
name=adv.local_name or device.name or device.address,
address=device.address,
rssi=adv.rssi,
manufacturer_data=adv.manufacturer_data,
service_data=adv.service_data,
service_uuids=adv.service_uuids,
source=source,
device=device,
advertisement=adv,
connectable=connectable,
time=time,
tx_power=adv.tx_power,
)
)
@contextmanager
def patch_discovered_devices(
mock_discovered: list[BLEDevice],
) -> Generator[None, None, None]:
"""Mock the combined best path to discovered devices from all the scanners."""
manager = get_manager()
original_all_history = manager._all_history
original_connectable_history = manager._connectable_history
manager._connectable_history = {}
manager._all_history = {
device.address: MagicMock(device=device) for device in mock_discovered
}
yield
manager._all_history = original_all_history
manager._connectable_history = original_connectable_history
Bluetooth-Devices-habluetooth-21accde/tests/channels/ 0000775 0000000 0000000 00000000000 15070247161 0023057 5 ustar 00root root 0000000 0000000 Bluetooth-Devices-habluetooth-21accde/tests/channels/__init__.py 0000664 0000000 0000000 00000000000 15070247161 0025156 0 ustar 00root root 0000000 0000000 Bluetooth-Devices-habluetooth-21accde/tests/channels/test_bluez.py 0000664 0000000 0000000 00000142620 15070247161 0025616 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: event_code (2), controller_idx (2), param_len (2)
# Params: address (6), address_type (1), rssi (1), flags (4),
# ad_data_len (2), ad_data
ad_data = b"\x02\x01\x06" # Simple advertisement data
param_len = 6 + 1 + 1 + 4 + 2 + len(ad_data)
header = b"\x12\x00" # DEVICE_FOUND
header += b"\x00\x00" # controller_idx = 0
header += param_len.to_bytes(2, "little")
params = b"\xaa\xbb\xcc\xdd\xee\xff" # address (reversed)
params += b"\x01" # address_type
params += b"\xc8" # rssi = -56 (200 - 256)
params += b"\x00\x00\x00\x00" # flags
params += len(ad_data).to_bytes(2, "little") # ad_data_len
params += ad_data
protocol.data_received(header + params)
mock_scanner._async_on_raw_bluez_advertisement.assert_called_once_with(
b"\xaa\xbb\xcc\xdd\xee\xff",
1,
-56,
0,
ad_data,
)
def test_data_received_adv_monitor_device_found(
event_loop: asyncio.AbstractEventLoop, mock_scanner: MockHaScanner
) -> None:
"""Test data_received handles ADV_MONITOR_DEVICE_FOUND event."""
future = event_loop.create_future()
scanners: dict[int, HaScanner] = {0: mock_scanner}
on_connection_lost = Mock()
is_shutting_down = Mock(return_value=False)
mock_sock = Mock()
protocol = BluetoothMGMTProtocol(
future, scanners, on_connection_lost, is_shutting_down, mock_sock
)
# Create an ADV_MONITOR_DEVICE_FOUND event (event_code 0x002F)
# Has 2 extra bytes at the beginning of params
ad_data = b"\x02\x01\x06"
param_len = 2 + 6 + 1 + 1 + 4 + 2 + len(ad_data)
header = b"\x2f\x00" # ADV_MONITOR_DEVICE_FOUND
header += b"\x00\x00" # controller_idx = 0
header += param_len.to_bytes(2, "little")
params = b"\x00\x00" # 2 extra bytes
params += b"\xaa\xbb\xcc\xdd\xee\xff" # address
params += b"\x02" # address_type
params += b"\x64" # rssi = 100 (positive, no conversion needed)
params += b"\x00\x00\x00\x00" # flags
params += len(ad_data).to_bytes(2, "little")
params += ad_data
protocol.data_received(header + params)
mock_scanner._async_on_raw_bluez_advertisement.assert_called_once_with(
b"\xaa\xbb\xcc\xdd\xee\xff",
2,
100,
0,
ad_data,
)
def test_data_received_cmd_complete_success(
event_loop: asyncio.AbstractEventLoop,
caplog: pytest.LogCaptureFixture,
) -> None:
"""Test data_received handles successful MGMT_EV_CMD_COMPLETE."""
future = event_loop.create_future()
scanners: dict[int, HaScanner] = {}
on_connection_lost = Mock()
is_shutting_down = Mock(return_value=False)
mock_sock = Mock()
protocol = BluetoothMGMTProtocol(
future, scanners, on_connection_lost, is_shutting_down, mock_sock
)
# Create a CMD_COMPLETE event for LOAD_CONN_PARAM
header = b"\x01\x00" # MGMT_EV_CMD_COMPLETE
header += b"\x00\x00" # controller_idx = 0
header += b"\x03\x00" # param_len = 3
params = b"\x35\x00" # opcode = MGMT_OP_LOAD_CONN_PARAM
params += b"\x00" # status = 0 (success)
protocol.data_received(header + params)
assert "Connection parameters loaded successfully" in caplog.text
def test_data_received_cmd_complete_failure(
event_loop: asyncio.AbstractEventLoop,
caplog: pytest.LogCaptureFixture,
) -> None:
"""Test data_received handles failed MGMT_EV_CMD_COMPLETE."""
future = event_loop.create_future()
scanners: dict[int, HaScanner] = {}
on_connection_lost = Mock()
is_shutting_down = Mock(return_value=False)
mock_sock = Mock()
protocol = BluetoothMGMTProtocol(
future, scanners, on_connection_lost, is_shutting_down, mock_sock
)
# Create a CMD_COMPLETE event with failure
header = b"\x01\x00" # MGMT_EV_CMD_COMPLETE
header += b"\x01\x00" # controller_idx = 1
header += b"\x03\x00" # param_len = 3
params = b"\x35\x00" # opcode = MGMT_OP_LOAD_CONN_PARAM
params += b"\x0c" # status = 12 (Not Supported)
protocol.data_received(header + params)
assert "Failed to load conn params: status=12" in caplog.text
def test_data_received_cmd_status(
event_loop: asyncio.AbstractEventLoop, caplog: pytest.LogCaptureFixture
) -> None:
"""Test data_received handles MGMT_EV_CMD_STATUS."""
future = event_loop.create_future()
scanners: dict[int, HaScanner] = {}
on_connection_lost = Mock()
is_shutting_down = Mock(return_value=False)
mock_sock = Mock()
protocol = BluetoothMGMTProtocol(
future, scanners, on_connection_lost, is_shutting_down, mock_sock
)
# Create a CMD_STATUS event
header = b"\x02\x00" # MGMT_EV_CMD_STATUS
header += b"\x00\x00" # controller_idx = 0
header += b"\x03\x00" # param_len = 3
params = b"\x35\x00" # opcode = MGMT_OP_LOAD_CONN_PARAM
params += b"\x01" # status = 1 (Unknown Command)
protocol.data_received(header + params)
assert "Failed to load conn params: status=1" in caplog.text
def test_data_received_partial_data(
event_loop: asyncio.AbstractEventLoop, mock_scanner: MockHaScanner
) -> None:
"""Test data_received handles partial data correctly."""
future = event_loop.create_future()
scanners: dict[int, HaScanner] = {0: mock_scanner}
on_connection_lost = Mock()
is_shutting_down = Mock(return_value=False)
mock_sock = Mock()
protocol = BluetoothMGMTProtocol(
future, scanners, on_connection_lost, is_shutting_down, mock_sock
)
# Create a DEVICE_FOUND event but send it in chunks
ad_data = b"\x02\x01\x06"
param_len = 6 + 1 + 1 + 4 + 2 + len(ad_data)
full_data = b"\x12\x00\x00\x00" + param_len.to_bytes(2, "little")
full_data += b"\xaa\xbb\xcc\xdd\xee\xff\x01\xc8\x00\x00\x00\x00"
full_data += len(ad_data).to_bytes(2, "little") + ad_data
# Send header first
protocol.data_received(full_data[:6])
mock_scanner._async_on_raw_bluez_advertisement.assert_not_called()
# Send rest of data
protocol.data_received(full_data[6:])
mock_scanner._async_on_raw_bluez_advertisement.assert_called_once()
def test_data_received_partial_data_split_in_params(
event_loop: asyncio.AbstractEventLoop, mock_scanner: MockHaScanner
) -> None:
"""Test data_received handles data split in the middle of params."""
future = event_loop.create_future()
scanners: dict[int, HaScanner] = {0: mock_scanner}
on_connection_lost = Mock()
is_shutting_down = Mock(return_value=False)
mock_sock = Mock()
protocol = BluetoothMGMTProtocol(
future, scanners, on_connection_lost, is_shutting_down, mock_sock
)
# Create a DEVICE_FOUND event
ad_data = b"\x02\x01\x06\x03\xff\x00\x01" # Longer ad data
param_len = 6 + 1 + 1 + 4 + 2 + len(ad_data)
full_data = b"\x12\x00\x00\x00" + param_len.to_bytes(2, "little")
full_data += b"\xaa\xbb\xcc\xdd\xee\xff\x01\xc8\x00\x00\x00\x00"
full_data += len(ad_data).to_bytes(2, "little") + ad_data
# Split in the middle of the address
protocol.data_received(full_data[:10]) # Header + part of address
mock_scanner._async_on_raw_bluez_advertisement.assert_not_called()
# Send rest of data
protocol.data_received(full_data[10:])
mock_scanner._async_on_raw_bluez_advertisement.assert_called_once_with(
b"\xaa\xbb\xcc\xdd\xee\xff",
1,
-56,
0,
ad_data,
)
def test_data_received_multiple_small_chunks(
event_loop: asyncio.AbstractEventLoop, mock_scanner: MockHaScanner
) -> None:
"""Test data_received handles data sent in many small chunks."""
future = event_loop.create_future()
scanners: dict[int, HaScanner] = {0: mock_scanner}
on_connection_lost = Mock()
is_shutting_down = Mock(return_value=False)
mock_sock = Mock()
protocol = BluetoothMGMTProtocol(
future, scanners, on_connection_lost, is_shutting_down, mock_sock
)
# Create a DEVICE_FOUND event
ad_data = b"\x02\x01\x06"
param_len = 6 + 1 + 1 + 4 + 2 + len(ad_data)
full_data = b"\x12\x00\x00\x00" + param_len.to_bytes(2, "little")
full_data += b"\xaa\xbb\xcc\xdd\xee\xff\x01\xc8\x00\x00\x00\x00"
full_data += len(ad_data).to_bytes(2, "little") + ad_data
# Send data byte by byte
for i in range(len(full_data)):
protocol.data_received(full_data[i : i + 1])
if i < len(full_data) - 1:
mock_scanner._async_on_raw_bluez_advertisement.assert_not_called()
# After all bytes are sent, callback should be called once
mock_scanner._async_on_raw_bluez_advertisement.assert_called_once()
def test_data_received_multiple_events_in_one_chunk(
event_loop: asyncio.AbstractEventLoop,
mock_scanner: Mock,
caplog: pytest.LogCaptureFixture,
) -> None:
"""Test data_received handles multiple events in one data chunk."""
future = event_loop.create_future()
scanners: dict[int, HaScanner] = {0: mock_scanner}
on_connection_lost = Mock()
is_shutting_down = Mock(return_value=False)
mock_sock = Mock()
protocol = BluetoothMGMTProtocol(
future, scanners, on_connection_lost, is_shutting_down, mock_sock
)
# Create two events: a DEVICE_FOUND and a CMD_COMPLETE
ad_data = b"\x02\x01\x06"
param_len1 = 6 + 1 + 1 + 4 + 2 + len(ad_data)
event1 = b"\x12\x00\x00\x00" + param_len1.to_bytes(2, "little")
event1 += b"\xaa\xbb\xcc\xdd\xee\xff\x01\xc8\x00\x00\x00\x00"
event1 += len(ad_data).to_bytes(2, "little") + ad_data
event2 = b"\x01\x00\x00\x00\x03\x00" # CMD_COMPLETE header
event2 += b"\x35\x00\x00" # LOAD_CONN_PARAM success
# Send both events in one chunk
protocol.data_received(event1 + event2)
# Both events should be processed
mock_scanner._async_on_raw_bluez_advertisement.assert_called_once()
assert "Connection parameters loaded successfully" in caplog.text
def test_data_received_partial_then_multiple_events(
event_loop: asyncio.AbstractEventLoop, mock_scanner: MockHaScanner
) -> None:
"""Test partial data followed by multiple complete events."""
future = event_loop.create_future()
scanners: dict[int, HaScanner] = {0: mock_scanner}
on_connection_lost = Mock()
is_shutting_down = Mock(return_value=False)
mock_sock = Mock()
protocol = BluetoothMGMTProtocol(
future, scanners, on_connection_lost, is_shutting_down, mock_sock
)
# First event (DEVICE_FOUND)
ad_data1 = b"\x02\x01\x06"
param_len1 = 6 + 1 + 1 + 4 + 2 + len(ad_data1)
event1 = b"\x12\x00\x00\x00" + param_len1.to_bytes(2, "little")
event1 += b"\x11\x22\x33\x44\x55\x66\x01\xc8\x00\x00\x00\x00"
event1 += len(ad_data1).to_bytes(2, "little") + ad_data1
# Second event (ADV_MONITOR_DEVICE_FOUND)
ad_data2 = b"\x03\xff\x00\x01"
param_len2 = 2 + 6 + 1 + 1 + 4 + 2 + len(ad_data2)
event2 = b"\x2f\x00\x00\x00" + param_len2.to_bytes(2, "little")
event2 += b"\x00\x00" # Extra 2 bytes
event2 += b"\x77\x88\x99\xaa\xbb\xcc\x02\x64\x00\x00\x00\x00"
event2 += len(ad_data2).to_bytes(2, "little") + ad_data2
# Send partial first event
protocol.data_received(event1[:15])
mock_scanner._async_on_raw_bluez_advertisement.assert_not_called()
# Send rest of first event + second event
protocol.data_received(event1[15:] + event2)
# Both callbacks should be called
assert mock_scanner._async_on_raw_bluez_advertisement.call_count == 2
calls = mock_scanner._async_on_raw_bluez_advertisement.call_args_list
# First call
assert calls[0][0] == (
b"\x11\x22\x33\x44\x55\x66",
1,
-56,
0,
ad_data1,
)
# Second call
assert calls[1][0] == (
b"\x77\x88\x99\xaa\xbb\xcc",
2,
100,
0,
ad_data2,
)
def test_data_received_cmd_complete_different_opcode(
event_loop: asyncio.AbstractEventLoop, caplog: pytest.LogCaptureFixture
) -> None:
"""Test data_received handles CMD_COMPLETE for different opcodes."""
future = event_loop.create_future()
scanners: dict[int, HaScanner] = {}
on_connection_lost = Mock()
is_shutting_down = Mock(return_value=False)
mock_sock = Mock()
protocol = BluetoothMGMTProtocol(
future, scanners, on_connection_lost, is_shutting_down, mock_sock
)
# Create a CMD_COMPLETE event for a different opcode (e.g., 0x0004 - Add UUID)
header = b"\x01\x00" # MGMT_EV_CMD_COMPLETE
header += b"\x00\x00" # controller_idx = 0
header += b"\x03\x00" # param_len = 3
params = b"\x04\x00" # opcode = 0x0004 (not MGMT_OP_LOAD_CONN_PARAM)
params += b"\x00" # status = 0 (success)
protocol.data_received(header + params)
# Should not log anything about connection parameters
assert "Connection parameters" not in caplog.text
def test_data_received_cmd_status_different_opcode(
event_loop: asyncio.AbstractEventLoop, caplog: pytest.LogCaptureFixture
) -> None:
"""Test data_received handles CMD_STATUS for different opcodes."""
future = event_loop.create_future()
scanners: dict[int, HaScanner] = {}
on_connection_lost = Mock()
is_shutting_down = Mock(return_value=False)
mock_sock = Mock()
protocol = BluetoothMGMTProtocol(
future, scanners, on_connection_lost, is_shutting_down, mock_sock
)
# Create a CMD_STATUS event for a different opcode
header = b"\x02\x00" # MGMT_EV_CMD_STATUS
header += b"\x00\x00" # controller_idx = 0
header += b"\x03\x00" # param_len = 3
params = b"\x05\x00" # opcode = 0x0005 (not MGMT_OP_LOAD_CONN_PARAM)
params += b"\x01" # status = 1 (failure)
protocol.data_received(header + params)
# Should not log anything about connection parameters
assert "conn params" not in caplog.text
def test_data_received_cmd_complete_short_params(
event_loop: asyncio.AbstractEventLoop, caplog: pytest.LogCaptureFixture
) -> None:
"""Test data_received handles CMD_COMPLETE with param_len < 3."""
future = event_loop.create_future()
scanners: dict[int, HaScanner] = {}
on_connection_lost = Mock()
is_shutting_down = Mock(return_value=False)
mock_sock = Mock()
protocol = BluetoothMGMTProtocol(
future, scanners, on_connection_lost, is_shutting_down, mock_sock
)
# Create a CMD_COMPLETE event with param_len < 3
header = b"\x01\x00" # MGMT_EV_CMD_COMPLETE
header += b"\x00\x00" # controller_idx = 0
header += b"\x02\x00" # param_len = 2 (too short to contain opcode + status)
params = b"\x00\x00" # Just 2 bytes
protocol.data_received(header + params)
# Should not log anything (no opcode to check)
assert "conn params" not in caplog.text
def test_data_received_cmd_status_param_len_1(
event_loop: asyncio.AbstractEventLoop, caplog: pytest.LogCaptureFixture
) -> None:
"""Test data_received handles CMD_STATUS with param_len = 1."""
future = event_loop.create_future()
scanners: dict[int, HaScanner] = {}
on_connection_lost = Mock()
is_shutting_down = Mock(return_value=False)
mock_sock = Mock()
protocol = BluetoothMGMTProtocol(
future, scanners, on_connection_lost, is_shutting_down, mock_sock
)
# Create a CMD_STATUS event with param_len = 1
header = b"\x02\x00" # MGMT_EV_CMD_STATUS
header += b"\x00\x00" # controller_idx = 0
header += b"\x01\x00" # param_len = 1 (too short)
params = b"\x00" # Just 1 byte
protocol.data_received(header + params)
# Should not log anything (no opcode to check)
assert "conn params" not in caplog.text
def test_data_received_cmd_complete_param_len_0(
event_loop: asyncio.AbstractEventLoop, caplog: pytest.LogCaptureFixture
) -> None:
"""Test data_received handles CMD_COMPLETE with param_len = 0."""
future = event_loop.create_future()
scanners: dict[int, HaScanner] = {}
on_connection_lost = Mock()
is_shutting_down = Mock(return_value=False)
mock_sock = Mock()
protocol = BluetoothMGMTProtocol(
future, scanners, on_connection_lost, is_shutting_down, mock_sock
)
# Create a CMD_COMPLETE event with param_len = 0
header = b"\x01\x00" # MGMT_EV_CMD_COMPLETE
header += b"\x00\x00" # controller_idx = 0
header += b"\x00\x00" # param_len = 0 (no params at all)
protocol.data_received(header)
# Should not log anything (no opcode to check)
assert "conn params" not in caplog.text
def test_data_received_unknown_event(event_loop: asyncio.AbstractEventLoop) -> None:
"""Test data_received ignores unknown events."""
future = event_loop.create_future()
scanners: dict[int, HaScanner] = {}
on_connection_lost = Mock()
is_shutting_down = Mock(return_value=False)
mock_sock = Mock()
protocol = BluetoothMGMTProtocol(
future, scanners, on_connection_lost, is_shutting_down, mock_sock
)
# Create an unknown event
header = b"\xff\x00" # Unknown event code
header += b"\x00\x00" # controller_idx = 0
header += b"\x04\x00" # param_len = 4
params = b"\x00\x00\x00\x00"
# Should not raise any exception
protocol.data_received(header + params)
def test_data_received_no_scanner_for_controller(
event_loop: asyncio.AbstractEventLoop,
) -> None:
"""Test data_received handles missing scanner gracefully."""
future = event_loop.create_future()
scanners: dict[int, HaScanner] = {} # No scanner for controller 0
on_connection_lost = Mock()
is_shutting_down = Mock(return_value=False)
mock_sock = Mock()
protocol = BluetoothMGMTProtocol(
future, scanners, on_connection_lost, is_shutting_down, mock_sock
)
# Create a DEVICE_FOUND event for controller 0
ad_data = b"\x02\x01\x06"
param_len = 6 + 1 + 1 + 4 + 2 + len(ad_data)
header = b"\x12\x00\x00\x00" + param_len.to_bytes(2, "little")
params = b"\xaa\xbb\xcc\xdd\xee\xff\x01\xc8\x00\x00\x00\x00"
params += len(ad_data).to_bytes(2, "little") + ad_data
# Should not raise any exception
protocol.data_received(header + params)
@pytest.mark.asyncio
async def test_setup_success() -> None:
"""Test successful setup."""
mock_sock = Mock()
mock_sock.fileno.return_value = 1 # Mock socket file descriptor
mock_protocol = Mock(spec=BluetoothMGMTProtocol)
mock_transport = Mock()
mock_protocol.transport = mock_transport
# Mock the future that gets created and set
loop = asyncio.get_running_loop()
mock_future = loop.create_future()
async def mock_create_connection(*args, **kwargs):
# Set the future result to simulate connection made
mock_future.set_result(None)
return mock_transport, mock_protocol
with (
patch("habluetooth.channels.bluez.btmgmt_socket.open", return_value=mock_sock),
patch.object(
asyncio.get_running_loop(),
"_create_connection_transport",
side_effect=mock_create_connection,
),
patch.object(
asyncio.get_running_loop(),
"create_future",
side_effect=[
mock_future,
loop.create_future(),
], # First for connection, second for on_connection_lost
),
patch.object(
MGMTBluetoothCtl,
"_check_capabilities",
return_value=True, # Mock successful capability check
),
):
ctl = MGMTBluetoothCtl(5.0, {})
await ctl.setup()
assert ctl.sock is mock_sock
assert ctl.protocol is mock_protocol
assert ctl._reconnect_task is not None
# Clean up
ctl._reconnect_task.cancel()
with pytest.raises(asyncio.CancelledError):
await ctl._reconnect_task
@pytest.mark.asyncio
async def test_setup_timeout() -> None:
"""Test setup timeout."""
mock_sock = Mock()
async def slow_connect(*args, **kwargs):
await asyncio.sleep(10)
with (
patch("habluetooth.channels.bluez.btmgmt_socket.open", return_value=mock_sock),
patch.object(
asyncio.get_running_loop(),
"_create_connection_transport",
side_effect=slow_connect,
),
patch("habluetooth.channels.bluez.btmgmt_socket.close") as mock_close,
):
ctl = MGMTBluetoothCtl(0.1, {})
with pytest.raises(TimeoutError):
await ctl.setup()
mock_close.assert_called_once_with(mock_sock)
@pytest.mark.asyncio
async def test_load_conn_params_fast() -> None:
"""Test loading fast connection parameters."""
mock_sock = Mock()
mock_protocol = Mock(spec=BluetoothMGMTProtocol)
mock_transport = Mock()
mock_protocol.transport = mock_transport
# Mock the _write_to_socket method
mock_protocol._write_to_socket = Mock()
ctl = MGMTBluetoothCtl(5.0, {})
ctl.protocol = mock_protocol
ctl.sock = mock_sock
result = ctl.load_conn_params(
0, # adapter_idx
"AA:BB:CC:DD:EE:FF", # address
BDADDR_LE_PUBLIC, # address_type
ConnectParams.FAST,
)
assert result is True
# Verify the command was sent
mock_protocol._write_to_socket.assert_called_once()
call_args = mock_protocol._write_to_socket.call_args[0][0]
# Check header (6 bytes)
assert call_args[0:2] == b"\x35\x00" # MGMT_OP_LOAD_CONN_PARAM
assert call_args[2:4] == b"\x00\x00" # adapter_idx = 0
assert call_args[4:6] == b"\x11\x00" # param_len = 17 (2 + 15)
# Check command data
assert call_args[6:8] == b"\x01\x00" # param_count = 1
assert call_args[8:14] == b"\xff\xee\xdd\xcc\xbb\xaa" # address (reversed)
assert call_args[14] == BDADDR_LE_PUBLIC # address_type
assert call_args[15:17] == FAST_MIN_CONN_INTERVAL.to_bytes(2, "little")
assert call_args[17:19] == FAST_MAX_CONN_INTERVAL.to_bytes(2, "little")
assert call_args[19:21] == FAST_CONN_LATENCY.to_bytes(2, "little")
assert call_args[21:23] == FAST_CONN_TIMEOUT.to_bytes(2, "little")
@pytest.mark.asyncio
async def test_load_conn_params_medium() -> None:
"""Test loading medium connection parameters."""
mock_sock = Mock()
mock_protocol = Mock(spec=BluetoothMGMTProtocol)
mock_transport = Mock()
mock_protocol.transport = mock_transport
# Mock the _write_to_socket method
mock_protocol._write_to_socket = Mock()
ctl = MGMTBluetoothCtl(5.0, {})
ctl.protocol = mock_protocol
ctl.sock = mock_sock
result = ctl.load_conn_params(
1, # adapter_idx
"11:22:33:44:55:66", # address
BDADDR_LE_RANDOM, # address_type
ConnectParams.MEDIUM,
)
assert result is True
# Verify the command was sent
mock_protocol._write_to_socket.assert_called_once()
call_args = mock_protocol._write_to_socket.call_args[0][0]
# Check header
assert call_args[0:2] == b"\x35\x00" # MGMT_OP_LOAD_CONN_PARAM
assert call_args[2:4] == b"\x01\x00" # adapter_idx = 1
# Check parameters
assert call_args[8:14] == b"\x66\x55\x44\x33\x22\x11" # address (reversed)
assert call_args[14] == BDADDR_LE_RANDOM # address_type
assert call_args[15:17] == MEDIUM_MIN_CONN_INTERVAL.to_bytes(2, "little")
assert call_args[17:19] == MEDIUM_MAX_CONN_INTERVAL.to_bytes(2, "little")
assert call_args[19:21] == MEDIUM_CONN_LATENCY.to_bytes(2, "little")
assert call_args[21:23] == MEDIUM_CONN_TIMEOUT.to_bytes(2, "little")
def test_load_conn_params_no_protocol(caplog: pytest.LogCaptureFixture) -> None:
"""Test load_conn_params when protocol is not connected."""
ctl = MGMTBluetoothCtl(5.0, {})
result = ctl.load_conn_params(
0,
"AA:BB:CC:DD:EE:FF",
BDADDR_LE_PUBLIC,
ConnectParams.FAST,
)
assert result is False
assert "Cannot load conn params: no connection" in caplog.text
def test_load_conn_params_invalid_address(caplog: pytest.LogCaptureFixture) -> None:
"""Test load_conn_params with invalid MAC address."""
mock_protocol = Mock(spec=BluetoothMGMTProtocol)
mock_transport = Mock()
mock_protocol.transport = mock_transport
ctl = MGMTBluetoothCtl(5.0, {})
ctl.protocol = mock_protocol
# Test with too short address
result = ctl.load_conn_params(
0,
"AA:BB",
BDADDR_LE_PUBLIC,
ConnectParams.FAST,
)
assert result is False
assert "Invalid MAC address: AA:BB" in caplog.text
def test_load_conn_params_transport_error(caplog: pytest.LogCaptureFixture) -> None:
"""Test load_conn_params with transport write error."""
mock_protocol = Mock(spec=BluetoothMGMTProtocol)
mock_transport = Mock()
mock_socket = Mock()
mock_socket.send.side_effect = Exception("Transport error")
mock_transport.get_extra_info = Mock(return_value=mock_socket)
mock_protocol.transport = mock_transport
mock_protocol._sock = mock_socket
mock_protocol._write_to_socket = Mock(side_effect=Exception("Transport error"))
ctl = MGMTBluetoothCtl(5.0, {})
ctl.protocol = mock_protocol
result = ctl.load_conn_params(
0,
"AA:BB:CC:DD:EE:FF",
BDADDR_LE_PUBLIC,
ConnectParams.FAST,
)
assert result is False
assert "Failed to load conn params" in caplog.text
def test_kernel_bug_workaround_send_returns_zero(
event_loop: asyncio.AbstractEventLoop, caplog: pytest.LogCaptureFixture
) -> None:
"""Test that the kernel bug workaround handles send returning 0."""
future = event_loop.create_future()
scanners: dict[int, HaScanner] = {}
on_connection_lost = Mock()
is_shutting_down = Mock(return_value=False)
# Create a mock socket that returns 0 (kernel bug behavior)
mock_socket = Mock()
mock_socket.send = Mock(return_value=0)
protocol = BluetoothMGMTProtocol(
future, scanners, on_connection_lost, is_shutting_down, mock_socket
)
# Send some data
test_data = b"\x25\x00\x00\x00\x00\x00"
with caplog.at_level(logging.DEBUG):
protocol._write_to_socket(test_data)
# Verify the send was called and the workaround logged
mock_socket.send.assert_called_once_with(test_data)
assert "kernel bug fix" in caplog.text
def test_kernel_bug_workaround_send_raises_exception(
event_loop: asyncio.AbstractEventLoop, caplog: pytest.LogCaptureFixture
) -> None:
"""Test that _write_to_socket handles and re-raises exceptions."""
future = event_loop.create_future()
scanners: dict[int, HaScanner] = {}
on_connection_lost = Mock()
is_shutting_down = Mock(return_value=False)
# Create a mock socket that raises an exception
mock_socket = Mock()
mock_socket.send = Mock(side_effect=OSError("Socket error"))
protocol = BluetoothMGMTProtocol(
future, scanners, on_connection_lost, is_shutting_down, mock_socket
)
# Send some data and expect the exception to be re-raised
test_data = b"\x25\x00\x00\x00\x00\x00"
with pytest.raises(OSError, match="Socket error"):
protocol._write_to_socket(test_data)
# Verify the error was logged
assert "Failed to write to mgmt socket: Socket error" in caplog.text
mock_socket.send.assert_called_once_with(test_data)
def test_close() -> None:
"""Test close method."""
mock_protocol = Mock(spec=BluetoothMGMTProtocol)
mock_transport = Mock()
mock_protocol.transport = mock_transport
mock_sock = Mock()
mock_reconnect_task = Mock()
ctl = MGMTBluetoothCtl(5.0, {})
ctl.protocol = mock_protocol
ctl.sock = mock_sock
ctl._reconnect_task = mock_reconnect_task
with patch("habluetooth.channels.bluez.btmgmt_socket.close") as mock_close:
ctl.close()
mock_reconnect_task.cancel.assert_called_once()
mock_transport.close.assert_called_once()
mock_close.assert_called_once_with(mock_sock)
assert ctl.protocol is None
def test_close_no_protocol() -> None:
"""Test close when protocol is not set."""
ctl = MGMTBluetoothCtl(5.0, {})
# Should not raise any exception
with patch("habluetooth.channels.bluez.btmgmt_socket.close"):
ctl.close()
@pytest.mark.asyncio
async def test_on_connection_lost() -> None:
"""Test _on_connection_lost callback."""
ctl = MGMTBluetoothCtl(5.0, {})
loop = asyncio.get_running_loop()
ctl._on_connection_lost_future = loop.create_future()
ctl._on_connection_lost()
# _on_connection_lost sets the future to None after setting result
assert ctl._on_connection_lost_future is None
@pytest.mark.asyncio
async def test_on_connection_lost_during_shutdown(
caplog: pytest.LogCaptureFixture,
) -> None:
"""Test _on_connection_lost callback during shutdown."""
ctl = MGMTBluetoothCtl(5.0, {})
loop = asyncio.get_running_loop()
ctl._on_connection_lost_future = loop.create_future()
ctl._shutting_down = True
with caplog.at_level(logging.DEBUG):
ctl._on_connection_lost()
# Should log shutdown message
assert "Bluetooth management socket connection lost during shutdown" in caplog.text
# Should not log reconnecting message
assert "reconnecting" not in caplog.text
# _on_connection_lost sets the future to None after setting result
assert ctl._on_connection_lost_future is None
@pytest.mark.asyncio
async def test_reconnect_task() -> None:
"""Test reconnect_task behavior."""
mock_protocol = Mock(spec=BluetoothMGMTProtocol)
mock_transport = Mock()
mock_protocol.transport = mock_transport
establish_count = 0
ctl = MGMTBluetoothCtl(5.0, {})
async def mock_establish_connection() -> None:
nonlocal establish_count
establish_count += 1
if establish_count == 1:
# First call succeeds
ctl.protocol = mock_protocol
ctl._on_connection_lost_future = asyncio.get_running_loop().create_future()
elif establish_count == 2:
# Second call fails
raise BluetoothSocketError("Test error")
else:
# Stop the test
raise asyncio.CancelledError()
with patch.object(
ctl, "_establish_connection", side_effect=mock_establish_connection
):
# Start the reconnect task
task = asyncio.create_task(ctl.reconnect_task())
# Trigger reconnection by calling _on_connection_lost
await asyncio.sleep(0.1)
ctl._on_connection_lost()
# Wait for reconnection attempt
await asyncio.sleep(1.5)
# Cancel the task
task.cancel()
with pytest.raises(asyncio.CancelledError):
await task
assert establish_count >= 2
@pytest.mark.asyncio
async def test_reconnect_task_timeout() -> None:
"""Test reconnect_task with connection timeout."""
async def mock_establish_connection() -> None:
raise TimeoutError("Connection timeout")
ctl = MGMTBluetoothCtl(5.0, {})
ctl._on_connection_lost_future = None
with patch.object(
ctl, "_establish_connection", side_effect=mock_establish_connection
):
# Run reconnect_task briefly
task = asyncio.create_task(ctl.reconnect_task())
await asyncio.sleep(0.1)
# Cancel the task
task.cancel()
with pytest.raises(asyncio.CancelledError):
await task
@pytest.mark.asyncio
async def test_reconnect_task_shutdown() -> None:
"""Test reconnect_task exits when shutting down."""
ctl = MGMTBluetoothCtl(5.0, {})
loop = asyncio.get_running_loop()
establish_called = False
async def mock_establish_connection() -> None:
nonlocal establish_called
establish_called = True
# Should not be called since we're shutting down
raise Exception("Should not be called")
with patch.object(
ctl, "_establish_connection", side_effect=mock_establish_connection
):
# Set up connection lost future
ctl._on_connection_lost_future = loop.create_future()
# Start the reconnect task
task = asyncio.create_task(ctl.reconnect_task())
# Give it a moment to start
await asyncio.sleep(0)
# Simulate shutdown
ctl._shutting_down = True
# Trigger the future to wake up the task
ctl._on_connection_lost_future.set_result(None)
# Task should exit cleanly
await task
# _establish_connection should not have been called
assert not establish_called
@pytest.mark.asyncio
async def test_command_response_context_manager() -> None:
"""Test the command_response context manager."""
future = asyncio.get_running_loop().create_future()
future.set_result(None) # Mark connection as made
scanners: dict[int, HaScanner] = {}
on_connection_lost = Mock()
is_shutting_down = Mock(return_value=False)
mock_sock = Mock()
protocol = BluetoothMGMTProtocol(
future, scanners, on_connection_lost, is_shutting_down, mock_sock
)
# Test successful command response
opcode = 0x0015 # MGMT_OP_GET_CONNECTIONS
async with protocol.command_response(opcode) as response_future:
# Verify we got a future
assert response_future is not None
assert isinstance(response_future, asyncio.Future)
# Simulate receiving a response
response_data = (
b"\x01\x00" # MGMT_EV_CMD_COMPLETE
+ b"\x00\x00" # controller index
+ b"\x03\x00" # param_len (3 bytes: opcode=2 + status=1)
+ opcode.to_bytes(2, "little") # opcode
+ b"\x00" # status (success)
)
protocol.data_received(response_data)
# Get the result
status, _data = await response_future
assert status == 0 # Success
# After context exits, future should be resolved
assert response_future.done()
@pytest.mark.asyncio
async def test_command_response_cleanup_on_exception() -> None:
"""Test that command_response cleans up even if an exception occurs."""
future = asyncio.get_running_loop().create_future()
scanners: dict[int, HaScanner] = {}
on_connection_lost = Mock()
is_shutting_down = Mock(return_value=False)
mock_sock = Mock()
protocol = BluetoothMGMTProtocol(
future, scanners, on_connection_lost, is_shutting_down, mock_sock
)
opcode = 0x0015 # MGMT_OP_GET_CONNECTIONS
# Test cleanup on exception
with pytest.raises(ValueError):
async with protocol.command_response(opcode) as response_future:
# Verify we got a future
assert response_future is not None
raise ValueError("Test exception")
# The future should still exist after exception
# (cleanup just removes it from internal tracking)
@pytest.mark.asyncio
async def test_get_connections_response_handling() -> None:
"""Test handling of GET_CONNECTIONS command response."""
future = asyncio.get_running_loop().create_future()
scanners: dict[int, HaScanner] = {}
on_connection_lost = Mock()
is_shutting_down = Mock(return_value=False)
mock_sock = Mock()
protocol = BluetoothMGMTProtocol(
future, scanners, on_connection_lost, is_shutting_down, mock_sock
)
opcode = 0x0015 # MGMT_OP_GET_CONNECTIONS
# Use the command_response context manager to register the command
async with protocol.command_response(opcode) as response_future:
# Test with permission denied status (0x14)
response_data = (
b"\x01\x00" # MGMT_EV_CMD_COMPLETE
+ b"\x00\x00" # controller index
+ b"\x03\x00" # param_len
+ opcode.to_bytes(2, "little") # opcode
+ b"\x14" # status (permission denied)
)
protocol.data_received(response_data)
# Verify the future was resolved with the status
status, data = await response_future
assert status == 0x14 # Permission denied
assert data == b"" # No additional data for param_len <= 3
@pytest.mark.asyncio
async def test_get_connections_response_with_data() -> None:
"""Test GET_CONNECTIONS response with additional data."""
future = asyncio.get_running_loop().create_future()
scanners: dict[int, HaScanner] = {}
on_connection_lost = Mock()
is_shutting_down = Mock(return_value=False)
mock_sock = Mock()
protocol = BluetoothMGMTProtocol(
future, scanners, on_connection_lost, is_shutting_down, mock_sock
)
opcode = 0x0015 # MGMT_OP_GET_CONNECTIONS
# Use the command_response context manager to register the command
async with protocol.command_response(opcode) as response_future:
# Test with success status and additional data
extra_data = b"\x01\x02\x03\x04"
response_data = (
b"\x01\x00" # MGMT_EV_CMD_COMPLETE
+ b"\x00\x00" # controller index
+ (3 + len(extra_data)).to_bytes(
2, "little"
) # param_len (opcode=2 + status=1 + extra_data)
+ opcode.to_bytes(2, "little") # opcode
+ b"\x00" # status (success)
+ extra_data # additional response data
)
protocol.data_received(response_data)
# Verify the future was resolved with status and data
status, data = await response_future
assert status == 0 # Success
assert data == extra_data
@pytest.mark.asyncio
async def test_has_mgmt_capabilities_from_status() -> None:
"""Test _has_mgmt_capabilities_from_status helper function."""
mgmt_ctl = MGMTBluetoothCtl(timeout=5.0, scanners={})
# Test permission denied
assert mgmt_ctl._has_mgmt_capabilities_from_status(0x14) is False
# Test success
assert mgmt_ctl._has_mgmt_capabilities_from_status(0x00) is True
# Test invalid index (still has permissions)
assert mgmt_ctl._has_mgmt_capabilities_from_status(0x11) is True
# Test unknown status (assumes no permissions)
assert mgmt_ctl._has_mgmt_capabilities_from_status(0xFF) is False
assert mgmt_ctl._has_mgmt_capabilities_from_status(0x01) is False
assert mgmt_ctl._has_mgmt_capabilities_from_status(0x0D) is False
@pytest.mark.asyncio
async def test_check_capabilities_success() -> None:
"""Test _check_capabilities when permissions are available."""
mgmt_ctl = MGMTBluetoothCtl(timeout=5.0, scanners={})
# Mock the protocol and transport
mock_protocol = Mock(spec=BluetoothMGMTProtocol)
mock_transport = Mock()
mock_protocol.transport = mock_transport
mgmt_ctl.protocol = mock_protocol
# Mock command_response to return success
def mock_command_response(opcode: int) -> object:
future = asyncio.get_running_loop().create_future()
future.set_result((0x00, b"")) # Success status
class MockContext:
async def __aenter__(self) -> asyncio.Future[tuple[int, bytes]]:
return future
async def __aexit__(self, *args: object) -> None:
pass
return MockContext()
mock_protocol.command_response = mock_command_response
# Mock the _write_to_socket method
mock_protocol._write_to_socket = Mock()
# Test capability check
result = await mgmt_ctl._check_capabilities()
assert result is True
# Verify the command was sent
mock_protocol._write_to_socket.assert_called_once()
sent_data = mock_protocol._write_to_socket.call_args[0][0]
# Check that it's a GET_CONNECTIONS command (opcode at bytes 0-1)
assert sent_data[0:2] == b"\x15\x00" # MGMT_OP_GET_CONNECTIONS little-endian
@pytest.mark.asyncio
async def test_check_capabilities_permission_denied() -> None:
"""Test _check_capabilities when permissions are denied."""
mgmt_ctl = MGMTBluetoothCtl(timeout=5.0, scanners={})
# Mock the protocol and transport
mock_protocol = Mock(spec=BluetoothMGMTProtocol)
mock_transport = Mock()
mock_protocol.transport = mock_transport
mgmt_ctl.protocol = mock_protocol
# Mock command_response to return permission denied
def mock_command_response(opcode: int) -> object:
future = asyncio.get_running_loop().create_future()
future.set_result((0x14, b"")) # Permission denied status
class MockContext:
async def __aenter__(self) -> asyncio.Future[tuple[int, bytes]]:
return future
async def __aexit__(self, *args: object) -> None:
pass
return MockContext()
mock_protocol.command_response = mock_command_response
# Test capability check
result = await mgmt_ctl._check_capabilities()
assert result is False
@pytest.mark.asyncio
async def test_check_capabilities_invalid_index() -> None:
"""Test _check_capabilities with invalid adapter index (still has permissions)."""
mgmt_ctl = MGMTBluetoothCtl(timeout=5.0, scanners={})
# Mock the protocol and transport
mock_protocol = Mock(spec=BluetoothMGMTProtocol)
mock_transport = Mock()
mock_protocol.transport = mock_transport
mgmt_ctl.protocol = mock_protocol
# Mock command_response to return invalid index
def mock_command_response(opcode: int) -> object:
future = asyncio.get_running_loop().create_future()
future.set_result((0x11, b"")) # Invalid index
class MockContext:
async def __aenter__(self) -> asyncio.Future[tuple[int, bytes]]:
return future
async def __aexit__(self, *args: object) -> None:
pass
return MockContext()
mock_protocol.command_response = mock_command_response
# Test capability check - invalid index means adapter doesn't exist
# but we still have permissions
result = await mgmt_ctl._check_capabilities()
assert result is True
@pytest.mark.asyncio
async def test_check_capabilities_unknown_status() -> None:
"""Test _check_capabilities with unknown status code."""
mgmt_ctl = MGMTBluetoothCtl(timeout=5.0, scanners={})
# Mock the protocol and transport
mock_protocol = Mock(spec=BluetoothMGMTProtocol)
mock_transport = Mock()
mock_protocol.transport = mock_transport
mgmt_ctl.protocol = mock_protocol
# Mock command_response to return unknown status
def mock_command_response(opcode: int) -> object:
future = asyncio.get_running_loop().create_future()
future.set_result((0xFF, b"")) # Unknown status
class MockContext:
async def __aenter__(self) -> asyncio.Future[tuple[int, bytes]]:
return future
async def __aexit__(self, *args: object) -> None:
pass
return MockContext()
mock_protocol.command_response = mock_command_response
# Test capability check - unknown status assumes no permissions
result = await mgmt_ctl._check_capabilities()
assert result is False
@pytest.mark.asyncio
async def test_check_capabilities_timeout() -> None:
"""Test _check_capabilities when command times out."""
mgmt_ctl = MGMTBluetoothCtl(timeout=5.0, scanners={})
# Mock the protocol and transport
mock_protocol = Mock(spec=BluetoothMGMTProtocol)
mock_transport = Mock()
mock_protocol.transport = mock_transport
mgmt_ctl.protocol = mock_protocol
# Mock command_response to timeout
def mock_command_response(opcode: int) -> object:
future = asyncio.get_running_loop().create_future()
# Never resolve the future
class MockContext:
async def __aenter__(self) -> asyncio.Future[tuple[int, bytes]]:
return future
async def __aexit__(self, *args: object) -> None:
pass
return MockContext()
mock_protocol.command_response = mock_command_response
# Test capability check with a very short timeout
with patch("habluetooth.channels.bluez.asyncio_timeout") as mock_timeout:
# Make timeout raise immediately
mock_timeout.side_effect = TimeoutError("Test timeout")
result = await mgmt_ctl._check_capabilities()
assert result is False
@pytest.mark.asyncio
async def test_check_capabilities_no_protocol() -> None:
"""Test _check_capabilities when protocol is not set."""
mgmt_ctl = MGMTBluetoothCtl(timeout=5.0, scanners={})
# No protocol set
mgmt_ctl.protocol = None
result = await mgmt_ctl._check_capabilities()
assert result is False
@pytest.mark.asyncio
async def test_check_capabilities_no_transport() -> None:
"""Test _check_capabilities when transport is not set."""
mgmt_ctl = MGMTBluetoothCtl(timeout=5.0, scanners={})
# Mock protocol with no transport
mock_protocol = Mock(spec=BluetoothMGMTProtocol)
mock_protocol.transport = None
mgmt_ctl.protocol = mock_protocol
result = await mgmt_ctl._check_capabilities()
assert result is False
@pytest.mark.asyncio
async def test_setup_with_failed_capabilities() -> None:
"""Test setup raises PermissionError when capabilities check fails."""
with (
patch("habluetooth.channels.bluez.btmgmt_socket") as mock_btmgmt,
patch.object(MGMTBluetoothCtl, "_establish_connection") as mock_establish,
patch.object(MGMTBluetoothCtl, "_check_capabilities", return_value=False),
):
mock_socket = Mock()
mock_socket.fileno.return_value = 99
mock_btmgmt.open.return_value = mock_socket
mgmt_ctl = MGMTBluetoothCtl(timeout=5.0, scanners={})
# Mock successful connection establishment
mock_establish.return_value = None
# Set the socket on mgmt_ctl
mgmt_ctl.sock = mock_socket
# Mock protocol for close operation
mock_protocol = Mock()
mock_transport = Mock()
mock_protocol.transport = mock_transport
mgmt_ctl.protocol = mock_protocol
# Setup should raise PermissionError
with pytest.raises(PermissionError) as exc_info:
await mgmt_ctl.setup()
assert "Missing NET_ADMIN/NET_RAW capabilities" in str(exc_info.value)
# Verify cleanup
assert mgmt_ctl._shutting_down is True
mock_transport.close.assert_called_once()
mock_btmgmt.close.assert_called_once_with(mock_socket)
Bluetooth-Devices-habluetooth-21accde/tests/conftest.py 0000664 0000000 0000000 00000022051 15070247161 0023463 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._unavailable_callbacks.clear()
manager._connectable_unavailable_callbacks.clear()
manager._bleak_callbacks.clear()
manager._fallback_intervals.clear()
manager._intervals.clear()
manager._adapter_sources.clear()
manager._adapters.clear()
manager._sources.clear()
manager._allocations.clear()
manager._non_connectable_scanners.clear()
manager._connectable_scanners.clear()
@pytest.fixture(scope="session")
def mock_bluetooth_adapters() -> Generator[None, None, None]:
"""Fixture to mock bluetooth adapters."""
with (
patch("bluetooth_auto_recovery.recover_adapter"),
patch("bluetooth_adapters.systems.platform.system", return_value="Linux"),
patch("bluetooth_adapters.systems.linux.LinuxAdapters.refresh"),
patch(
"bluetooth_adapters.systems.linux.LinuxAdapters.adapters",
{
"hci0": {
"address": "00:00:00:00:00:01",
"hw_version": "usb:v1D6Bp0246d053F",
"passive_scan": False,
"sw_version": "homeassistant",
"manufacturer": "ACME",
"product": "Bluetooth Adapter 5.0",
"product_id": "aa01",
"vendor_id": "cc01",
},
},
),
):
yield
@pytest.fixture
def mock_bleak_scanner_start() -> Generator[MagicMock, None, None]:
"""Fixture to mock starting the bleak scanner."""
bluetooth_scanner.OriginalBleakScanner.stop = AsyncMock()
with (
patch.object(
bluetooth_scanner.OriginalBleakScanner,
"start",
) as mock_bleak_scanner_start,
patch.object(bluetooth_scanner, "HaScanner"),
):
yield mock_bleak_scanner_start
@pytest.fixture(name="two_adapters")
def two_adapters_fixture():
"""Fixture that mocks two adapters on Linux."""
with (
patch(
"habluetooth.scanner.platform.system",
return_value="Linux",
),
patch("bluetooth_adapters.systems.platform.system", return_value="Linux"),
patch("bluetooth_adapters.systems.linux.LinuxAdapters.refresh"),
patch(
"bluetooth_adapters.systems.linux.LinuxAdapters.adapters",
{
"hci0": {
"address": "00:00:00:00:00:01",
"hw_version": "usb:v1D6Bp0246d053F",
"passive_scan": False,
"sw_version": "homeassistant",
"manufacturer": "ACME",
"product": "Bluetooth Adapter 5.0",
"product_id": "aa01",
"vendor_id": "cc01",
"connection_slots": 1,
},
"hci1": {
"address": "00:00:00:00:00:02",
"hw_version": "usb:v1D6Bp0246d053F",
"passive_scan": True,
"sw_version": "homeassistant",
"manufacturer": "ACME",
"product": "Bluetooth Adapter 5.0",
"product_id": "aa01",
"vendor_id": "cc01",
"connection_slots": 2,
},
},
),
):
yield
@pytest.fixture(name="macos_adapter")
def macos_adapter() -> Generator[None, None, None]:
"""Fixture that mocks the macos adapter."""
with (
patch("bleak.get_platform_scanner_backend_type"),
patch(
"habluetooth.scanner.platform.system",
return_value="Darwin",
),
patch(
"bluetooth_adapters.systems.platform.system",
return_value="Darwin",
),
patch("habluetooth.scanner.SYSTEM", "Darwin"),
):
yield
@pytest.fixture
def register_hci0_scanner() -> Generator[None, None, None]:
"""Register an hci0 scanner."""
hci0_scanner = FakeScanner("AA:BB:CC:DD:EE:00", "hci0")
hci0_scanner.connectable = True
manager = get_manager()
cancel = manager.async_register_scanner(hci0_scanner, connection_slots=5)
yield
cancel()
@pytest.fixture
def register_hci1_scanner() -> Generator[None, None, None]:
"""Register an hci1 scanner."""
hci1_scanner = FakeScanner("AA:BB:CC:DD:EE:11", "hci1")
hci1_scanner.connectable = True
manager = get_manager()
cancel = manager.async_register_scanner(hci1_scanner, connection_slots=5)
yield
cancel()
@pytest.fixture
def register_non_connectable_scanner() -> Generator[None, None, None]:
"""Register an non connectable remote scanner."""
remote_scanner = BaseHaRemoteScanner(
"AA:BB:CC:DD:EE:FF", "non connectable", None, False
)
manager = get_manager()
cancel = manager.async_register_scanner(remote_scanner)
yield
cancel()
class MockBluetoothManagerWithCallbacks(BluetoothManager):
"""Mock bluetooth manager that tracks scanner start callbacks."""
def __init__(self, *args, **kwargs):
"""Initialize the mock manager."""
super().__init__(*args, **kwargs)
self.scanner_start_calls = []
def on_scanner_start(self, scanner):
"""Track scanner start calls."""
self.scanner_start_calls.append(scanner)
super().on_scanner_start(scanner)
@pytest.fixture
def mock_manager_with_scanner_callbacks() -> (
Generator[MockBluetoothManagerWithCallbacks, None, None]
):
"""Provide a mock BluetoothManager that tracks scanner start callbacks."""
mock_bluetooth_adapters = FakeBluetoothAdapters()
manager = MockBluetoothManagerWithCallbacks(
mock_bluetooth_adapters,
slot_manager=MagicMock(),
)
# Save the original manager
original_manager = get_manager()
# Set our mock manager as the global manager
set_manager(manager)
try:
yield manager
finally:
# Restore the original manager
set_manager(original_manager)
@pytest_asyncio.fixture
async def async_mock_manager_with_scanner_callbacks() -> (
AsyncGenerator[MockBluetoothManagerWithCallbacks, None]
):
"""Provide an async mock BluetoothManager that tracks scanner start callbacks."""
mock_bluetooth_adapters = FakeBluetoothAdapters()
manager = MockBluetoothManagerWithCallbacks(
mock_bluetooth_adapters,
slot_manager=MagicMock(),
)
# Setup the manager
await manager.async_setup()
# Save the original manager
original_manager = get_manager()
# Set our mock manager as the global manager
set_manager(manager)
try:
yield manager
finally:
# Restore the original manager
set_manager(original_manager)
Bluetooth-Devices-habluetooth-21accde/tests/test_advertisement_tracker.py 0000664 0000000 0000000 00000006125 15070247161 0027266 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-21accde/tests/test_base_scanner.py 0000664 0000000 0000000 00000117604 15070247161 0025331 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 .conftest import FakeBluetoothAdapters, MockBluetoothManagerWithCallbacks
class FakeScanner(BaseHaRemoteScanner):
"""Fake scanner."""
def inject_advertisement(
self,
device: BLEDevice,
advertisement_data: AdvertisementData,
now: float | None = None,
) -> None:
"""Inject an advertisement."""
self._async_on_advertisement(
device.address,
advertisement_data.rssi,
device.name,
advertisement_data.service_uuids,
advertisement_data.service_data,
advertisement_data.manufacturer_data,
advertisement_data.tx_power,
{"scanner_specific_data": "test"},
now or monotonic_time_coarse(),
)
def inject_raw_advertisement(
self,
address: str,
rssi: int,
adv: bytes,
now: float | None = None,
) -> None:
"""Inject a raw advertisement."""
self._async_on_raw_advertisement(
address,
rssi,
adv,
{"scanner_specific_data": "test"},
now or monotonic_time_coarse(),
)
@pytest.mark.parametrize("name_2", [None, "w"])
@pytest.mark.usefixtures("enable_bluetooth")
@pytest.mark.asyncio
async def test_remote_scanner(name_2: str | None) -> None:
"""Test the remote scanner base class merges advertisement_data."""
manager = get_manager()
switchbot_device = generate_ble_device(
"44:44:33:11:23:45",
"wohand",
{},
rssi=-100,
)
switchbot_device_adv = generate_advertisement_data(
local_name="wohand",
service_uuids=["050a021a-0000-1000-8000-00805f9b34fb"],
service_data={"050a021a-0000-1000-8000-00805f9b34fb": b"\n\xff"},
manufacturer_data={1: b"\x01"},
rssi=-100,
)
switchbot_device_2 = generate_ble_device(
"44:44:33:11:23:45",
name_2,
{},
rssi=-100,
)
switchbot_device_adv_2 = generate_advertisement_data(
local_name=name_2,
service_uuids=["00000001-0000-1000-8000-00805f9b34fb"],
service_data={"00000001-0000-1000-8000-00805f9b34fb": b"\n\xff"},
manufacturer_data={1: b"\x01", 2: b"\x02"},
rssi=-100,
)
switchbot_device_3 = generate_ble_device(
"44:44:33:11:23:45",
"wohandlonger",
{},
rssi=-100,
)
switchbot_device_adv_3 = generate_advertisement_data(
local_name="wohandlonger",
service_uuids=["00000001-0000-1000-8000-00805f9b34fb"],
service_data={"00000001-0000-1000-8000-00805f9b34fb": b"\n\xff"},
manufacturer_data={1: b"\x01", 2: b"\x02"},
rssi=-100,
)
switchbot_device_adv_4 = generate_advertisement_data(
local_name="wohandlonger",
service_uuids=["00000001-0000-1000-8000-00805f9b34fb"],
service_data={"00000001-0000-1000-8000-00805f9b34fb": b"\n\xff"},
manufacturer_data={1: b"\x04", 2: b"\x02", 3: b"\x03"},
rssi=-100,
)
switchbot_device_adv_5 = generate_advertisement_data(
local_name="wohandlonger",
service_uuids=["00000001-0000-1000-8000-00805f9b34fb"],
service_data={"00000001-0000-1000-8000-00805f9b34fb": b"\n\xff"},
manufacturer_data={1: b"\x04", 2: b"\x01"},
rssi=-100,
)
connector = HaBluetoothConnector(
MockBleakClient, "mock_bleak_client", lambda: False
)
scanner = FakeScanner("esp32", "esp32", connector, True)
details = scanner.details
assert details == HaScannerDetails(
source=scanner.source,
connectable=scanner.connectable,
name=scanner.name,
adapter=scanner.adapter,
scanner_type=HaScannerType.REMOTE,
)
unsetup = scanner.async_setup()
cancel = manager.async_register_scanner(scanner)
scanner.inject_advertisement(switchbot_device, switchbot_device_adv)
data = scanner.discovered_devices_and_advertisement_data
discovered_device, discovered_adv_data = data[switchbot_device.address]
assert discovered_device.address == switchbot_device.address
assert discovered_device.name == switchbot_device.name
assert (
discovered_adv_data.manufacturer_data == switchbot_device_adv.manufacturer_data
)
assert discovered_adv_data.service_data == switchbot_device_adv.service_data
assert discovered_adv_data.service_uuids == switchbot_device_adv.service_uuids
scanner.inject_advertisement(switchbot_device_2, switchbot_device_adv_2)
data = scanner.discovered_devices_and_advertisement_data
discovered_device, discovered_adv_data = data[switchbot_device.address]
assert discovered_device.address == switchbot_device.address
assert discovered_device.name == switchbot_device.name
assert discovered_adv_data.manufacturer_data == {1: b"\x01", 2: b"\x02"}
assert discovered_adv_data.service_data == {
"050a021a-0000-1000-8000-00805f9b34fb": b"\n\xff",
"00000001-0000-1000-8000-00805f9b34fb": b"\n\xff",
}
assert set(discovered_adv_data.service_uuids) == {
"050a021a-0000-1000-8000-00805f9b34fb",
"00000001-0000-1000-8000-00805f9b34fb",
}
# The longer name should be used
scanner.inject_advertisement(switchbot_device_3, switchbot_device_adv_3)
assert discovered_device.name == switchbot_device_3.name
# Inject the shorter name / None again to make
# sure we always keep the longer name
scanner.inject_advertisement(switchbot_device_2, switchbot_device_adv_2)
assert discovered_device.name == switchbot_device_3.name
scanner.inject_advertisement(switchbot_device_2, switchbot_device_adv_4)
assert scanner.discovered_devices_and_advertisement_data[
switchbot_device_2.address
][1].manufacturer_data == {1: b"\x04", 2: b"\x02", 3: b"\x03"}
scanner.inject_advertisement(switchbot_device_2, switchbot_device_adv_5)
assert scanner.discovered_devices_and_advertisement_data[
switchbot_device_2.address
][1].manufacturer_data == {1: b"\x04", 2: b"\x01", 3: b"\x03"}
assert (
"00090401-0052-036b-3206-ff0a050a021a"
not in scanner.discovered_devices_and_advertisement_data[
switchbot_device_2.address
][1].service_data
)
scanner.inject_raw_advertisement(
switchbot_device_2.address,
0,
b"\x12\x21\x1a\x02\n\x05\n\xff\x062k\x03R\x00\x01\x04\t\x00\x04",
)
assert (
"00090401-0052-036b-3206-ff0a050a021a"
in scanner.discovered_devices_and_advertisement_data[
switchbot_device_2.address
][1].service_data
)
assert scanner.serialize_discovered_devices() == DiscoveredDeviceAdvertisementData(
connectable=True,
expire_seconds=195,
discovered_device_advertisement_datas={"44:44:33:11:23:45": ANY},
discovered_device_timestamps={"44:44:33:11:23:45": ANY},
discovered_device_raw={
"44:44:33:11:23:45": b"\x12!\x1a\x02"
b"\n\x05\n\xff"
b"\x062k\x03"
b"R\x00\x01\x04"
b"\t\x00\x04"
},
)
cancel()
unsetup()
@pytest.mark.usefixtures("enable_bluetooth")
@pytest.mark.asyncio
async def test_remote_scanner_expires_connectable() -> None:
"""Test the remote scanner expires stale connectable data."""
manager = get_manager()
switchbot_device = generate_ble_device(
"44:44:33:11:23:45",
"wohand",
{},
rssi=-100,
)
switchbot_device_adv = generate_advertisement_data(
local_name="wohand",
service_uuids=[],
manufacturer_data={1: b"\x01"},
rssi=-100,
)
connector = HaBluetoothConnector(
MockBleakClient, "mock_bleak_client", lambda: False
)
scanner = FakeScanner("esp32", "esp32", connector, True)
unsetup = scanner.async_setup()
cancel = manager.async_register_scanner(scanner)
start_time_monotonic = time.monotonic()
scanner.inject_advertisement(switchbot_device, switchbot_device_adv)
devices = scanner.discovered_devices
assert len(scanner.discovered_devices) == 1
assert len(scanner.discovered_devices_and_advertisement_data) == 1
assert devices[0].name == "wohand"
expire_monotonic = (
start_time_monotonic
+ CONNECTABLE_FALLBACK_MAXIMUM_STALE_ADVERTISEMENT_SECONDS
+ 1
)
expire_utc = utcnow() + timedelta(
seconds=CONNECTABLE_FALLBACK_MAXIMUM_STALE_ADVERTISEMENT_SECONDS + 1
)
with patch_bluetooth_time(expire_monotonic):
async_fire_time_changed(expire_utc)
await asyncio.sleep(0)
devices = scanner.discovered_devices
assert len(scanner.discovered_devices) == 0
assert len(scanner.discovered_devices_and_advertisement_data) == 0
cancel()
unsetup()
@pytest.mark.usefixtures("enable_bluetooth")
@pytest.mark.asyncio
async def test_remote_scanner_expires_non_connectable() -> None:
"""Test the remote scanner expires stale non connectable data."""
manager = get_manager()
switchbot_device = generate_ble_device(
"44:44:33:11:23:45",
"wohand",
{},
rssi=-100,
)
switchbot_device_adv = generate_advertisement_data(
local_name="wohand",
service_uuids=[],
manufacturer_data={1: b"\x01"},
rssi=-100,
)
connector = HaBluetoothConnector(
MockBleakClient, "mock_bleak_client", lambda: False
)
scanner = FakeScanner("esp32", "esp32", connector, True)
unsetup = scanner.async_setup()
cancel = manager.async_register_scanner(scanner)
start_time_monotonic = time.monotonic()
scanner.inject_advertisement(switchbot_device, switchbot_device_adv)
devices = scanner.discovered_devices
assert len(scanner.discovered_devices) == 1
assert len(scanner.discovered_devices_and_advertisement_data) == 1
assert len(scanner.discovered_device_timestamps) == 1
assert len(scanner._discovered_device_timestamps) == 1
dev_adv = scanner.get_discovered_device_advertisement_data(switchbot_device.address)
assert dev_adv is not None
dev, adv = dev_adv
assert dev.name == "wohand"
assert adv.local_name == "wohand"
assert adv.manufacturer_data == switchbot_device_adv.manufacturer_data
assert devices[0].name == "wohand"
assert (
FALLBACK_MAXIMUM_STALE_ADVERTISEMENT_SECONDS
> CONNECTABLE_FALLBACK_MAXIMUM_STALE_ADVERTISEMENT_SECONDS
)
# The connectable timeout is used for all devices
# as the manager takes care of availability and the scanner
# if only concerned about making a connection
expire_monotonic = (
start_time_monotonic
+ CONNECTABLE_FALLBACK_MAXIMUM_STALE_ADVERTISEMENT_SECONDS
+ 1
)
expire_utc = utcnow() + timedelta(
seconds=CONNECTABLE_FALLBACK_MAXIMUM_STALE_ADVERTISEMENT_SECONDS + 1
)
with patch_bluetooth_time(expire_monotonic):
async_fire_time_changed(expire_utc)
await asyncio.sleep(0)
assert len(scanner.discovered_devices) == 0
assert len(scanner.discovered_devices_and_advertisement_data) == 0
assert len(scanner.discovered_device_timestamps) == 0
assert len(scanner._discovered_device_timestamps) == 0
assert (
scanner.get_discovered_device_advertisement_data(switchbot_device.address)
is None
)
expire_monotonic = (
start_time_monotonic + FALLBACK_MAXIMUM_STALE_ADVERTISEMENT_SECONDS + 1
)
expire_utc = utcnow() + timedelta(
seconds=FALLBACK_MAXIMUM_STALE_ADVERTISEMENT_SECONDS + 1
)
with patch_bluetooth_time(expire_monotonic):
async_fire_time_changed(expire_utc)
await asyncio.sleep(0)
assert len(scanner.discovered_devices) == 0
assert len(scanner.discovered_devices_and_advertisement_data) == 0
assert len(scanner.discovered_device_timestamps) == 0
assert len(scanner._discovered_device_timestamps) == 0
assert (
scanner.get_discovered_device_advertisement_data(switchbot_device.address)
is None
)
cancel()
unsetup()
@pytest.mark.usefixtures("enable_bluetooth")
@pytest.mark.asyncio
async def test_base_scanner_connecting_behavior() -> None:
"""Test the default behavior is to mark the scanner as not scanning on connect."""
manager = get_manager()
switchbot_device = generate_ble_device(
"44:44:33:11:23:45",
"wohand",
{},
rssi=-100,
)
switchbot_device_adv = generate_advertisement_data(
local_name="wohand",
service_uuids=[],
manufacturer_data={1: b"\x01"},
rssi=-100,
)
connector = HaBluetoothConnector(
MockBleakClient, "mock_bleak_client", lambda: False
)
scanner = FakeScanner("esp32", "esp32", connector, True)
unsetup = scanner.async_setup()
cancel = manager.async_register_scanner(scanner)
with scanner.connecting():
assert scanner.scanning is False
# We should still accept new advertisements while connecting
# since advertisements are delivered asynchronously and
# we don't want to miss any even when we are willing to
# accept advertisements from another scanner in the brief window
# between when we start connecting and when we stop scanning
scanner.inject_advertisement(switchbot_device, switchbot_device_adv)
devices = scanner.discovered_devices
assert len(scanner.discovered_devices) == 1
assert len(scanner.discovered_devices_and_advertisement_data) == 1
assert devices[0].name == "wohand"
cancel()
unsetup()
@pytest.mark.usefixtures("enable_bluetooth")
@pytest.mark.asyncio
async def test_scanner_stops_responding() -> None:
"""Test we mark a scanner are not scanning when it stops responding."""
manager = get_manager()
connector = HaBluetoothConnector(
MockBleakClient, "mock_bleak_client", lambda: False
)
scanner = FakeScanner(
"esp32",
"esp32",
connector,
True,
current_mode=BluetoothScanningMode.ACTIVE,
requested_mode=BluetoothScanningMode.ACTIVE,
)
unsetup = scanner.async_setup()
cancel = manager.async_register_scanner(scanner)
start_time_monotonic = time.monotonic()
assert scanner.scanning is True
failure_reached_time = (
start_time_monotonic
+ SCANNER_WATCHDOG_TIMEOUT
+ SCANNER_WATCHDOG_INTERVAL.total_seconds()
)
# We hit the timer with no detections,
# so we reset the adapter and restart the scanner
with patch_bluetooth_time(failure_reached_time):
async_fire_time_changed(utcnow() + SCANNER_WATCHDOG_INTERVAL)
await asyncio.sleep(0)
assert scanner.scanning is False
bparasite_device = generate_ble_device( # type: ignore[unreachable]
"44:44:33:11:23:45",
"bparasite",
{},
rssi=-100,
)
bparasite_device_adv = generate_advertisement_data(
local_name="bparasite",
service_uuids=[],
manufacturer_data={1: b"\x01"},
rssi=-100,
)
failure_reached_time += 1
with patch_bluetooth_time(failure_reached_time):
scanner.inject_advertisement(
bparasite_device, bparasite_device_adv, failure_reached_time
)
# As soon as we get a detection, we know the scanner is working again
assert scanner.scanning is True
assert scanner.requested_mode == BluetoothScanningMode.ACTIVE
assert scanner.current_mode == BluetoothScanningMode.ACTIVE
cancel()
unsetup()
@pytest.mark.usefixtures("enable_bluetooth")
@pytest.mark.asyncio
async def test_merge_manufacturer_data_history_existing() -> None:
"""Test merging manufacturer data history."""
manager = get_manager()
sensor_push_device = generate_ble_device(
"44:44:33:11:23:45",
"",
{},
rssi=-60,
)
sensor_push_device_adv = generate_advertisement_data(
local_name="",
rssi=-60,
manufacturer_data={
64256: b"B\r.\xa9\xb6",
31488: b"\x98\xfa\xb6\x91\xb6",
},
service_uuids=["ef090000-11d6-42ba-93b8-9dd7ec090ab0"],
service_data={},
)
sensor_push_adv_2 = generate_advertisement_data(
local_name="",
service_uuids=["ef090000-11d6-42ba-93b8-9dd7ec090ab0"],
service_data={},
manufacturer_data={
31488: b"\x98\xfa\xb6\x91\xb6",
},
rssi=-100,
)
connector = HaBluetoothConnector(
MockBleakClient, "mock_bleak_client", lambda: False
)
scanner = FakeScanner("esp32", "esp32", connector, True)
details = scanner.details
assert details == HaScannerDetails(
source=scanner.source,
connectable=scanner.connectable,
name=scanner.name,
adapter=scanner.adapter,
scanner_type=HaScannerType.REMOTE,
)
unsetup = scanner.async_setup()
cancel = manager.async_register_scanner(scanner)
scanner.inject_advertisement(sensor_push_device, sensor_push_device_adv)
data = scanner.discovered_devices_and_advertisement_data
discovered_device, discovered_adv_data = data[sensor_push_device.address]
assert discovered_device.address == sensor_push_device.address
assert discovered_device.name == sensor_push_device.name
assert (
discovered_adv_data.manufacturer_data
== sensor_push_device_adv.manufacturer_data
)
assert discovered_adv_data.service_data == sensor_push_device_adv.service_data
assert discovered_adv_data.service_uuids == sensor_push_device_adv.service_uuids
scanner.inject_advertisement(sensor_push_device, sensor_push_adv_2)
data = scanner.discovered_devices_and_advertisement_data
discovered_device, discovered_adv_data = data[sensor_push_device.address]
assert discovered_device.address == sensor_push_device.address
assert discovered_device.name == sensor_push_device.name
assert discovered_adv_data.manufacturer_data == {
**sensor_push_device_adv.manufacturer_data,
**sensor_push_adv_2.manufacturer_data,
}
assert discovered_adv_data.service_data == {}
assert set(discovered_adv_data.service_uuids) == {
*sensor_push_device_adv.service_uuids
}
cancel()
unsetup()
@pytest.mark.usefixtures("enable_bluetooth")
@pytest.mark.asyncio
async def test_merge_manufacturer_data_history_new() -> None:
"""Test merging manufacturer data history."""
manager = get_manager()
sensor_push_device = generate_ble_device(
"44:44:33:11:23:45",
"",
{},
rssi=-60,
)
sensor_push_device_adv = generate_advertisement_data(
local_name="",
rssi=-60,
manufacturer_data={
64256: b"B\r.\xa9\xb6",
31488: b"\x98\xfa\xb6\x91\xb6",
},
service_uuids=["ef090000-11d6-42ba-93b8-9dd7ec090ab0"],
service_data={},
)
sensor_push_adv_2 = generate_advertisement_data(
local_name="",
service_uuids=["ef090000-11d6-42ba-93b8-9dd7ec090ab0"],
service_data={},
manufacturer_data={
21248: b"\xb9\xe9\xe1\xb9\xb6",
},
rssi=-100,
)
connector = HaBluetoothConnector(
MockBleakClient, "mock_bleak_client", lambda: False
)
scanner = FakeScanner("esp32", "esp32", connector, True)
details = scanner.details
assert details == HaScannerDetails(
source=scanner.source,
connectable=scanner.connectable,
name=scanner.name,
adapter=scanner.adapter,
scanner_type=HaScannerType.REMOTE,
)
unsetup = scanner.async_setup()
cancel = manager.async_register_scanner(scanner)
scanner.inject_advertisement(sensor_push_device, sensor_push_device_adv)
data = scanner.discovered_devices_and_advertisement_data
discovered_device, discovered_adv_data = data[sensor_push_device.address]
assert discovered_device.address == sensor_push_device.address
assert discovered_device.name == sensor_push_device.name
assert (
discovered_adv_data.manufacturer_data
== sensor_push_device_adv.manufacturer_data
)
assert discovered_adv_data.service_data == sensor_push_device_adv.service_data
assert discovered_adv_data.service_uuids == sensor_push_device_adv.service_uuids
scanner.inject_advertisement(sensor_push_device, sensor_push_adv_2)
data = scanner.discovered_devices_and_advertisement_data
discovered_device, discovered_adv_data = data[sensor_push_device.address]
assert discovered_device.address == sensor_push_device.address
assert discovered_device.name == sensor_push_device.name
assert discovered_adv_data.manufacturer_data == {
**sensor_push_device_adv.manufacturer_data,
**sensor_push_adv_2.manufacturer_data,
}
assert discovered_adv_data.service_data == {}
assert set(discovered_adv_data.service_uuids) == {
*sensor_push_device_adv.service_uuids
}
cancel()
unsetup()
@pytest.mark.usefixtures("enable_bluetooth")
@pytest.mark.asyncio
async def test_filter_apple_data() -> None:
"""Test filtering apple data accepts bytes that start with 01."""
manager = get_manager()
device = generate_ble_device(
"44:44:33:11:23:45",
"",
{},
rssi=-60,
)
device_adv = generate_advertisement_data(
local_name="",
rssi=-60,
manufacturer_data={
76: b"\x01\r.\xa9\xb6",
},
service_uuids=["ef090000-11d6-42ba-93b8-9dd7ec090ab0"],
service_data={},
)
connector = HaBluetoothConnector(
MockBleakClient, "mock_bleak_client", lambda: False
)
scanner = FakeScanner("esp32", "esp32", connector, True)
details = scanner.details
assert details == HaScannerDetails(
source=scanner.source,
connectable=scanner.connectable,
name=scanner.name,
adapter=scanner.adapter,
scanner_type=HaScannerType.REMOTE,
)
unsetup = scanner.async_setup()
cancel = manager.async_register_scanner(scanner)
scanner.inject_advertisement(device, device_adv)
data = scanner.discovered_devices_and_advertisement_data
discovered_device, discovered_adv_data = data[device.address]
assert discovered_device.address == device.address
assert discovered_device.name == device.name
assert discovered_adv_data.manufacturer_data == device_adv.manufacturer_data
unsetup()
cancel()
@pytest.mark.usefixtures("register_hci0_scanner")
def test_connection_history_count_in_progress() -> None:
"""Test connection history in process counting."""
manager = get_manager()
device1_address = "44:44:33:11:23:12"
device2_address = "44:44:33:11:23:13"
hci0_scanner = manager.async_scanner_by_source(HCI0_SOURCE_ADDRESS)
assert hci0_scanner is not None
hci0_scanner._add_connecting(device1_address)
assert hci0_scanner._connections_in_progress() == 1
hci0_scanner._add_connecting(device1_address)
hci0_scanner._add_connecting(device2_address)
assert hci0_scanner._connections_in_progress() == 3
hci0_scanner._finished_connecting(device1_address, True)
assert hci0_scanner._connections_in_progress() == 2
hci0_scanner._finished_connecting(device1_address, False)
assert hci0_scanner._connections_in_progress() == 1
hci0_scanner._finished_connecting(device2_address, False)
assert hci0_scanner._connections_in_progress() == 0
@pytest.mark.usefixtures("register_hci0_scanner")
def test_connection_history_failure_count(caplog: pytest.LogCaptureFixture) -> None:
"""Test connection history failure count."""
manager = get_manager()
device1_address = "44:44:33:11:23:12"
device2_address = "44:44:33:11:23:13"
hci0_scanner = manager.async_scanner_by_source(HCI0_SOURCE_ADDRESS)
assert hci0_scanner is not None
hci0_scanner._add_connecting(device1_address)
hci0_scanner._finished_connecting(device1_address, False)
assert hci0_scanner._connection_failures(device1_address) == 1
hci0_scanner._add_connecting(device1_address)
hci0_scanner._add_connecting(device2_address)
hci0_scanner._finished_connecting(device1_address, False)
assert hci0_scanner._connection_failures(device1_address) == 2
hci0_scanner._finished_connecting(device2_address, False)
assert hci0_scanner._connection_failures(device2_address) == 1
hci0_scanner._add_connecting(device1_address)
hci0_scanner._finished_connecting(device1_address, True)
# On success, we should reset the failure count
assert hci0_scanner._connection_failures(device1_address) == 0
assert "Removing a non-existing connecting" not in caplog.text
hci0_scanner._finished_connecting(device1_address, True)
assert "Removing a non-existing connecting" in caplog.text
@pytest.mark.usefixtures("enable_bluetooth")
@pytest.mark.asyncio
async def test_scanner_mode_changes() -> None:
"""Test scanner mode change methods notify the manager."""
manager = get_manager()
# Track mode changes
mode_changes: list[HaScannerModeChange] = []
def mode_callback(change: HaScannerModeChange) -> None:
"""Track mode changes."""
mode_changes.append(change)
cancel = manager.async_register_scanner_mode_change_callback(mode_callback, None)
# Create a scanner with initial modes
scanner = FakeScanner(
HCI0_SOURCE_ADDRESS,
"hci0",
connectable=True,
requested_mode=BluetoothScanningMode.PASSIVE,
current_mode=BluetoothScanningMode.PASSIVE,
)
# Set up the scanner
unsetup = scanner.async_setup()
# Test changing requested mode
scanner.set_requested_mode(BluetoothScanningMode.ACTIVE)
assert len(mode_changes) == 1
assert mode_changes[0].scanner == scanner
assert mode_changes[0].requested_mode == BluetoothScanningMode.ACTIVE
assert mode_changes[0].current_mode == BluetoothScanningMode.PASSIVE
assert scanner.requested_mode == BluetoothScanningMode.ACTIVE
# Test changing current mode
scanner.set_current_mode(BluetoothScanningMode.ACTIVE)
assert len(mode_changes) == 2
assert mode_changes[1].scanner == scanner
assert mode_changes[1].requested_mode == BluetoothScanningMode.ACTIVE
assert mode_changes[1].current_mode == BluetoothScanningMode.ACTIVE
assert scanner.current_mode == BluetoothScanningMode.ACTIVE
# Test no notification when mode doesn't change
scanner.set_current_mode(BluetoothScanningMode.ACTIVE)
assert len(mode_changes) == 2 # No new notification
# Test setting to None
scanner.set_requested_mode(None)
assert len(mode_changes) == 3
assert mode_changes[2].requested_mode is None
assert scanner.requested_mode is None
scanner.set_current_mode(None) # type: ignore[unreachable]
assert len(mode_changes) == 4
assert mode_changes[3].current_mode is None
assert scanner.current_mode is None
# Clean up
unsetup()
cancel()
def test_remote_scanner_type() -> None:
"""Test that remote scanners have REMOTE type."""
class TestRemoteScanner(BaseHaRemoteScanner):
"""Test remote scanner implementation."""
pass
scanner = TestRemoteScanner("test_source", "test_adapter")
assert scanner.details.scanner_type is HaScannerType.REMOTE
def test_base_scanner_with_connector() -> None:
"""Test BaseHaScanner with connector and adapter type."""
manager = get_manager()
mock_adapters: dict[str, dict[str, Any]] = {
"test_adapter": {
"address": "00:1A:7D:DA:71:04",
"adapter_type": "usb",
}
}
connector = HaBluetoothConnector(
client=MagicMock, source="test_source", can_connect=lambda: True
)
with patch.object(manager, "_adapters", mock_adapters):
scanner = BaseHaScanner(
source="test_source",
adapter="test_adapter",
connector=connector,
connectable=True,
)
assert scanner.details.scanner_type is HaScannerType.USB
class TestScanner(BaseHaScanner):
"""Test scanner without slots for mocking."""
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self._test_allocations = None
def get_allocations(self):
"""Override to return test allocations."""
return self._test_allocations
def test_score_with_no_allocations():
"""Test scoring when no allocation info is available."""
scanner = TestScanner(
source="test_source",
adapter="test_adapter",
connectable=True,
)
ble_device = BLEDevice(
address="00:11:22:33:44:55",
name="Test Device",
details={},
rssi=-50,
)
advertisement = AdvertisementData(
local_name="Test Device",
manufacturer_data={},
service_data={},
service_uuids=[],
rssi=-50,
platform_data=(),
tx_power=None,
)
scanner_device = BluetoothScannerDevice(
scanner=scanner,
ble_device=ble_device,
advertisement=advertisement,
)
# No allocation info available, should just use RSSI
score = scanner._score_connection_paths(10, scanner_device)
assert score == -50
def test_score_with_all_slots_free():
"""Test scoring when all slots are free."""
scanner = TestScanner(
source="test_source",
adapter="test_adapter",
connectable=True,
)
# Set test allocations to return all slots free
scanner._test_allocations = Allocations(
adapter="test_adapter",
slots=5,
free=5,
allocated=[],
)
ble_device = BLEDevice(
address="00:11:22:33:44:55",
name="Test Device",
details={},
rssi=-50,
)
advertisement = AdvertisementData(
local_name="Test Device",
manufacturer_data={},
service_data={},
service_uuids=[],
rssi=-50,
platform_data=(),
tx_power=None,
)
scanner_device = BluetoothScannerDevice(
scanner=scanner,
ble_device=ble_device,
advertisement=advertisement,
)
# All slots free, no penalty
score = scanner._score_connection_paths(10, scanner_device)
assert score == -50
def test_score_with_one_slot_remaining():
"""Test scoring when only one slot remains."""
scanner = TestScanner(
source="test_source",
adapter="test_adapter",
connectable=True,
)
# Set test allocations to return only 1 slot free
scanner._test_allocations = Allocations(
adapter="test_adapter",
slots=5,
free=1,
allocated=[
"AA:BB:CC:DD:EE:01",
"AA:BB:CC:DD:EE:02",
"AA:BB:CC:DD:EE:03",
"AA:BB:CC:DD:EE:04",
],
)
ble_device = BLEDevice(
address="00:11:22:33:44:55",
name="Test Device",
details={},
rssi=-50,
)
advertisement = AdvertisementData(
local_name="Test Device",
manufacturer_data={},
service_data={},
service_uuids=[],
rssi=-50,
platform_data=(),
tx_power=None,
)
scanner_device = BluetoothScannerDevice(
scanner=scanner,
ble_device=ble_device,
advertisement=advertisement,
)
# One slot remaining, small penalty (rssi_diff * 0.76)
score = scanner._score_connection_paths(10, scanner_device)
assert score == -50 - (10 * 0.76)
assert score == -57.6
def test_score_with_no_slots_available():
"""Test scoring when no slots are available."""
scanner = TestScanner(
source="test_source",
adapter="test_adapter",
connectable=True,
)
# Set test allocations to return no slots free
scanner._test_allocations = Allocations(
adapter="test_adapter",
slots=5,
free=0,
allocated=[
"AA:BB:CC:DD:EE:01",
"AA:BB:CC:DD:EE:02",
"AA:BB:CC:DD:EE:03",
"AA:BB:CC:DD:EE:04",
"AA:BB:CC:DD:EE:05",
],
)
ble_device = BLEDevice(
address="00:11:22:33:44:55",
name="Test Device",
details={},
rssi=-50,
)
advertisement = AdvertisementData(
local_name="Test Device",
manufacturer_data={},
service_data={},
service_uuids=[],
rssi=-50,
platform_data=(),
tx_power=None,
)
scanner_device = BluetoothScannerDevice(
scanner=scanner,
ble_device=ble_device,
advertisement=advertisement,
)
# No slots available, returns NO_RSSI_VALUE (-127)
score = scanner._score_connection_paths(10, scanner_device)
assert score == -127
def test_score_comparison_with_different_slot_availability():
"""Test that scanners with more free slots score better."""
# Scanner with all slots free
scanner_all_free = TestScanner(
source="adapter1",
adapter="adapter1",
connectable=True,
)
# Scanner with one slot remaining
scanner_one_slot = TestScanner(
source="adapter2",
adapter="adapter2",
connectable=True,
)
# Scanner with no slots
scanner_no_slots = TestScanner(
source="adapter3",
adapter="adapter3",
connectable=True,
)
ble_device = BLEDevice(
address="00:11:22:33:44:55",
name="Test Device",
details={},
rssi=-50,
)
advertisement = AdvertisementData(
local_name="Test Device",
manufacturer_data={},
service_data={},
service_uuids=[],
rssi=-50,
platform_data=(),
tx_power=None,
)
# Create scanner devices for each scanner
device_all_free = BluetoothScannerDevice(
scanner=scanner_all_free,
ble_device=ble_device,
advertisement=advertisement,
)
device_one_slot = BluetoothScannerDevice(
scanner=scanner_one_slot,
ble_device=ble_device,
advertisement=advertisement,
)
device_no_slots = BluetoothScannerDevice(
scanner=scanner_no_slots,
ble_device=ble_device,
advertisement=advertisement,
)
# Set allocations for each scanner
scanner_all_free._test_allocations = Allocations(
adapter="adapter1",
slots=5,
free=5,
allocated=[],
)
scanner_one_slot._test_allocations = Allocations(
adapter="adapter2",
slots=5,
free=1,
allocated=[
"AA:BB:CC:DD:EE:01",
"AA:BB:CC:DD:EE:02",
"AA:BB:CC:DD:EE:03",
"AA:BB:CC:DD:EE:04",
],
)
scanner_no_slots._test_allocations = Allocations(
adapter="adapter3",
slots=5,
free=0,
allocated=[
"AA:BB:CC:DD:EE:01",
"AA:BB:CC:DD:EE:02",
"AA:BB:CC:DD:EE:03",
"AA:BB:CC:DD:EE:04",
"AA:BB:CC:DD:EE:05",
],
)
score_all_free = scanner_all_free._score_connection_paths(10, device_all_free)
score_one_slot = scanner_one_slot._score_connection_paths(10, device_one_slot)
score_no_slots = scanner_no_slots._score_connection_paths(10, device_no_slots)
# Verify the scoring order: all_free > one_slot > no_slots
assert score_all_free > score_one_slot
assert score_one_slot > score_no_slots
# Verify specific values
assert score_all_free == -50
assert score_one_slot == -57.6
assert score_no_slots == -127 # NO_RSSI_VALUE
def test_score_with_connections_in_progress_and_slots():
"""Test that both connection progress and slot availability are considered."""
scanner = TestScanner(
source="test_source",
adapter="test_adapter",
connectable=True,
)
# Add a connection in progress
scanner._add_connecting("FF:EE:DD:CC:BB:AA")
# Set test allocations to return only 1 slot free
scanner._test_allocations = Allocations(
adapter="test_adapter",
slots=5,
free=1,
allocated=[
"AA:BB:CC:DD:EE:01",
"AA:BB:CC:DD:EE:02",
"AA:BB:CC:DD:EE:03",
"AA:BB:CC:DD:EE:04",
],
)
ble_device = BLEDevice(
address="00:11:22:33:44:55",
name="Test Device",
details={},
rssi=-50,
)
advertisement = AdvertisementData(
local_name="Test Device",
manufacturer_data={},
service_data={},
service_uuids=[],
rssi=-50,
platform_data=(),
tx_power=None,
)
scanner_device = BluetoothScannerDevice(
scanner=scanner,
ble_device=ble_device,
advertisement=advertisement,
)
# Both penalties should apply
score = scanner._score_connection_paths(10, scanner_device)
# -50 (RSSI) - 10.1 (connection in progress) - 7.6 (last slot)
assert score == -50 - 10.1 - 7.6
assert score == -67.7
@pytest.mark.asyncio
async def test_on_scanner_start_callback_remote_scanner(
async_mock_manager_with_scanner_callbacks: MockBluetoothManagerWithCallbacks,
) -> None:
"""Test that on_scanner_start is called when a remote scanner starts."""
manager = async_mock_manager_with_scanner_callbacks
# Create a fake remote scanner
scanner = FakeScanner(
source="esp32_proxy",
adapter="esp32_proxy",
connector=None,
connectable=True,
)
# Simulate scanner start success
scanner._on_start_success()
# Verify the callback was called
assert len(manager.scanner_start_calls) == 1
assert manager.scanner_start_calls[0] is scanner
@pytest.mark.asyncio
async def test_on_scanner_start_multiple_scanners(
async_mock_manager_with_scanner_callbacks: MockBluetoothManagerWithCallbacks,
) -> None:
"""Test that on_scanner_start is called for multiple scanners."""
manager = async_mock_manager_with_scanner_callbacks
# Create multiple scanners
scanner1 = FakeScanner(
source="scanner1",
adapter="scanner1",
connector=None,
connectable=True,
)
scanner2 = FakeScanner(
source="scanner2",
adapter="scanner2",
connector=None,
connectable=True,
)
# Simulate both scanners starting
scanner1._on_start_success()
scanner2._on_start_success()
# Verify both callbacks were called
assert len(manager.scanner_start_calls) == 2
assert scanner1 in manager.scanner_start_calls
assert scanner2 in manager.scanner_start_calls
@pytest.mark.asyncio
async def test_scanner_without_manager() -> None:
"""Test that _on_start_success handles scanner without manager gracefully."""
# Set a temporary manager for scanner creation
mock_bluetooth_adapters = FakeBluetoothAdapters()
temp_manager = BluetoothManager(
mock_bluetooth_adapters,
slot_manager=Mock(),
)
original_manager = get_manager()
set_manager(temp_manager)
try:
# Create a scanner
scanner = FakeScanner(
source="test",
adapter="test",
connector=None,
connectable=True,
)
# Clear the manager to simulate no manager scenario
scanner._manager = None # type: ignore[assignment]
# Should not raise an exception
scanner._on_start_success()
finally:
# Restore original manager
set_manager(original_manager)
Bluetooth-Devices-habluetooth-21accde/tests/test_benchmark_base_scanner.py 0000664 0000000 0000000 00000060601 15070247161 0027335 0 ustar 00root root 0000000 0000000 """Benchmarks for the base scanner."""
from __future__ import annotations
import pytest
from bleak.backends.scanner import AdvertisementData
from bluetooth_data_tools import monotonic_time_coarse
from pytest_codspeed import BenchmarkFixture
from habluetooth import BaseHaRemoteScanner, HaBluetoothConnector, get_manager
from . import (
MockBleakClient,
generate_advertisement_data,
generate_ble_device,
)
@pytest.mark.usefixtures("enable_bluetooth")
@pytest.mark.asyncio
async def test_inject_100_simple_advertisements(benchmark: BenchmarkFixture) -> None:
"""Test injecting 100 simple advertisements."""
manager = get_manager()
switchbot_device = generate_ble_device(
"44:44:33:11:23:45",
"wohand",
{},
rssi=-100,
)
switchbot_device_adv = generate_advertisement_data(
local_name="wohand",
service_uuids=["050a021a-0000-1000-8000-00805f9b34fb"],
service_data={"050a021a-0000-1000-8000-00805f9b34fb": b"\n\xff"},
manufacturer_data={1: b"\x01"},
rssi=-100,
)
connector = HaBluetoothConnector(
MockBleakClient, "mock_bleak_client", lambda: False
)
scanner = BaseHaRemoteScanner("esp32", "esp32", connector, True)
unsetup = scanner.async_setup()
cancel = manager.async_register_scanner(scanner)
_address = switchbot_device.address
_name = switchbot_device.name
_service_uuids = switchbot_device_adv.service_uuids
_service_data = switchbot_device_adv.service_data
_manufacturer_data = switchbot_device_adv.manufacturer_data
_tx_power = switchbot_device_adv.tx_power
_details = {"scanner_specific_data": "test"}
_now = monotonic_time_coarse()
@benchmark
def run():
for _ in range(100):
scanner._async_on_advertisement(
_address,
-100, # rssi
_name,
_service_uuids,
_service_data,
_manufacturer_data,
_tx_power,
_details,
_now,
)
cancel()
unsetup()
@pytest.mark.usefixtures("enable_bluetooth")
@pytest.mark.asyncio
async def test_inject_100_complex_advertisements(benchmark: BenchmarkFixture) -> None:
"""Test injecting 100 complex advertisements."""
manager = get_manager()
switchbot_device = generate_ble_device(
"44:44:33:11:23:45",
"wohand",
{},
rssi=-100,
)
switchbot_device_adv = generate_advertisement_data(
local_name="wohand",
service_uuids=["050a021a-0000-1000-8000-00805f9b34fb"],
service_data={"050a021a-0000-1000-8000-00805f9b34fb": b"\n\xff"},
manufacturer_data=dict.fromkeys(range(100), b"\x01"),
rssi=-100,
)
connector = HaBluetoothConnector(
MockBleakClient, "mock_bleak_client", lambda: False
)
scanner = BaseHaRemoteScanner("esp32", "esp32", connector, True)
unsetup = scanner.async_setup()
cancel = manager.async_register_scanner(scanner)
_address = switchbot_device.address
_name = switchbot_device.name
_service_uuids = switchbot_device_adv.service_uuids
_service_data = switchbot_device_adv.service_data
_manufacturer_data = switchbot_device_adv.manufacturer_data
_tx_power = switchbot_device_adv.tx_power
_details = {"scanner_specific_data": "test"}
_now = monotonic_time_coarse()
@benchmark
def run():
for _ in range(100):
scanner._async_on_advertisement(
_address,
-100, # rssi
_name,
_service_uuids,
_service_data,
_manufacturer_data,
_tx_power,
_details,
_now,
)
cancel()
unsetup()
@pytest.mark.usefixtures("enable_bluetooth")
@pytest.mark.asyncio
async def test_inject_100_different_advertisements(benchmark: BenchmarkFixture) -> None:
"""Test injecting 100 different advertisements."""
manager = get_manager()
switchbot_device = generate_ble_device(
"44:44:33:11:23:45",
"wohand",
{},
rssi=-100,
)
advs: list[AdvertisementData] = []
for i in range(100):
switchbot_device_adv = generate_advertisement_data(
local_name="wohand",
service_uuids=["050a021a-0000-1000-8000-00805f9b34fb"],
service_data={"050a021a-0000-1000-8000-00805f9b34fb": b"\n\xff"},
manufacturer_data={i: b"\x01"},
rssi=-100,
)
advs.append(switchbot_device_adv)
connector = HaBluetoothConnector(
MockBleakClient, "mock_bleak_client", lambda: False
)
scanner = BaseHaRemoteScanner("esp32", "esp32", connector, True)
unsetup = scanner.async_setup()
cancel = manager.async_register_scanner(scanner)
_address = switchbot_device.address
_name = switchbot_device.name
_service_uuids = switchbot_device_adv.service_uuids
_service_data = switchbot_device_adv.service_data
_tx_power = switchbot_device_adv.tx_power
_details = {"scanner_specific_data": "test"}
_now = monotonic_time_coarse()
@benchmark
def run():
for adv in advs:
scanner._async_on_advertisement(
_address,
-100, # rssi
_name,
_service_uuids,
_service_data,
adv.manufacturer_data,
_tx_power,
_details,
_now,
)
cancel()
unsetup()
@pytest.mark.usefixtures("enable_bluetooth")
@pytest.mark.asyncio
async def test_inject_100_different_manufacturer_data(
benchmark: BenchmarkFixture,
) -> None:
"""Test injecting 100 different manufacturer_data."""
manager = get_manager()
switchbot_device = generate_ble_device(
"44:44:33:11:23:45",
"wohand",
{},
rssi=-100,
)
advs: list[AdvertisementData] = []
for i in range(100):
switchbot_device_adv = generate_advertisement_data(
local_name="wohand",
service_uuids=["050a021a-0000-1000-8000-00805f9b34fb"],
service_data={"050a021a-0000-1000-8000-00805f9b34fb": b"\n\xff"},
manufacturer_data={1: b"\x01", 3: bytes((i,) * 20)},
rssi=-100,
)
advs.append(switchbot_device_adv)
connector = HaBluetoothConnector(
MockBleakClient, "mock_bleak_client", lambda: False
)
scanner = BaseHaRemoteScanner("esp32", "esp32", connector, True)
unsetup = scanner.async_setup()
cancel = manager.async_register_scanner(scanner)
_address = switchbot_device.address
_name = switchbot_device.name
_service_uuids = switchbot_device_adv.service_uuids
_service_data = switchbot_device_adv.service_data
_tx_power = switchbot_device_adv.tx_power
_details = {"scanner_specific_data": "test"}
_now = monotonic_time_coarse()
@benchmark
def run():
for adv in advs:
scanner._async_on_advertisement(
_address,
-100, # rssi
_name,
_service_uuids,
_service_data,
adv.manufacturer_data,
_tx_power,
_details,
_now,
)
cancel()
unsetup()
@pytest.mark.usefixtures("enable_bluetooth")
@pytest.mark.asyncio
async def test_inject_100_different_service_data(
benchmark: BenchmarkFixture,
) -> None:
"""Test injecting 100 different service_data."""
manager = get_manager()
switchbot_device = generate_ble_device(
"44:44:33:11:23:45",
"wohand",
{},
rssi=-100,
)
advs: list[AdvertisementData] = []
for i in range(100):
switchbot_device_adv = generate_advertisement_data(
local_name="wohand",
service_uuids=["050a021a-0000-1000-8000-00805f9b34fb"],
service_data={"050a021a-0000-1000-8000-00805f9b34fb": bytes((i,) * 20)},
manufacturer_data={1: b"\x01"},
rssi=-100,
)
advs.append(switchbot_device_adv)
connector = HaBluetoothConnector(
MockBleakClient, "mock_bleak_client", lambda: False
)
scanner = BaseHaRemoteScanner("esp32", "esp32", connector, True)
unsetup = scanner.async_setup()
cancel = manager.async_register_scanner(scanner)
_address = switchbot_device.address
_name = switchbot_device.name
_service_uuids = switchbot_device_adv.service_uuids
_service_data = switchbot_device_adv.service_data
_tx_power = switchbot_device_adv.tx_power
_details = {"scanner_specific_data": "test"}
_now = monotonic_time_coarse()
@benchmark
def run():
for adv in advs:
scanner._async_on_advertisement(
_address,
-100, # rssi
_name,
_service_uuids,
_service_data,
adv.manufacturer_data,
_tx_power,
_details,
_now,
)
cancel()
unsetup()
@pytest.mark.usefixtures("enable_bluetooth")
@pytest.mark.asyncio
async def test_inject_100_rotating_manufacturer_data(
benchmark: BenchmarkFixture,
) -> None:
"""Test injecting 100 different manufacturer_data to mimic a sensor push device."""
manager = get_manager()
sensor_push_device = generate_ble_device(
"44:44:33:11:23:45",
"",
{},
rssi=-60,
)
sensor_push_device_adv = generate_advertisement_data(
local_name="",
rssi=-60,
manufacturer_data={
17667: b"\xad\x00\x01\x00\x00",
1280: b"\xe7\xb4\xe1\xaf\xb6",
2304: b"7\xe1:\xb7\xb6",
55552: b"#\xc7$\xad\xb6",
58624: b";\x01%\x9d\xb6",
44288: b"\xa2|'x\xb6",
64000: b";\xad\xdc\xa7\xb6",
28672: b"\xdb\xe8\\\xa2\xb6",
7168: b"\xb5\xbd\xe0\xaf\xb6",
11264: b"\x00S}\xae\xb6",
4096: b"\xe9\xef\x8e\xba\xb6",
44800: b"\x85\xa2=\xb5\xb6",
32768: b"\x86b\xe9\xc1\xb6",
37376: b"\x8bS<\xc1\xb6",
25344: b"\xb4\xb2\xe7\xbb\xb6",
51200: b"\xae\xdc\xc8\x97\xb6",
49152: b"O\x80O\xc7\xb6",
17664: b"\x0e\xb7q\xa0\xb6",
34816: b"\x9a\xf6\xf8\xc3\xb6",
21760: b"G\xd9\xd6\xa7\xb6",
512: b"\xaa\x14M\xc5\xb6",
41984: b"\xfd\xb4\xd7\xa5\xb6",
16640: b"\x9b\xdd\xd9\xa5\xb6",
33024: b"\x99\xdbB\xb9\xb6",
25088: b"\xee\xec\xea\xbf\xb6",
24576: b"\xc3G\x16\x99\xb6",
50176: b"\x88Q\x9d\xc4\xb6",
57856: b"~\x1a\xb0\x87\xb6",
2816: b"08\xa2\xc4\xb6",
19712: b",\xf1u\x9a\xb6",
26880: b"\x8f\x0f(\xa9\xb6",
54528: b"U\xbe\x1c\x9b\xb6",
7936: b"\x01\x1e\x93\xbc\xb6",
52992: b"R\x19\xb9\x91\xb6",
9472: b"\x0f\xb9\x9a\x87\xb6",
47360: b"A\xe16\xb5\xb6",
14080: b"r\x82S\xc7\xb6",
60416: b"#A\xc5v\xb6",
19968: b"\xf5=\x80\xa0\xb6",
30976: b"\r\x99\x13\x91\xb6",
9216: b'\x08">\xbd\xb6',
16896: b'"\x94L\xc7\xb6',
54784: b"\xae\xce%\x9d\xb6",
21248: b"\xb9\xe9\xe1\xb9\xb6",
40960: b"\x15}\xda\xbb\xb6",
16128: b"s\xe9\xf7\xc5\xb6",
36608: b"\xad\xd6\x8f\xc0\xb6",
1536: b"\x1a\xd1\x8c\xb0\xb6",
30720: b"\xf4`\x93\xb4\xb6",
17920: b"mIi\xae\xb6",
30464: b"\x8c}\x19\x99\xb6",
61952: b"\xb4{\xec\xbd\xb6",
30208: b'\xa8\xac"\x9b\xb6',
27904: b"D\xcb8\xb5\xb6",
45568: b"\xfc\xb5\xdf\xa9\xb6",
12288: b"\xe9\x11\xa7\x8f\xb6",
6400: b"\\\xcf\xe0\xb7\xb6",
10496: b"P_\xe1\xbb\xb6",
52736: b"fv\xd3\xa1\xb6",
37888: b"\xb1\x7f'\xaf\xb6",
6656: b"\x80Wh\x90\xb6",
15872: b"\xd7\x91\xe0\xb7\xb6",
28160: b"P<\xc5\x95\xb6",
37632: b"NN\xc7x\xb6",
11776: b"\x03z0\xab\xb6",
48896: b"B\x9e\xaa\xc8\xb6",
65280: b"w\xb1\xee\xb9\xb6",
56320: b"\xb1\xfa\x1f\x99\xb6",
59136: b"_\xd5\x1c\x97\xb6",
26368: b"\xbe\x82\xbd\x93\xb6",
7424: b"A\xc8\x19\x99\xb6",
49408: b"\xef\xda\x91\xb4\xb6",
24832: b"l\xc03\xbd\xb6",
48128: b"Vs4\xa9\xb6",
48384: b"\xack;\xbb\xb6",
20224: b"\xd8O\xe5\xb9\xb6",
35840: b"Nj\xe1\xbb\xb6",
51712: b"\x96\xba\xcc\x9b\xb6",
23296: b"\xda\\v\x9c\xb6",
39168: b"0j\xe3\xb3\xb6",
29440: b"\xf9\xc9J\xc3\xb6",
54016: b"\xe9\x1c\x88\xa6\xb6",
62208: b"\x1b\x0f\xe3\xbf\xb6",
33280: b"\xc2s\x83\xa2\xb6",
20480: b"\xa9\xc5\xc4\x95\xb6",
50688: b"\xd5O\xe5\xb7\xb6",
19456: b"T }\x9e\xb6",
27136: b"\xd3\n\xda\xbb\xb6",
34304: b"\x10\x164\xb7\xb6",
3328: b'"\xb1\x1c\x99\xb6',
50944: b"it\xbf\x91\xb6",
29952: b"\xd7\xc5\xb8\x93\xb6",
46592: b"\x14-\xbc\x95\xb6",
60928: b"|\xcd\xb8\x8d\xb6",
16384: b"4\x95\xce\x9b\xb6",
23040: b"\x99\xca\x9f\x8d\xb6",
58112: b"P\xcc;\xb7\xb6",
22784: b"\x8a4L\xc5\xb6",
12800: b"el\xe0\xad\xb6",
8960: b"xe\x8e\xb8\xb6",
13568: b"\xec2\x8f\xb8\xb6",
36864: b"\r\xde1\xb5\xb6",
64512: b"\xf7\xf8\x17n\xb6",
39424: b"?\xbc\x87\xa8\xb6",
8448: b"\xfa\x8c\xa6\x8f\xb6",
53760: b"\xf3\x92\xdd\xb3\xb6",
23552: b"A\xb5A\xc3\xb6",
51968: b"\xb6\xc9\xa5^\xb6",
9728: b"\xff\xa1\x7f\xa6\xb6",
18944: b"\xc0\xddI\xc1\xb6",
46848: b"\x05t\xea\xb9\xb6",
33792: b"\xdb\xa8\xd9\xa3\xb6",
6144: b"+\xcb?\xb9\xb6",
10752: b";\x93:\xb9\xb6",
40704: b"\x8e\x85p\x96\xb6",
58368: b"\x91\xf2\xd0\x9d\xb6",
32512: b"\xec\x80\x85\xa4\xb6",
55808: b"-\x98\x80\xb0\xb6",
25856: b"\x90\xd5\x85\xaa\xb6",
58880: b":J\x81\xba\xb6",
31232: b"\x80\xfe\xdd\xa5\xb6",
55040: b"o13\xa9\xb6",
50432: b"t\xe5I\xc3\xb6",
37120: b"\xd3\x05\x89\xa6\xb6",
12544: b"\x06\x00<\xbd\xb6",
59904: b"\xddb\xbe\x93\xb6",
27392: b"OB\x0f\x8f\xb6",
61696: b"\x1d\xe8\x18\x97\xb6",
29696: b"#\xcc\xde\xbd\xb6",
32000: b"X}3\xb5\xb6",
44544: b"\xb8\xa1\x1e\x99\xb6",
7680: b"\xe7Qr\x98\xb6",
45312: b"\xfbI\x10\x8f\xb6",
63488: b"\xc6\xda(\xa5\xb6",
25600: b"O\xda\xe2\xb7\xb6",
24320: b"r\x14n\x98\xb6",
62464: b"\xb0\x87\xf4\xc1\xb6",
63744: b"\x96\xd6\x14\x95\xb6",
21504: b"[\x85\x0f\x93\xb6",
8192: b"\xb7\x84\xd3\xa5\xb6",
29184: b"\xbf\xdfg\x90\xb6",
64768: b"\xa2\x84\xe5\xbf\xb6",
57088: b"9\t\x8a\xb4\xb6",
22272: b"r~(\x9f\xb6",
55296: b".\x03\xc6\x97\xb6",
34560: b"\xb5r\x7f\xa0\xb6",
52224: b"\xe2\xc3\x1c\xa3\xb6",
13824: b"8>\xe6\xb5\xb6",
46080: b"Y\x7f@\xb9\xb6",
34048: b"/_k\x96\xb6",
4608: b"9\x95K\xc3\xb6",
62720: b"K-;\x84\xb6",
44032: b"\xd5\xd0\xa7\xc6\xb6",
35584: b"?}D\xcd\xb6",
43008: b"2\x8f\x8a\xae\xb6",
47104: b"\xa1\xff\xe6\xbb\xb6",
61184: b"\xa3\x7f%\xa5\xb6",
59648: b"\xf1\xb8\x8d\xb4\xb6",
57344: b"\x88\xee2\xb7\xb6",
36096: b"\\\xd5\x9c\xc0\xb6",
38912: b"n\x12_\x90\xb6",
56832: b"$gG\xc3\xb6",
18176: b"\xf9\x96\xfc\xc5\xb6",
18432: b"b\xcdA\xbf\xb6",
57600: b":\x19@\xbf\xb6",
18688: b"$\xe2\xcb\x99\xb6",
38656: b"\x0cA-\xb9\xb6",
48640: b"V\x8c\xda\xab\xb6",
46336: b'"WL]\xb6',
9984: b"\xa8\xab\xa2\xd2\xb6",
42496: b"4\x0b\x1f\x9b\xb6",
41216: b")M%\xab\xb6",
49664: b"M%6\xa9\xb6",
42240: b",\x1e\x86\xb6\xb6",
20992: b"\xab\x052\xbd\xb6",
53504: b"\x8a\xf6\x84\xaa\xb6",
56064: b"\xda\xbf\xa4`\xb6",
53248: b"\x18:\xc8\x99\xb6",
19200: b"\xb1\xb4\x89\xbc\xb6",
38400: b"\xba=\x1f\x99\xb6",
41728: b"\xe4\xa3+\xb1\xb6",
5376: b"z\xd6\x94\xb2\xb6",
47616: b"\x88\x1f\xe3\xb9\xb6",
60672: b"\x9c\x85{\xb4\xb6",
3584: b"\xe7\xdc\xa8\xc6\xb6",
28416: b"\xdc\xddT\x90\xb6",
14336: b"\x87\xa6\xf2\xc5\xb6",
43776: b"9y\x8a\xae\xb6",
39936: b"\xe2\x8cSa\xb6",
5632: b"\xa5_0\xab\xb6",
14592: b"\xbf\xa9\x80\xae\xb6",
63232: b"\xd6A\xc5\x99\xb6",
13312: b"=\xcdL\xc3\xb6",
8704: b"\xf9\xd1'\xa1\xb6",
11008: b"\xdc\xed\xf6\xc5\xb6",
26624: b"\x9b\x81\xc2\x99\xb6",
13056: b"\x88@\xda\xab\xb6",
5888: b"p\xea\x85\xaa\xb6",
12032: b"L\xdb\xe9\xb9\xb6",
3072: b"$\x1e\x83\xac\xb6",
31744: b"\xcb\xe60\xad\xb6",
14848: b"\xee\x9d\xe8\xc9\xb6",
45824: b"Mo\x8e\xb2\xb6",
768: b"\xa6\x8d=\xb5\xb6",
56576: b"\x02\xba\x8d\xb0\xb6",
49920: b"\xa3\xac$\x9d\xb6",
41472: b"\x9dM\xe0\xab\xb6",
65024: b"\x89!\xf2\xbf\xb6",
1024: b"\x89\xbf\x8d\xb4\xb6",
0: b"\xb6\xc5\xc2\x97\xb6",
61440: b"\xad\xa8s\x98\xb6",
17408: b"\xc2\x99?\xbb\xb6",
42752: b"R\xf81\xa9\xb6",
38144: b'\x83\x89"\x9d\xb6',
43520: b"\xb7\xa2'\x9f\xb6",
35328: b";d\xa2\xd0\xb6",
51456: b"\xa4\x85h\x90\xb6",
35072: b"\xfb\x90@\xbf\xb6",
39680: b"\xf5\xcb\x04\xa1\xb6",
4352: b"j\xd0e\x92\xb6",
32256: b"\xcc\x99\xbf\x95\xb6",
3840: b"\xd0\xdd\xc7\x99\xb6",
45056: b"U\xf2\xf0\xc3\xb6",
47872: b'\xdc\x07"\x9b\xb6',
60160: b"0\x8a\xdf\xbb\xb6",
28928: b"\xe7\xa8\xdc\xaf\xb6",
54272: b"c\x15\x85\xb4\xb6",
17152: b"\xc0Q\x7f\xa0\xb6",
5120: b"B@w\x9a\xb6",
43264: b"rC\x85\xaa\xb6",
23808: b"[\xe3=\xb7\xb6",
256: b"\x9c\x9f\x90\xb2\xb6",
6912: b"\xf2\x18\xdc\xab\xb6",
15616: b"\x1b,/\xb5\xb6",
15104: b"{=\xbf\x91\xb6",
4864: b"g?\xe3\xb9\xb6",
36352: b"\x88\xac2\xad\xb6",
22016: b"O\x91\x18\x95\xb6",
52480: b"q\x8dS\xc9\xb6",
62976: b"ZX\x7f\xa2\xb6",
59392: b"\xef\xdc\xa5\xc4\xb6",
15360: b"\xd0\x9aD\xc1\xb6",
10240: b"a\x92\x92\xb0\xb6",
2048: b" /]\x9a\xb6",
20736: b"\x9d\xdek\x94\xb6",
2560: b"\xf5z=\xb3\xb6",
22528: b"j@\xe2\xad\xb6",
26112: b"\x18\x1f\xc5x\xb6",
40448: b"\xdf\xfe=\xbb\xb6",
11520: b"2\xf7<\xbb\xb6",
1792: b"$\n\x1c\x99\xb6",
40192: b"\xaa\x88\xff\xc9\xb6",
27648: b"\x87\xac\xb8\x8d\xb6",
33536: b"{:\x1b\x97\xb6",
64256: b"B\r.\xa9\xb6",
31488: b"\x98\xfa\xb6\x91\xb6",
},
service_uuids=["ef090000-11d6-42ba-93b8-9dd7ec090ab0"],
service_data={},
)
connector = HaBluetoothConnector(
MockBleakClient, "mock_bleak_client", lambda: False
)
scanner = BaseHaRemoteScanner("esp32", "esp32", connector, True)
unsetup = scanner.async_setup()
cancel = manager.async_register_scanner(scanner)
scanner._async_on_advertisement(
sensor_push_device.address,
-100, # rssi
sensor_push_device.name,
sensor_push_device_adv.service_uuids,
sensor_push_device_adv.service_data,
sensor_push_device_adv.manufacturer_data,
sensor_push_device_adv.tx_power,
{"scanner_specific_data": "test"},
monotonic_time_coarse(),
)
advs: list[AdvertisementData] = []
for i in range(100):
sensorpush_device_adv = generate_advertisement_data(
local_name="",
service_uuids=["ef090000-11d6-42ba-93b8-9dd7ec090ab0"],
service_data={},
manufacturer_data={i: bytes((i,) * 20)},
rssi=-(i),
)
advs.append(sensorpush_device_adv)
_address = sensor_push_device.address
_name = sensor_push_device.name
_service_uuids = sensorpush_device_adv.service_uuids
_service_data = sensorpush_device_adv.service_data
_tx_power = sensorpush_device_adv.tx_power
_details = {"scanner_specific_data": "test"}
_now = monotonic_time_coarse()
@benchmark
def run():
for adv in advs:
scanner._async_on_advertisement(
_address,
-100, # rssi
_name,
_service_uuids,
_service_data,
adv.manufacturer_data,
_tx_power,
_details,
_now,
)
cancel()
unsetup()
@pytest.mark.usefixtures("enable_bluetooth")
@pytest.mark.asyncio
async def test_filter_unwanted_apple_advs(benchmark: BenchmarkFixture) -> None:
"""Test filtering unwanted apple data."""
manager = get_manager()
device = generate_ble_device(
"44:44:33:11:23:45",
"beacon",
{},
rssi=-100,
)
device_adv = generate_advertisement_data(
local_name="beacon",
service_uuids=[],
service_data={},
manufacturer_data={76: b"\xff"},
rssi=-100,
)
connector = HaBluetoothConnector(
MockBleakClient, "mock_bleak_client", lambda: False
)
scanner = BaseHaRemoteScanner("esp32", "esp32", connector, True)
unsetup = scanner.async_setup()
cancel = manager.async_register_scanner(scanner)
_address = device.address
_name = device.name
_service_uuids = device_adv.service_uuids
_service_data = device_adv.service_data
_manufacturer_data = device_adv.manufacturer_data
_tx_power = device_adv.tx_power
_details = {"scanner_specific_data": "test"}
_now = monotonic_time_coarse()
@benchmark
def run():
for _ in range(100):
scanner._async_on_advertisement(
_address,
-100, # rssi
_name,
_service_uuids,
_service_data,
_manufacturer_data,
_tx_power,
_details,
_now,
)
cancel()
unsetup()
@pytest.mark.usefixtures("enable_bluetooth")
@pytest.mark.asyncio
async def test_filter_wanted_apple_advs(benchmark: BenchmarkFixture) -> None:
"""Test filtering wanted apple data."""
manager = get_manager()
device = generate_ble_device(
"44:44:33:11:23:45",
"beacon",
{},
rssi=-100,
)
device_adv = generate_advertisement_data(
local_name="beacon",
service_uuids=[],
service_data={},
manufacturer_data={76: b"\x02"},
rssi=-100,
)
connector = HaBluetoothConnector(
MockBleakClient, "mock_bleak_client", lambda: False
)
scanner = BaseHaRemoteScanner("esp32", "esp32", connector, True)
unsetup = scanner.async_setup()
cancel = manager.async_register_scanner(scanner)
_address = device.address
_name = device.name
_service_uuids = device_adv.service_uuids
_service_data = device_adv.service_data
_manufacturer_data = device_adv.manufacturer_data
_tx_power = device_adv.tx_power
_details = {"scanner_specific_data": "test"}
_now = monotonic_time_coarse()
@benchmark
def run():
for _ in range(100):
scanner._async_on_advertisement(
_address,
-100, # rssi
_name,
_service_uuids,
_service_data,
_manufacturer_data,
_tx_power,
_details,
_now,
)
cancel()
unsetup()
Bluetooth-Devices-habluetooth-21accde/tests/test_init.py 0000664 0000000 0000000 00000014722 15070247161 0023646 0 ustar 00root root 0000000 0000000 from unittest.mock import ANY
from bleak.backends.device import BLEDevice
from bleak.backends.scanner import AdvertisementData
from habluetooth import (
BaseHaRemoteScanner,
BaseHaScanner,
BluetoothScanningMode,
HaBluetoothConnector,
HaScanner,
)
class MockBleakClient:
pass
def test_create_scanner():
connector = HaBluetoothConnector(MockBleakClient, "any", lambda: True)
class MockScanner(BaseHaScanner):
pass
@property
def discovered_devices_and_advertisement_data(self):
return []
@property
def discovered_devices(self):
return []
scanner = MockScanner("any", "any", connector)
assert isinstance(scanner, BaseHaScanner)
def test_create_remote_scanner():
connector = HaBluetoothConnector(MockBleakClient, "any", lambda: True)
scanner = BaseHaRemoteScanner("any", "any", connector, True)
assert isinstance(scanner, BaseHaRemoteScanner)
def test__async_on_advertisement():
connector = HaBluetoothConnector(MockBleakClient, "any", lambda: True)
scanner = BaseHaRemoteScanner("any", "any", connector, True)
details = scanner._details | {}
scanner._async_on_advertisement(
"AA:BB:CC:DD:EE:FF",
-88,
"name",
["service_uuid"],
{"service_uuid": b"\x00\x01\x02\x03\x04\x05\x06\x07\x08\x09\x0a\x0b"},
{32: b"\x00\x01\x02\x03\x04\x05\x06\x07\x08\x09\x0a\x0b"},
-88,
details,
1.0,
)
scanner._async_on_advertisement(
"AA:BB:CC:DD:EE:FF",
-21,
"name",
["service_uuid2"],
{"service_uuid2": b"\x00\x01\x02\x03\x04\x05\x06\x07\x08\x09\x0a\x0b"},
{21: b"\x00\x01\x02\x03\x04\x05\x06\x07\x08\x09\x0a\x0b"},
-88,
details,
1.0,
)
ble_device = BLEDevice(
"AA:BB:CC:DD:EE:FF",
"name",
details,
)
first_device = scanner.discovered_devices[0]
assert first_device.address == ble_device.address
assert first_device.details == ble_device.details
assert first_device.name == ble_device.name
assert "AA:BB:CC:DD:EE:FF" in scanner.discovered_devices_and_advertisement_data
adv = scanner.discovered_devices_and_advertisement_data["AA:BB:CC:DD:EE:FF"][1]
assert set(adv.service_data) == {"service_uuid", "service_uuid2"}
assert adv == AdvertisementData(
local_name="name",
manufacturer_data={
32: b"\x00\x01\x02\x03\x04\x05\x06\x07\x08\t\n\x0b",
21: b"\x00\x01\x02\x03\x04\x05\x06\x07\x08\t\n\x0b",
},
service_data={
"service_uuid": b"\x00\x01\x02\x03\x04\x05\x06\x07\x08\t\n\x0b",
"service_uuid2": b"\x00\x01\x02\x03\x04\x05\x06\x07\x08\t\n\x0b",
},
service_uuids=ANY,
tx_power=-88,
rssi=-21,
platform_data=(),
)
assert len(scanner.discovered_devices) == 1
assert scanner.discovered_devices[0].address == "AA:BB:CC:DD:EE:FF"
assert len(scanner.discovered_devices_and_advertisement_data) == 1
# BLEDevice no longer has rssi attribute in bleak 1.0+
# rssi is only available in AdvertisementData
assert (
scanner.discovered_devices_and_advertisement_data["AA:BB:CC:DD:EE:FF"][1].rssi
== -21
)
assert "AA:BB:CC:DD:EE:FF" in scanner.discovered_addresses
device_adv = scanner.get_discovered_device_advertisement_data("AA:BB:CC:DD:EE:FF")
assert device_adv is not None
assert device_adv[1] == adv
def test__async_on_advertisement_first():
connector = HaBluetoothConnector(MockBleakClient, "any", lambda: True)
scanner = BaseHaRemoteScanner("any", "any", connector, True)
details = scanner._details | {}
scanner._async_on_advertisement(
"AA:BB:CC:DD:EE:FF",
-88,
"name",
["service_uuid"],
{"service_uuid": b"\x00\x01\x02\x03\x04\x05\x06\x07\x08\x09\x0a\x0b"},
{32: b"\x00\x01\x02\x03\x04\x05\x06\x07\x08\x09\x0a\x0b"},
-88,
details,
1.0,
)
device_adv = scanner.get_discovered_device_advertisement_data("AA:BB:CC:DD:EE:FF")
assert device_adv is not None
device, adv = device_adv
assert device is not None
assert adv is not None
assert device.address == "AA:BB:CC:DD:EE:FF"
assert adv.rssi == -88
assert adv.service_uuids == ["service_uuid"]
assert adv.service_data == {
"service_uuid": b"\x00\x01\x02\x03\x04\x05\x06\x07\x08\t\n\x0b"
}
assert adv.manufacturer_data == {
32: b"\x00\x01\x02\x03\x04\x05\x06\x07\x08\t\n\x0b"
}
assert adv.service_uuids == ANY
assert adv.tx_power == -88
assert adv.rssi == -88
assert adv.platform_data == ()
assert device.name == "name"
assert device.details == details
def test__async_on_advertisement_prefers_longest_local_name():
connector = HaBluetoothConnector(MockBleakClient, "any", lambda: True)
scanner = BaseHaRemoteScanner("any", "any", connector, True)
details = scanner._details | {}
scanner._async_on_advertisement(
"AA:BB:CC:DD:EE:FF",
-88,
"shortname",
[],
{},
{},
-88,
details,
1.0,
)
device_adv = scanner.get_discovered_device_advertisement_data("AA:BB:CC:DD:EE:FF")
assert device_adv is not None
device, adv = device_adv
assert device is not None
assert adv is not None
assert device.name == "shortname"
assert adv.local_name == "shortname"
scanner._async_on_advertisement(
"AA:BB:CC:DD:EE:FF",
-88,
"tinyname",
[],
{},
{},
-88,
details,
1.0,
)
device_adv = scanner.get_discovered_device_advertisement_data("AA:BB:CC:DD:EE:FF")
assert device_adv is not None
device, adv = device_adv
assert device is not None
assert adv is not None
assert device.name == "shortname"
assert adv.local_name == "shortname"
scanner._async_on_advertisement(
"AA:BB:CC:DD:EE:FF",
-88,
"longername",
[],
{},
{},
-88,
details,
1.0,
)
device_adv = scanner.get_discovered_device_advertisement_data("AA:BB:CC:DD:EE:FF")
assert device_adv is not None
device, adv = device_adv
assert device is not None
assert adv is not None
assert device.name == "longername"
assert adv.local_name == "longername"
def test_create_ha_scanner():
scanner = HaScanner(BluetoothScanningMode.ACTIVE, "hci0", "AA:BB:CC:DD:EE:FF")
assert isinstance(scanner, HaScanner)
Bluetooth-Devices-habluetooth-21accde/tests/test_manager.py 0000664 0000000 0000000 00000131706 15070247161 0024317 0 ustar 00root root 0000000 0000000 """Tests for the manager."""
import asyncio
import time
from datetime import timedelta
from typing import Any
from unittest.mock import ANY, AsyncMock, Mock, patch
import pytest
from bleak_retry_connector import AllocationChange, Allocations, BleakSlotManager
from bluetooth_adapters.systems.linux import LinuxAdapters
from freezegun import freeze_time
from habluetooth import (
FALLBACK_MAXIMUM_STALE_ADVERTISEMENT_SECONDS,
TRACKER_BUFFERING_WOBBLE_SECONDS,
UNAVAILABLE_TRACK_SECONDS,
BluetoothManager,
BluetoothScanningMode,
BluetoothServiceInfoBleak,
HaBluetoothSlotAllocations,
HaScannerModeChange,
HaScannerRegistration,
HaScannerRegistrationEvent,
get_manager,
set_manager,
)
from . import (
HCI0_SOURCE_ADDRESS,
HCI1_SOURCE_ADDRESS,
async_fire_time_changed,
generate_advertisement_data,
generate_ble_device,
inject_advertisement_with_source,
inject_advertisement_with_time_and_source,
inject_advertisement_with_time_and_source_connectable,
patch_bluetooth_time,
utcnow,
)
from .conftest import FakeBluetoothAdapters, FakeScanner
SOURCE_LOCAL = "local"
@pytest.mark.asyncio
@pytest.mark.skipif("platform.system() == 'Windows'")
async def test_async_recover_failed_adapters() -> None:
"""Return the BluetoothManager instance."""
attempt = 0
class MockLinuxAdapters(LinuxAdapters):
@property
def adapters(self) -> dict[str, Any]:
nonlocal attempt
attempt += 1
if attempt == 1:
return {
"hci0": {
"address": "00:00:00:00:00:01",
"hw_version": "usb:v1D6Bp0246d053F",
"passive_scan": False,
"sw_version": "homeassistant",
"manufacturer": "ACME",
"product": "Bluetooth Adapter 5.0",
"product_id": "aa01",
"vendor_id": "cc01",
},
"hci1": {
"address": "00:00:00:00:00:00",
"hw_version": "usb:v1D6Bp0246d053F",
"passive_scan": False,
"sw_version": "homeassistant",
"manufacturer": "ACME",
"product": "Bluetooth Adapter 5.0",
"product_id": "aa01",
"vendor_id": "cc01",
},
"hci2": {
"address": "00:00:00:00:00:00",
"hw_version": "usb:v1D6Bp0246d053F",
"passive_scan": False,
"sw_version": "homeassistant",
"manufacturer": "ACME",
"product": "Bluetooth Adapter 5.0",
"product_id": "aa01",
"vendor_id": "cc01",
},
}
return {
"hci0": {
"address": "00:00:00:00:00:01",
"hw_version": "usb:v1D6Bp0246d053F",
"passive_scan": False,
"sw_version": "homeassistant",
"manufacturer": "ACME",
"product": "Bluetooth Adapter 5.0",
"product_id": "aa01",
"vendor_id": "cc01",
},
"hci1": {
"address": "00:00:00:00:00:02",
"hw_version": "usb:v1D6Bp0246d053F",
"passive_scan": False,
"sw_version": "homeassistant",
"manufacturer": "ACME",
"product": "Bluetooth Adapter 5.0",
"product_id": "aa01",
"vendor_id": "cc01",
},
"hci2": {
"address": "00:00:00:00:00:03",
"hw_version": "usb:v1D6Bp0246d053F",
"passive_scan": False,
"sw_version": "homeassistant",
"manufacturer": "ACME",
"product": "Bluetooth Adapter 5.0",
"product_id": "aa01",
"vendor_id": "cc01",
},
}
with (
patch("habluetooth.manager.async_reset_adapter") as mock_async_reset_adapter,
):
adapters = MockLinuxAdapters()
slot_manager = BleakSlotManager()
manager = BluetoothManager(adapters, slot_manager)
await manager.async_setup()
set_manager(manager)
adapter = await manager.async_get_adapter_from_address_or_recover(
"00:00:00:00:00:03"
)
assert adapter == "hci2"
adapter = await manager.async_get_adapter_from_address_or_recover(
"00:00:00:00:00:02"
)
assert adapter == "hci1"
adapter = await manager.async_get_adapter_from_address_or_recover(
"00:00:00:00:00:01"
)
assert adapter == "hci0"
assert mock_async_reset_adapter.call_count == 2
assert mock_async_reset_adapter.call_args_list == [
(("hci1", "00:00:00:00:00:00", False),),
(("hci2", "00:00:00:00:00:00", False),),
]
@pytest.mark.asyncio
async def test_create_manager() -> None:
"""Return the BluetoothManager instance."""
adapters = FakeBluetoothAdapters()
slot_manager = BleakSlotManager()
manager = BluetoothManager(adapters, slot_manager)
set_manager(manager)
assert manager
@pytest.mark.asyncio
@pytest.mark.usefixtures("enable_bluetooth")
async def test_async_register_disappeared_callback(
register_hci0_scanner: None,
register_hci1_scanner: None,
) -> None:
"""Test bluetooth async_register_disappeared_callback handles failures."""
manager = get_manager()
assert manager._loop is not None
address = "44:44:33:11:23:12"
switchbot_device_signal_100 = generate_ble_device(
address, "wohand_signal_100", rssi=-100
)
switchbot_adv_signal_100 = generate_advertisement_data(
local_name="wohand_signal_100", service_uuids=[]
)
inject_advertisement_with_source(
switchbot_device_signal_100, switchbot_adv_signal_100, "hci0"
)
failed_disappeared: list[str] = []
def _failing_callback(_address: str) -> None:
"""Failing callback."""
failed_disappeared.append(_address)
raise ValueError("This is a test")
ok_disappeared: list[str] = []
def _ok_callback(_address: str) -> None:
"""Ok callback."""
ok_disappeared.append(_address)
cancel1 = manager.async_register_disappeared_callback(_failing_callback)
# Make sure the second callback still works if the first one fails and
# raises an exception
cancel2 = manager.async_register_disappeared_callback(_ok_callback)
switchbot_adv_signal_100 = generate_advertisement_data(
local_name="wohand_signal_100",
manufacturer_data={123: b"abc"},
service_uuids=[],
rssi=-80,
)
inject_advertisement_with_source(
switchbot_device_signal_100, switchbot_adv_signal_100, "hci1"
)
future_time = utcnow() + timedelta(seconds=3600)
future_monotonic_time = time.monotonic() + 3600
with (
freeze_time(future_time),
patch(
"habluetooth.manager.monotonic_time_coarse",
return_value=future_monotonic_time,
),
):
manager._async_check_unavailable()
async_fire_time_changed(future_time)
assert len(ok_disappeared) == 1
assert ok_disappeared[0] == address
assert len(failed_disappeared) == 1
assert failed_disappeared[0] == address
cancel1()
cancel2()
@pytest.mark.asyncio
@pytest.mark.usefixtures("enable_bluetooth")
async def test_async_register_allocation_callback(
register_hci0_scanner: None,
register_hci1_scanner: None,
) -> None:
"""Test bluetooth async_register_allocation_callback handles failures."""
manager = get_manager()
assert manager._loop is not None
address = "44:44:33:11:23:12"
switchbot_device_signal_100 = generate_ble_device(
address, "wohand_signal_100", rssi=-100
)
switchbot_adv_signal_100 = generate_advertisement_data(
local_name="wohand_signal_100", service_uuids=[]
)
inject_advertisement_with_source(
switchbot_device_signal_100, switchbot_adv_signal_100, "hci0"
)
failed_allocations: list[HaBluetoothSlotAllocations] = []
def _failing_callback(allocations: HaBluetoothSlotAllocations) -> None:
"""Failing callback."""
failed_allocations.append(allocations)
raise ValueError("This is a test")
ok_allocations: list[HaBluetoothSlotAllocations] = []
def _ok_callback(allocations: HaBluetoothSlotAllocations) -> None:
"""Ok callback."""
ok_allocations.append(allocations)
cancel1 = manager.async_register_allocation_callback(_failing_callback)
# Make sure the second callback still works if the first one fails and
# raises an exception
cancel2 = manager.async_register_allocation_callback(_ok_callback)
switchbot_adv_signal_100 = generate_advertisement_data(
local_name="wohand_signal_100",
manufacturer_data={123: b"abc"},
service_uuids=[],
rssi=-80,
)
inject_advertisement_with_source(
switchbot_device_signal_100, switchbot_adv_signal_100, "hci1"
)
assert manager.async_current_allocations() == [
HaBluetoothSlotAllocations(
source="AA:BB:CC:DD:EE:00", slots=5, free=5, allocated=[]
),
HaBluetoothSlotAllocations(
source="AA:BB:CC:DD:EE:11", slots=5, free=5, allocated=[]
),
]
manager.async_on_allocation_changed(
Allocations(
"AA:BB:CC:DD:EE:00",
5,
4,
["44:44:33:11:23:12"],
)
)
assert len(ok_allocations) == 1
assert ok_allocations[0] == HaBluetoothSlotAllocations(
"AA:BB:CC:DD:EE:00",
5,
4,
["44:44:33:11:23:12"],
)
assert len(failed_allocations) == 1
assert failed_allocations[0] == HaBluetoothSlotAllocations(
"AA:BB:CC:DD:EE:00",
5,
4,
["44:44:33:11:23:12"],
)
with patch.object(
manager.slot_manager,
"get_allocations",
return_value=Allocations(
adapter="hci0",
slots=5,
free=4,
allocated=["44:44:33:11:23:12"],
),
):
manager.slot_manager._call_callbacks(
AllocationChange.ALLOCATED, "/org/bluez/hci0/dev_44_44_33_11_23_12"
)
assert len(ok_allocations) == 2
assert manager.async_current_allocations() == [
HaBluetoothSlotAllocations("AA:BB:CC:DD:EE:00", 5, 4, ["44:44:33:11:23:12"]),
HaBluetoothSlotAllocations(
source="AA:BB:CC:DD:EE:11", slots=5, free=5, allocated=[]
),
]
assert manager.async_current_allocations("AA:BB:CC:DD:EE:00") == [
HaBluetoothSlotAllocations("AA:BB:CC:DD:EE:00", 5, 4, ["44:44:33:11:23:12"]),
]
cancel1()
cancel2()
@pytest.mark.asyncio
@pytest.mark.usefixtures("enable_bluetooth")
async def test_async_register_allocation_callback_non_connectable(
register_non_connectable_scanner: None,
) -> None:
"""Test async_current_allocations for a non-connectable scanner."""
manager = get_manager()
assert manager._loop is not None
assert manager.async_current_allocations() == [
HaBluetoothSlotAllocations(
source="AA:BB:CC:DD:EE:FF",
slots=0,
free=0,
allocated=[],
),
]
@pytest.mark.asyncio
@pytest.mark.usefixtures("enable_bluetooth")
async def test_async_register_scanner_registration_callback(
register_hci0_scanner: None,
register_hci1_scanner: None,
) -> None:
"""Test bluetooth async_register_scanner_registration_callback handles failures."""
manager = get_manager()
assert manager._loop is not None
scanners = manager.async_current_scanners()
assert len(scanners) == 2
sources = {scanner.source for scanner in scanners}
assert sources == {"AA:BB:CC:DD:EE:00", "AA:BB:CC:DD:EE:11"}
failed_scanner_callbacks: list[HaScannerRegistration] = []
def _failing_callback(scanner_registration: HaScannerRegistration) -> None:
"""Failing callback."""
failed_scanner_callbacks.append(scanner_registration)
raise ValueError("This is a test")
ok_scanner_callbacks: list[HaScannerRegistration] = []
def _ok_callback(scanner_registration: HaScannerRegistration) -> None:
"""Ok callback."""
ok_scanner_callbacks.append(scanner_registration)
cancel1 = manager.async_register_scanner_registration_callback(
_failing_callback, None
)
# Make sure the second callback still works if the first one fails and
# raises an exception
cancel2 = manager.async_register_scanner_registration_callback(_ok_callback, None)
hci3_scanner = FakeScanner("AA:BB:CC:DD:EE:33", "hci3")
hci3_scanner.connectable = True
manager = get_manager()
cancel = manager.async_register_scanner(hci3_scanner, connection_slots=5)
assert len(ok_scanner_callbacks) == 1
assert ok_scanner_callbacks[0] == HaScannerRegistration(
HaScannerRegistrationEvent.ADDED, hci3_scanner
)
assert len(failed_scanner_callbacks) == 1
cancel()
assert len(ok_scanner_callbacks) == 2
assert ok_scanner_callbacks[1] == HaScannerRegistration(
HaScannerRegistrationEvent.REMOVED, hci3_scanner
)
cancel1()
cancel2()
@pytest.mark.asyncio
@pytest.mark.usefixtures("enable_bluetooth")
async def test_async_register_scanner_mode_change_callback(
register_hci0_scanner: None,
register_hci1_scanner: None,
) -> None:
"""Test bluetooth async_register_scanner_mode_change_callback handles failures."""
manager = get_manager()
assert manager._loop is not None
scanners = manager.async_current_scanners()
assert len(scanners) == 2
scanner = scanners[0]
failed_mode_callbacks: list[HaScannerModeChange] = []
def _failing_callback(mode_change: HaScannerModeChange) -> None:
"""Failing callback."""
failed_mode_callbacks.append(mode_change)
raise ValueError("This is a test")
ok_mode_callbacks: list[HaScannerModeChange] = []
def _ok_callback(mode_change: HaScannerModeChange) -> None:
"""Ok callback."""
ok_mode_callbacks.append(mode_change)
cancel1 = manager.async_register_scanner_mode_change_callback(
_failing_callback, None
)
# Make sure the second callback still works if the first one fails and
# raises an exception
cancel2 = manager.async_register_scanner_mode_change_callback(_ok_callback, None)
# Test specific source callback
source_specific_callbacks: list[HaScannerModeChange] = []
def _source_specific_callback(mode_change: HaScannerModeChange) -> None:
"""Source specific callback."""
source_specific_callbacks.append(mode_change)
cancel3 = manager.async_register_scanner_mode_change_callback(
_source_specific_callback, scanner.source
)
# Change requested mode
scanner.set_requested_mode(BluetoothScanningMode.ACTIVE)
assert len(ok_mode_callbacks) == 1
assert ok_mode_callbacks[0].scanner == scanner
assert ok_mode_callbacks[0].requested_mode == BluetoothScanningMode.ACTIVE
assert ok_mode_callbacks[0].current_mode == scanner.current_mode
assert len(failed_mode_callbacks) == 1
assert len(source_specific_callbacks) == 1
# Change current mode
scanner.set_current_mode(BluetoothScanningMode.ACTIVE)
assert len(ok_mode_callbacks) == 2
assert ok_mode_callbacks[1].scanner == scanner
assert ok_mode_callbacks[1].current_mode == BluetoothScanningMode.ACTIVE
assert len(failed_mode_callbacks) == 2
assert len(source_specific_callbacks) == 2
# No change when setting the same mode
scanner.set_current_mode(BluetoothScanningMode.ACTIVE)
assert len(ok_mode_callbacks) == 2
cancel1()
cancel2()
cancel3()
@pytest.mark.asyncio
async def test_async_register_scanner_with_connection_slots() -> None:
"""Test registering a scanner with connection slots."""
manager = get_manager()
assert manager._loop is not None
scanners = manager.async_current_scanners()
assert len(scanners) == 0
hci3_scanner = FakeScanner("AA:BB:CC:DD:EE:33", "hci3")
hci3_scanner.connectable = True
manager = get_manager()
cancel = manager.async_register_scanner(hci3_scanner, connection_slots=5)
assert manager.async_current_allocations(hci3_scanner.source) == [
HaBluetoothSlotAllocations(hci3_scanner.source, 5, 5, [])
]
cancel()
@pytest.mark.asyncio
@pytest.mark.usefixtures("enable_bluetooth")
async def test_diagnostics(register_hci0_scanner: None) -> None:
"""Test bluetooth diagnostics."""
manager = get_manager()
assert manager._loop is not None
manager.async_on_allocation_changed(
Allocations(
"AA:BB:CC:DD:EE:00",
5,
4,
["44:44:33:11:23:12"],
)
)
diagnostics = await manager.async_diagnostics()
assert diagnostics == {
"adapters": {},
"advertisement_tracker": ANY,
"all_history": ANY,
"allocations": {
"AA:BB:CC:DD:EE:00": {
"allocated": ["44:44:33:11:23:12"],
"free": 4,
"slots": 5,
"source": "AA:BB:CC:DD:EE:00",
}
},
"connectable_history": ANY,
"scanners": [
{
"discovered_devices_and_advertisement_data": [],
"connectable": True,
"current_mode": None,
"requested_mode": None,
"last_detection": 0.0,
"monotonic_time": ANY,
"name": "hci0 (AA:BB:CC:DD:EE:00)",
"scanning": True,
"source": "AA:BB:CC:DD:EE:00",
"start_time": 0.0,
"type": "FakeScanner",
}
],
"slot_manager": {
"adapter_slots": {"hci0": 5},
"allocations_by_adapter": {"hci0": []},
"manager": False,
},
}
@pytest.mark.usefixtures("enable_bluetooth")
@pytest.mark.asyncio
async def test_advertisements_do_not_switch_adapters_for_no_reason(
register_hci0_scanner: None,
register_hci1_scanner: None,
) -> None:
"""Test we only switch adapters when needed."""
address = "44:44:33:11:23:12"
switchbot_device_signal_100 = generate_ble_device(
address, "wohand_signal_100", rssi=-100
)
switchbot_adv_signal_100 = generate_advertisement_data(
local_name="wohand_signal_100", service_uuids=[]
)
inject_advertisement_with_source(
switchbot_device_signal_100, switchbot_adv_signal_100, HCI0_SOURCE_ADDRESS
)
assert (
get_manager().async_ble_device_from_address(address, True)
is switchbot_device_signal_100
)
switchbot_device_signal_99 = generate_ble_device(
address, "wohand_signal_99", rssi=-99
)
switchbot_adv_signal_99 = generate_advertisement_data(
local_name="wohand_signal_99", service_uuids=[]
)
inject_advertisement_with_source(
switchbot_device_signal_99, switchbot_adv_signal_99, HCI0_SOURCE_ADDRESS
)
assert (
get_manager().async_ble_device_from_address(address, True)
is switchbot_device_signal_99
)
switchbot_device_signal_98 = generate_ble_device(
address, "wohand_good_signal", rssi=-98
)
switchbot_adv_signal_98 = generate_advertisement_data(
local_name="wohand_good_signal", service_uuids=[]
)
inject_advertisement_with_source(
switchbot_device_signal_98, switchbot_adv_signal_98, HCI1_SOURCE_ADDRESS
)
# should not switch to hci1
assert (
get_manager().async_ble_device_from_address(address, True)
is switchbot_device_signal_99
)
@pytest.mark.usefixtures("enable_bluetooth")
@pytest.mark.asyncio
async def test_switching_adapters_based_on_rssi(
register_hci0_scanner: None,
register_hci1_scanner: None,
) -> None:
"""Test switching adapters based on rssi."""
address = "44:44:33:11:23:45"
switchbot_device_poor_signal = generate_ble_device(address, "wohand_poor_signal")
switchbot_adv_poor_signal = generate_advertisement_data(
local_name="wohand_poor_signal", service_uuids=[], rssi=-100
)
inject_advertisement_with_source(
switchbot_device_poor_signal,
switchbot_adv_poor_signal,
HCI0_SOURCE_ADDRESS,
)
assert (
get_manager().async_ble_device_from_address(address, True)
is switchbot_device_poor_signal
)
switchbot_device_good_signal = generate_ble_device(address, "wohand_good_signal")
switchbot_adv_good_signal = generate_advertisement_data(
local_name="wohand_good_signal", service_uuids=[], rssi=-60
)
inject_advertisement_with_source(
switchbot_device_good_signal,
switchbot_adv_good_signal,
HCI1_SOURCE_ADDRESS,
)
assert (
get_manager().async_ble_device_from_address(address, True)
is switchbot_device_good_signal
)
inject_advertisement_with_source(
switchbot_device_good_signal,
switchbot_adv_poor_signal,
HCI0_SOURCE_ADDRESS,
)
assert (
get_manager().async_ble_device_from_address(address, True)
is switchbot_device_good_signal
)
# We should not switch adapters unless the signal hits the threshold
switchbot_device_similar_signal = generate_ble_device(
address, "wohand_similar_signal"
)
switchbot_adv_similar_signal = generate_advertisement_data(
local_name="wohand_similar_signal", service_uuids=[], rssi=-62
)
inject_advertisement_with_source(
switchbot_device_similar_signal,
switchbot_adv_similar_signal,
HCI0_SOURCE_ADDRESS,
)
assert (
get_manager().async_ble_device_from_address(address, True)
is switchbot_device_good_signal
)
@pytest.mark.usefixtures("enable_bluetooth")
@pytest.mark.asyncio
async def test_switching_adapters_based_on_zero_rssi(
register_hci0_scanner: None,
register_hci1_scanner: None,
) -> None:
"""Test switching adapters based on zero rssi."""
address = "44:44:33:11:23:45"
switchbot_device_no_rssi = generate_ble_device(address, "wohand_poor_signal")
switchbot_adv_no_rssi = generate_advertisement_data(
local_name="wohand_no_rssi", service_uuids=[], rssi=0
)
inject_advertisement_with_source(
switchbot_device_no_rssi, switchbot_adv_no_rssi, HCI0_SOURCE_ADDRESS
)
assert (
get_manager().async_ble_device_from_address(address, True)
is switchbot_device_no_rssi
)
switchbot_device_good_signal = generate_ble_device(address, "wohand_good_signal")
switchbot_adv_good_signal = generate_advertisement_data(
local_name="wohand_good_signal", service_uuids=[], rssi=-60
)
inject_advertisement_with_source(
switchbot_device_good_signal,
switchbot_adv_good_signal,
HCI1_SOURCE_ADDRESS,
)
assert (
get_manager().async_ble_device_from_address(address, True)
is switchbot_device_good_signal
)
inject_advertisement_with_source(
switchbot_device_good_signal, switchbot_adv_no_rssi, HCI0_SOURCE_ADDRESS
)
assert (
get_manager().async_ble_device_from_address(address, True)
is switchbot_device_good_signal
)
# We should not switch adapters unless the signal hits the threshold
switchbot_device_similar_signal = generate_ble_device(
address, "wohand_similar_signal"
)
switchbot_adv_similar_signal = generate_advertisement_data(
local_name="wohand_similar_signal", service_uuids=[], rssi=-62
)
inject_advertisement_with_source(
switchbot_device_similar_signal,
switchbot_adv_similar_signal,
HCI0_SOURCE_ADDRESS,
)
assert (
get_manager().async_ble_device_from_address(address, True)
is switchbot_device_good_signal
)
@pytest.mark.usefixtures("enable_bluetooth")
@pytest.mark.asyncio
async def test_switching_adapters_based_on_stale(
register_hci0_scanner: None,
register_hci1_scanner: None,
) -> None:
"""Test switching adapters based on the previous advertisement being stale."""
address = "44:44:33:11:23:41"
start_time_monotonic = 50.0
switchbot_device_poor_signal_hci0 = generate_ble_device(
address, "wohand_poor_signal_hci0"
)
switchbot_adv_poor_signal_hci0 = generate_advertisement_data(
local_name="wohand_poor_signal_hci0", service_uuids=[], rssi=-100
)
inject_advertisement_with_time_and_source(
switchbot_device_poor_signal_hci0,
switchbot_adv_poor_signal_hci0,
start_time_monotonic,
HCI0_SOURCE_ADDRESS,
)
assert (
get_manager().async_ble_device_from_address(address, True)
is switchbot_device_poor_signal_hci0
)
switchbot_device_poor_signal_hci1 = generate_ble_device(
address, "wohand_poor_signal_hci1"
)
switchbot_adv_poor_signal_hci1 = generate_advertisement_data(
local_name="wohand_poor_signal_hci1", service_uuids=[], rssi=-99
)
inject_advertisement_with_time_and_source(
switchbot_device_poor_signal_hci1,
switchbot_adv_poor_signal_hci1,
start_time_monotonic,
HCI1_SOURCE_ADDRESS,
)
# Should not switch adapters until the advertisement is stale
assert (
get_manager().async_ble_device_from_address(address, True)
is switchbot_device_poor_signal_hci0
)
# Should switch to hci1 since the previous advertisement is stale
# even though the signal is poor because the device is now
# likely unreachable via hci0
inject_advertisement_with_time_and_source(
switchbot_device_poor_signal_hci1,
switchbot_adv_poor_signal_hci1,
start_time_monotonic + FALLBACK_MAXIMUM_STALE_ADVERTISEMENT_SECONDS + 1,
"hci1",
)
assert (
get_manager().async_ble_device_from_address(address, True)
is switchbot_device_poor_signal_hci1
)
@pytest.mark.usefixtures("enable_bluetooth")
@pytest.mark.asyncio
async def test_switching_adapters_based_on_stale_with_discovered_interval(
register_hci0_scanner: None,
register_hci1_scanner: None,
) -> None:
"""Test switching with discovered interval."""
address = "44:44:33:11:23:41"
start_time_monotonic = 50.0
switchbot_device_poor_signal_hci0 = generate_ble_device(
address, "wohand_poor_signal_hci0"
)
switchbot_adv_poor_signal_hci0 = generate_advertisement_data(
local_name="wohand_poor_signal_hci0", service_uuids=[], rssi=-100
)
inject_advertisement_with_time_and_source(
switchbot_device_poor_signal_hci0,
switchbot_adv_poor_signal_hci0,
start_time_monotonic,
HCI0_SOURCE_ADDRESS,
)
assert (
get_manager().async_ble_device_from_address(address, True)
is switchbot_device_poor_signal_hci0
)
get_manager().async_set_fallback_availability_interval(address, 10)
switchbot_device_poor_signal_hci1 = generate_ble_device(
address, "wohand_poor_signal_hci1"
)
switchbot_adv_poor_signal_hci1 = generate_advertisement_data(
local_name="wohand_poor_signal_hci1", service_uuids=[], rssi=-99
)
inject_advertisement_with_time_and_source(
switchbot_device_poor_signal_hci1,
switchbot_adv_poor_signal_hci1,
start_time_monotonic,
HCI1_SOURCE_ADDRESS,
)
# Should not switch adapters until the advertisement is stale
assert (
get_manager().async_ble_device_from_address(address, True)
is switchbot_device_poor_signal_hci0
)
inject_advertisement_with_time_and_source(
switchbot_device_poor_signal_hci1,
switchbot_adv_poor_signal_hci1,
start_time_monotonic + 10 + 1,
HCI1_SOURCE_ADDRESS,
)
# Should not switch yet since we are not within the
# wobble period
assert (
get_manager().async_ble_device_from_address(address, True)
is switchbot_device_poor_signal_hci0
)
inject_advertisement_with_time_and_source(
switchbot_device_poor_signal_hci1,
switchbot_adv_poor_signal_hci1,
start_time_monotonic + 10 + TRACKER_BUFFERING_WOBBLE_SECONDS + 1,
HCI1_SOURCE_ADDRESS,
)
# Should switch to hci1 since the previous advertisement is stale
# even though the signal is poor because the device is now
# likely unreachable via hci0
assert (
get_manager().async_ble_device_from_address(address, True)
is switchbot_device_poor_signal_hci1
)
@pytest.mark.usefixtures("enable_bluetooth")
@pytest.mark.asyncio
async def test_switching_adapters_based_on_rssi_connectable_to_non_connectable(
register_hci0_scanner: None,
register_hci1_scanner: None,
) -> None:
"""Test switching adapters based on rssi from connectable to non connectable."""
address = "44:44:33:11:23:45"
now = time.monotonic()
switchbot_device_poor_signal = generate_ble_device(address, "wohand_poor_signal")
switchbot_adv_poor_signal = generate_advertisement_data(
local_name="wohand_poor_signal", service_uuids=[], rssi=-100
)
inject_advertisement_with_time_and_source_connectable(
switchbot_device_poor_signal,
switchbot_adv_poor_signal,
now,
HCI0_SOURCE_ADDRESS,
True,
)
assert (
get_manager().async_ble_device_from_address(address, False)
is switchbot_device_poor_signal
)
assert (
get_manager().async_ble_device_from_address(address, True)
is switchbot_device_poor_signal
)
switchbot_device_good_signal = generate_ble_device(address, "wohand_good_signal")
switchbot_adv_good_signal = generate_advertisement_data(
local_name="wohand_good_signal", service_uuids=[], rssi=-60
)
inject_advertisement_with_time_and_source_connectable(
switchbot_device_good_signal,
switchbot_adv_good_signal,
now,
"hci1",
False,
)
assert (
get_manager().async_ble_device_from_address(address, False)
is switchbot_device_good_signal
)
assert (
get_manager().async_ble_device_from_address(address, True)
is switchbot_device_poor_signal
)
inject_advertisement_with_time_and_source_connectable(
switchbot_device_good_signal,
switchbot_adv_poor_signal,
now,
"hci0",
False,
)
assert (
get_manager().async_ble_device_from_address(address, False)
is switchbot_device_good_signal
)
assert (
get_manager().async_ble_device_from_address(address, True)
is switchbot_device_poor_signal
)
switchbot_device_excellent_signal = generate_ble_device(
address, "wohand_excellent_signal"
)
switchbot_adv_excellent_signal = generate_advertisement_data(
local_name="wohand_excellent_signal", service_uuids=[], rssi=-25
)
inject_advertisement_with_time_and_source_connectable(
switchbot_device_excellent_signal,
switchbot_adv_excellent_signal,
now,
"hci2",
False,
)
assert (
get_manager().async_ble_device_from_address(address, False)
is switchbot_device_excellent_signal
)
assert (
get_manager().async_ble_device_from_address(address, True)
is switchbot_device_poor_signal
)
@pytest.mark.usefixtures("enable_bluetooth")
@pytest.mark.asyncio
async def test_connectable_advertisement_can_be_retrieved_best_path_is_non_connectable(
register_hci0_scanner: None,
register_hci1_scanner: None,
) -> None:
"""
Test we can still get a connectable BLEDevice when the best path is non-connectable.
In this case the device is closer to a non-connectable scanner, but the
at least one connectable scanner has the device in range.
"""
address = "44:44:33:11:23:45"
now = time.monotonic()
switchbot_device_good_signal = generate_ble_device(address, "wohand_good_signal")
switchbot_adv_good_signal = generate_advertisement_data(
local_name="wohand_good_signal", service_uuids=[], rssi=-60
)
inject_advertisement_with_time_and_source_connectable(
switchbot_device_good_signal,
switchbot_adv_good_signal,
now,
HCI1_SOURCE_ADDRESS,
False,
)
assert (
get_manager().async_ble_device_from_address(address, False)
is switchbot_device_good_signal
)
assert get_manager().async_ble_device_from_address(address, True) is None
switchbot_device_poor_signal = generate_ble_device(address, "wohand_poor_signal")
switchbot_adv_poor_signal = generate_advertisement_data(
local_name="wohand_poor_signal", service_uuids=[], rssi=-100
)
inject_advertisement_with_time_and_source_connectable(
switchbot_device_poor_signal,
switchbot_adv_poor_signal,
now,
HCI0_SOURCE_ADDRESS,
True,
)
assert (
get_manager().async_ble_device_from_address(address, False)
is switchbot_device_good_signal
)
assert (
get_manager().async_ble_device_from_address(address, True)
is switchbot_device_poor_signal
)
@pytest.mark.usefixtures("enable_bluetooth")
@pytest.mark.asyncio
async def test_switching_adapters_when_one_goes_away(
register_hci0_scanner: None,
) -> None:
"""Test switching adapters when one goes away."""
cancel_hci2 = get_manager().async_register_scanner(FakeScanner("hci2", "hci2"))
address = "44:44:33:11:23:45"
switchbot_device_good_signal = generate_ble_device(address, "wohand_good_signal")
switchbot_adv_good_signal = generate_advertisement_data(
local_name="wohand_good_signal", service_uuids=[], rssi=-60
)
inject_advertisement_with_source(
switchbot_device_good_signal, switchbot_adv_good_signal, "hci2"
)
assert (
get_manager().async_ble_device_from_address(address, True)
is switchbot_device_good_signal
)
switchbot_device_poor_signal = generate_ble_device(address, "wohand_poor_signal")
switchbot_adv_poor_signal = generate_advertisement_data(
local_name="wohand_poor_signal", service_uuids=[], rssi=-100
)
inject_advertisement_with_source(
switchbot_device_poor_signal,
switchbot_adv_poor_signal,
HCI0_SOURCE_ADDRESS,
)
# We want to prefer the good signal when we have options
assert (
get_manager().async_ble_device_from_address(address, True)
is switchbot_device_good_signal
)
cancel_hci2()
inject_advertisement_with_source(
switchbot_device_poor_signal,
switchbot_adv_poor_signal,
HCI0_SOURCE_ADDRESS,
)
# Now that hci2 is gone, we should prefer the poor signal
# since no poor signal is better than no signal
assert (
get_manager().async_ble_device_from_address(address, True)
is switchbot_device_poor_signal
)
@pytest.mark.usefixtures("enable_bluetooth")
@pytest.mark.asyncio
async def test_switching_adapters_when_one_stop_scanning(
register_hci0_scanner: None,
) -> None:
"""Test switching adapters when stops scanning."""
hci2_scanner = FakeScanner("hci2", "hci2")
cancel_hci2 = get_manager().async_register_scanner(hci2_scanner)
address = "44:44:33:11:23:45"
switchbot_device_good_signal = generate_ble_device(address, "wohand_good_signal")
switchbot_adv_good_signal = generate_advertisement_data(
local_name="wohand_good_signal", service_uuids=[], rssi=-60
)
inject_advertisement_with_source(
switchbot_device_good_signal, switchbot_adv_good_signal, "hci2"
)
assert (
get_manager().async_ble_device_from_address(address, True)
is switchbot_device_good_signal
)
switchbot_device_poor_signal = generate_ble_device(address, "wohand_poor_signal")
switchbot_adv_poor_signal = generate_advertisement_data(
local_name="wohand_poor_signal", service_uuids=[], rssi=-100
)
inject_advertisement_with_source(
switchbot_device_poor_signal,
switchbot_adv_poor_signal,
HCI0_SOURCE_ADDRESS,
)
# We want to prefer the good signal when we have options
assert (
get_manager().async_ble_device_from_address(address, True)
is switchbot_device_good_signal
)
hci2_scanner.scanning = False
inject_advertisement_with_source(
switchbot_device_poor_signal,
switchbot_adv_poor_signal,
HCI0_SOURCE_ADDRESS,
)
# Now that hci2 has stopped scanning, we should prefer the poor signal
# since poor signal is better than no signal
assert (
get_manager().async_ble_device_from_address(address, True)
is switchbot_device_poor_signal
)
cancel_hci2()
@pytest.mark.usefixtures("enable_bluetooth", "macos_adapter")
@pytest.mark.asyncio
async def test_set_fallback_interval_small() -> None:
"""Test we can set the fallback advertisement interval."""
assert (
get_manager().async_get_fallback_availability_interval("44:44:33:11:23:12")
is None
)
get_manager().async_set_fallback_availability_interval("44:44:33:11:23:12", 2.0)
assert (
get_manager().async_get_fallback_availability_interval("44:44:33:11:23:12")
== 2.0
)
start_monotonic_time = time.monotonic()
switchbot_device = generate_ble_device("44:44:33:11:23:12", "wohand")
switchbot_adv = generate_advertisement_data(
local_name="wohand", service_uuids=["cba20d00-224d-11e6-9fb8-0002a5d5c51b"]
)
switchbot_device_went_unavailable = False
inject_advertisement_with_time_and_source(
switchbot_device,
switchbot_adv,
start_monotonic_time,
SOURCE_LOCAL,
)
def _switchbot_device_unavailable_callback(
_address: BluetoothServiceInfoBleak,
) -> None:
"""Switchbot device unavailable callback."""
nonlocal switchbot_device_went_unavailable
switchbot_device_went_unavailable = True
assert (
get_manager().async_get_learned_advertising_interval("44:44:33:11:23:12")
is None
)
switchbot_device_unavailable_cancel = get_manager().async_track_unavailable(
_switchbot_device_unavailable_callback,
switchbot_device.address,
connectable=False,
)
monotonic_now = start_monotonic_time + 2
with patch_bluetooth_time(
monotonic_now + UNAVAILABLE_TRACK_SECONDS,
):
async_fire_time_changed(utcnow() + timedelta(seconds=UNAVAILABLE_TRACK_SECONDS))
await asyncio.sleep(0)
assert switchbot_device_went_unavailable is True
switchbot_device_unavailable_cancel()
# We should forget fallback interval after it expires
assert (
get_manager().async_get_fallback_availability_interval("44:44:33:11:23:12")
is None
)
@pytest.mark.usefixtures("enable_bluetooth", "macos_adapter")
@pytest.mark.asyncio
async def test_set_fallback_interval_big() -> None:
"""Test we can set the fallback advertisement interval."""
assert (
get_manager().async_get_fallback_availability_interval("44:44:33:11:23:12")
is None
)
# Force the interval to be really big and check it doesn't expire using the default
# timeout (900)
get_manager().async_set_fallback_availability_interval(
"44:44:33:11:23:12", 604800.0
)
assert (
get_manager().async_get_fallback_availability_interval("44:44:33:11:23:12")
== 604800.0
)
start_monotonic_time = time.monotonic()
switchbot_device = generate_ble_device("44:44:33:11:23:12", "wohand")
switchbot_adv = generate_advertisement_data(
local_name="wohand", service_uuids=["cba20d00-224d-11e6-9fb8-0002a5d5c51b"]
)
switchbot_device_went_unavailable = False
inject_advertisement_with_time_and_source(
switchbot_device,
switchbot_adv,
start_monotonic_time,
SOURCE_LOCAL,
)
def _switchbot_device_unavailable_callback(
_address: BluetoothServiceInfoBleak,
) -> None:
"""Switchbot device unavailable callback."""
nonlocal switchbot_device_went_unavailable
switchbot_device_went_unavailable = True
assert (
get_manager().async_get_learned_advertising_interval("44:44:33:11:23:12")
is None
)
switchbot_device_unavailable_cancel = get_manager().async_track_unavailable(
_switchbot_device_unavailable_callback,
switchbot_device.address,
connectable=False,
)
# Check that device hasn't expired after a day
monotonic_now = start_monotonic_time + 86400
with patch_bluetooth_time(
monotonic_now + UNAVAILABLE_TRACK_SECONDS,
):
async_fire_time_changed(utcnow() + timedelta(seconds=UNAVAILABLE_TRACK_SECONDS))
await asyncio.sleep(0)
assert switchbot_device_went_unavailable is False
# Try again after it has expired
monotonic_now = start_monotonic_time + 604800
with patch_bluetooth_time(
monotonic_now + UNAVAILABLE_TRACK_SECONDS,
):
async_fire_time_changed(utcnow() + timedelta(seconds=UNAVAILABLE_TRACK_SECONDS))
await asyncio.sleep(0)
assert switchbot_device_went_unavailable is True
switchbot_device_unavailable_cancel() # type: ignore[unreachable]
# We should forget fallback interval after it expires
assert (
get_manager().async_get_fallback_availability_interval("44:44:33:11:23:12")
is None
)
@pytest.mark.asyncio
async def test_subclassing_bluetooth_manager(caplog: pytest.LogCaptureFixture) -> None:
"""Test subclassing BluetoothManager."""
slot_manager = BleakSlotManager()
bluetooth_adapters = FakeBluetoothAdapters()
class TestBluetoothManager(BluetoothManager):
"""
Test class for BluetoothManager.
This class implements _discover_service_info.
"""
def _discover_service_info(
self, service_info: BluetoothServiceInfoBleak
) -> None:
"""
Discover a new service info.
This method is intended to be overridden by subclasses.
"""
TestBluetoothManager(bluetooth_adapters, slot_manager)
assert "does not implement _discover_service_info" not in caplog.text
class TestBluetoothManager2(BluetoothManager):
"""
Test class for BluetoothManager.
This class does not implement _discover_service_info.
"""
TestBluetoothManager2(bluetooth_adapters, slot_manager)
assert "does not implement _discover_service_info" in caplog.text
@pytest.mark.asyncio
async def test_is_operating_degraded_on_linux_with_mgmt() -> None:
"""Test is_operating_degraded returns False on Linux with mgmt control."""
mock_bluetooth_adapters = FakeBluetoothAdapters()
manager = BluetoothManager(
mock_bluetooth_adapters,
slot_manager=Mock(),
)
with (
patch("habluetooth.manager.IS_LINUX", True),
patch.object(manager, "_mgmt_ctl", Mock()),
):
# Mock mgmt_ctl being available
assert manager.is_operating_degraded() is False
@pytest.mark.asyncio
async def test_is_operating_degraded_on_linux_without_mgmt() -> None:
"""Test is_operating_degraded returns True on Linux without mgmt control."""
mock_bluetooth_adapters = FakeBluetoothAdapters()
manager = BluetoothManager(
mock_bluetooth_adapters,
slot_manager=Mock(),
)
with patch("habluetooth.manager.IS_LINUX", True):
# mgmt_ctl is None by default
assert manager._mgmt_ctl is None
assert manager.is_operating_degraded() is True
@pytest.mark.asyncio
async def test_is_operating_degraded_on_non_linux() -> None:
"""Test is_operating_degraded returns False on non-Linux systems."""
mock_bluetooth_adapters = FakeBluetoothAdapters()
manager = BluetoothManager(
mock_bluetooth_adapters,
slot_manager=Mock(),
)
with patch("habluetooth.manager.IS_LINUX", False):
# Should return False regardless of mgmt_ctl state
assert manager.is_operating_degraded() is False
# Even with mgmt_ctl set
manager._mgmt_ctl = Mock()
assert manager.is_operating_degraded() is False
@pytest.mark.asyncio
async def test_is_operating_degraded_after_permission_error() -> None:
"""Test is_operating_degraded after mgmt setup fails with permission error."""
mock_bluetooth_adapters = FakeBluetoothAdapters()
manager = BluetoothManager(
mock_bluetooth_adapters,
slot_manager=Mock(),
)
with (
patch("habluetooth.manager.IS_LINUX", True),
patch("habluetooth.manager.MGMTBluetoothCtl") as mock_mgmt_class,
):
# Make setup fail with permission error
mock_mgmt_instance = Mock()
mock_mgmt_instance.setup = AsyncMock(
side_effect=PermissionError("No permission")
)
mock_mgmt_class.return_value = mock_mgmt_instance
# Setup should handle the error and set mgmt_ctl to None
await manager.async_setup()
# Should be in degraded mode
assert manager._mgmt_ctl is None
assert manager.is_operating_degraded() is True
Bluetooth-Devices-habluetooth-21accde/tests/test_models.py 0000664 0000000 0000000 00000023005 15070247161 0024160 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():
class pyobjc_str(str):
pass
class pyobjc_int(int):
pass
name = pyobjc_str("wohand")
address = pyobjc_str("44:44:33:11:23:45")
rssi = pyobjc_int(-127)
assert name == "wohand"
assert address == "44:44:33:11:23:45"
assert rssi == -127
switchbot_device = generate_ble_device(address, name, {})
switchbot_adv = generate_advertisement_data(
local_name=name, service_uuids=["cba20d00-224d-11e6-9fb8-0002a5d5c51b"]
)
now = time.monotonic()
service_info = BluetoothServiceInfoBleak(
name=str(name),
address=str(address),
rssi=rssi,
manufacturer_data=switchbot_adv.manufacturer_data,
service_data=switchbot_adv.service_data,
service_uuids=switchbot_adv.service_uuids,
source=SOURCE_LOCAL,
device=switchbot_device,
advertisement=switchbot_adv,
connectable=False,
time=now,
tx_power=1,
)
assert service_info.service_uuids == ["cba20d00-224d-11e6-9fb8-0002a5d5c51b"]
assert service_info.name == "wohand"
assert service_info.source == SOURCE_LOCAL
assert service_info.manufacturer is None
assert service_info.manufacturer_id is None
assert service_info.time == now
assert service_info.connectable is False
safe_as_dict = service_info.as_dict()
assert safe_as_dict == {
"address": "44:44:33:11:23:45",
"advertisement": switchbot_adv,
"device": switchbot_device,
"connectable": False,
"manufacturer_data": {},
"name": "wohand",
"raw": None,
"rssi": -127,
"service_data": {},
"service_uuids": ["cba20d00-224d-11e6-9fb8-0002a5d5c51b"],
"source": "local",
"time": now,
"tx_power": 1,
}
def test_as_connectable():
switchbot_device = generate_ble_device("44:44:33:11:23:45", "wohand", {})
switchbot_adv = generate_advertisement_data(
local_name="wohand", service_uuids=["cba20d00-224d-11e6-9fb8-0002a5d5c51b"]
)
now = time.monotonic()
service_info = BluetoothServiceInfoBleak(
name="wohand",
address="44:44:33:11:23:45",
rssi=-127,
manufacturer_data=switchbot_adv.manufacturer_data,
service_data=switchbot_adv.service_data,
service_uuids=switchbot_adv.service_uuids,
source=SOURCE_LOCAL,
device=switchbot_device,
advertisement=switchbot_adv,
connectable=False,
time=now,
tx_power=1,
raw=b"\x00\x00\x00\x00\x00\x00",
)
connectable_service_info = service_info._as_connectable()
assert connectable_service_info.connectable is True
assert service_info.connectable is False
assert connectable_service_info is not service_info
assert service_info.name == connectable_service_info.name
assert service_info.address == connectable_service_info.address
assert service_info.rssi == connectable_service_info.rssi
assert service_info.manufacturer_data == connectable_service_info.manufacturer_data
assert service_info.service_data == connectable_service_info.service_data
assert service_info.service_uuids == connectable_service_info.service_uuids
assert service_info.source == connectable_service_info.source
assert service_info.device == connectable_service_info.device
assert service_info.advertisement == connectable_service_info.advertisement
assert service_info.time == connectable_service_info.time
assert service_info.tx_power == connectable_service_info.tx_power
assert service_info.raw == connectable_service_info.raw
Bluetooth-Devices-habluetooth-21accde/tests/test_scanner.py 0000664 0000000 0000000 00000153715 15070247161 0024342 0 ustar 00root root 0000000 0000000 """Tests for the Bluetooth integration scanners."""
import asyncio
import platform
import time
from datetime import timedelta
from typing import Any
from unittest.mock import ANY, AsyncMock, MagicMock, Mock, patch
import pytest
from bleak import BleakError
from bleak.backends.scanner import AdvertisementDataCallback
from bleak_retry_connector import BleakSlotManager
from habluetooth import (
SCANNER_WATCHDOG_INTERVAL,
SCANNER_WATCHDOG_TIMEOUT,
BluetoothManager,
BluetoothScanningMode,
BluetoothServiceInfoBleak,
HaScanner,
HaScannerType,
ScannerStartError,
get_manager,
scanner,
set_manager,
)
from habluetooth.channels.bluez import (
BluetoothMGMTProtocol,
MGMTBluetoothCtl,
)
from habluetooth.scanner import (
InvalidMessageError,
bytes_mac_to_str,
make_bluez_details,
)
from . import (
async_fire_time_changed,
generate_advertisement_data,
generate_ble_device,
patch_bluetooth_time,
utcnow,
)
from .conftest import FakeBluetoothAdapters, MockBluetoothManagerWithCallbacks
DEVICE_FOUND = 0x0012
ADV_MONITOR_DEVICE_FOUND = 0x002F
IS_WINDOWS = 'os.name == "nt"'
IS_POSIX = 'os.name == "posix"'
NOT_POSIX = 'os.name != "posix"'
# or_patterns is a workaround for the fact that passive scanning
# needs at least one matcher to be set. The below matcher
# will match all devices.
if platform.system() == "Linux":
# On Linux, use the real BlueZScannerArgs to avoid mocking issues
from bleak.args.bluez import BlueZScannerArgs, OrPattern
from bleak.assigned_numbers import AdvertisementDataType
scanner.PASSIVE_SCANNER_ARGS = BlueZScannerArgs(
or_patterns=[
OrPattern(0, AdvertisementDataType.FLAGS, b"\x02"),
OrPattern(0, AdvertisementDataType.FLAGS, b"\x06"),
OrPattern(0, AdvertisementDataType.FLAGS, b"\x1a"),
]
)
else:
# On other platforms, we can use a simple mock
scanner.PASSIVE_SCANNER_ARGS = Mock()
# If the adapter is in a stuck state the following errors are raised:
NEED_RESET_ERRORS = [
"org.bluez.Error.Failed",
"org.bluez.Error.InProgress",
"org.bluez.Error.NotReady",
"not found",
]
@pytest.fixture(autouse=True, scope="module")
def disable_stop_discovery():
"""Disable stop discovery."""
with patch("habluetooth.scanner.stop_discovery"):
yield
@pytest.fixture(autouse=True, scope="module")
def manager():
"""Return the BluetoothManager instance."""
adapters = FakeBluetoothAdapters()
slot_manager = BleakSlotManager()
manager = BluetoothManager(adapters, slot_manager)
set_manager(manager)
return manager
@pytest.fixture
def mock_btmgmt_socket():
"""Mock the btmgmt_socket module."""
with patch("habluetooth.channels.bluez.btmgmt_socket") as mock_btmgmt:
mock_socket = Mock()
# Make the socket look like a real socket with a file descriptor
mock_socket.fileno.return_value = 99
mock_btmgmt.open.return_value = mock_socket
yield mock_btmgmt
def test_bytes_mac_to_str() -> None:
"""Test bytes_mac_to_str."""
assert bytes_mac_to_str(b"\xff\xee\xdd\xcc\xbb\xaa") == "AA:BB:CC:DD:EE:FF"
assert bytes_mac_to_str(b"\xff\xee\xdd\xcc\xbb\xaa") == "AA:BB:CC:DD:EE:FF"
def test_make_bluez_details() -> None:
"""Test make_bluez_details."""
assert make_bluez_details("AA:BB:CC:DD:EE:FF", "hci0") == {
"path": "/org/bluez/hci0/dev_AA_BB_CC_DD_EE_FF",
"props": {"Adapter": "/org/bluez/hci0"},
}
@pytest.mark.asyncio
async def test_empty_data_no_scanner() -> None:
"""Test we handle empty data."""
scanner = HaScanner(BluetoothScanningMode.ACTIVE, "hci0", "AA:BB:CC:DD:EE:FF")
scanner.async_setup()
assert scanner.discovered_devices == []
assert scanner.discovered_devices_and_advertisement_data == {}
@pytest.mark.asyncio
@pytest.mark.skipif(NOT_POSIX)
async def test_dbus_socket_missing_in_container(
caplog: pytest.LogCaptureFixture,
) -> None:
"""Test we handle dbus being missing in the container."""
with (
patch("habluetooth.scanner.is_docker_env", return_value=True),
patch(
"habluetooth.scanner.OriginalBleakScanner.start",
side_effect=FileNotFoundError,
),
patch(
"habluetooth.scanner.OriginalBleakScanner.stop",
) as mock_stop,
pytest.raises(
ScannerStartError,
match="DBus service not found; docker config may be missing",
),
):
scanner = HaScanner(BluetoothScanningMode.ACTIVE, "hci0", "AA:BB:CC:DD:EE:FF")
scanner.async_setup()
await scanner.async_start()
assert mock_stop.called
await scanner.async_stop()
@pytest.mark.asyncio
@pytest.mark.skipif(NOT_POSIX)
async def test_dbus_socket_missing(caplog: pytest.LogCaptureFixture) -> None:
"""Test we handle dbus being missing."""
with (
patch("habluetooth.scanner.is_docker_env", return_value=False),
patch(
"habluetooth.scanner.OriginalBleakScanner.start",
side_effect=FileNotFoundError,
),
patch(
"habluetooth.scanner.OriginalBleakScanner.stop",
) as mock_stop,
pytest.raises(
ScannerStartError,
match="DBus service not found; make sure the DBus socket is available",
),
):
scanner = HaScanner(BluetoothScanningMode.ACTIVE, "hci0", "AA:BB:CC:DD:EE:FF")
scanner.async_setup()
await scanner.async_start()
assert mock_stop.called
await scanner.async_stop()
@pytest.mark.asyncio
@pytest.mark.skipif(NOT_POSIX)
async def test_handle_cancellation(caplog: pytest.LogCaptureFixture) -> None:
"""Test cancellation stops."""
with (
patch("habluetooth.scanner.is_docker_env", return_value=False),
patch(
"habluetooth.scanner.OriginalBleakScanner.start",
side_effect=asyncio.CancelledError,
),
patch(
"habluetooth.scanner.OriginalBleakScanner.stop",
) as mock_stop,
):
scanner = HaScanner(BluetoothScanningMode.ACTIVE, "hci0", "AA:BB:CC:DD:EE:FF")
scanner.async_setup()
with pytest.raises(asyncio.CancelledError):
await scanner.async_start()
assert mock_stop.called
@pytest.mark.asyncio
@pytest.mark.skipif(NOT_POSIX)
async def test_handle_stop_while_starting(caplog: pytest.LogCaptureFixture) -> None:
"""Test stop while starting."""
async def _start(*args, **kwargs):
await asyncio.sleep(1000)
with (
patch("habluetooth.scanner.is_docker_env", return_value=False),
patch("habluetooth.scanner.OriginalBleakScanner.start", _start),
patch(
"habluetooth.scanner.OriginalBleakScanner.stop",
) as mock_stop,
):
scanner = HaScanner(BluetoothScanningMode.ACTIVE, "hci0", "AA:BB:CC:DD:EE:FF")
scanner.async_setup()
task = asyncio.create_task(scanner.async_start())
await asyncio.sleep(0)
await asyncio.sleep(0)
await scanner.async_stop()
with pytest.raises(
ScannerStartError, match="Starting bluetooth scanner aborted"
):
await task
assert mock_stop.called
@pytest.mark.asyncio
@pytest.mark.skipif(NOT_POSIX)
async def test_dbus_broken_pipe_in_container(caplog: pytest.LogCaptureFixture) -> None:
"""Test we handle dbus broken pipe in the container."""
with (
patch("habluetooth.scanner.is_docker_env", return_value=True),
patch(
"habluetooth.scanner.OriginalBleakScanner.start",
side_effect=BrokenPipeError,
),
patch(
"habluetooth.scanner.OriginalBleakScanner.stop",
) as mock_stop,
pytest.raises(ScannerStartError, match="DBus connection broken"),
):
scanner = HaScanner(BluetoothScanningMode.ACTIVE, "hci0", "AA:BB:CC:DD:EE:FF")
scanner.async_setup()
await scanner.async_start()
assert mock_stop.called
await scanner.async_stop()
@pytest.mark.asyncio
@pytest.mark.skipif(NOT_POSIX)
async def test_dbus_broken_pipe(caplog: pytest.LogCaptureFixture) -> None:
"""Test we handle dbus broken pipe."""
with (
patch("habluetooth.scanner.is_docker_env", return_value=False),
patch(
"habluetooth.scanner.OriginalBleakScanner.start",
side_effect=BrokenPipeError,
),
patch(
"habluetooth.scanner.OriginalBleakScanner.stop",
) as mock_stop,
pytest.raises(ScannerStartError, match="DBus connection broken:"),
):
scanner = HaScanner(BluetoothScanningMode.ACTIVE, "hci0", "AA:BB:CC:DD:EE:FF")
scanner.async_setup()
await scanner.async_start()
assert mock_stop.called
await scanner.async_stop()
@pytest.mark.asyncio
@pytest.mark.skipif(NOT_POSIX)
async def test_invalid_dbus_message(caplog: pytest.LogCaptureFixture) -> None:
"""Test we handle invalid dbus message."""
with (
patch(
"habluetooth.scanner.OriginalBleakScanner.start",
side_effect=InvalidMessageError,
),
pytest.raises(ScannerStartError, match="Invalid DBus message received"),
):
scanner = HaScanner(BluetoothScanningMode.ACTIVE, "hci0", "AA:BB:CC:DD:EE:FF")
scanner.async_setup()
await scanner.async_start()
await scanner.async_stop()
@pytest.mark.asyncio
@pytest.mark.skipif(IS_WINDOWS)
@pytest.mark.parametrize("error", NEED_RESET_ERRORS)
async def test_adapter_needs_reset_at_start(
caplog: pytest.LogCaptureFixture, error: str
) -> None:
"""Test we cycle the adapter when it needs a restart."""
called_start = 0
called_stop = 0
_callback = None
mock_discovered: list[Any] = []
class MockBleakScanner:
async def start(self, *args, **kwargs):
"""Mock Start."""
nonlocal called_start
called_start += 1
if called_start < 3:
raise BleakError(error)
async def stop(self, *args, **kwargs):
"""Mock Start."""
nonlocal called_stop
called_stop += 1
@property
def discovered_devices(self):
"""Mock discovered_devices."""
nonlocal mock_discovered
return mock_discovered
def register_detection_callback(
self, callback: AdvertisementDataCallback
) -> None:
"""Mock Register Detection Callback."""
nonlocal _callback
_callback = callback
mock_scanner = MockBleakScanner()
with (
patch("habluetooth.scanner.OriginalBleakScanner", return_value=mock_scanner),
patch(
"habluetooth.util.recover_adapter", return_value=True
) as mock_recover_adapter,
):
scanner = HaScanner(BluetoothScanningMode.ACTIVE, "hci0", "AA:BB:CC:DD:EE:FF")
scanner.async_setup()
await scanner.async_start()
assert len(mock_recover_adapter.mock_calls) == 1
await scanner.async_stop()
@pytest.mark.asyncio
@pytest.mark.skipif(IS_WINDOWS)
async def test_recovery_from_dbus_restart() -> None:
"""Test we can recover when DBus gets restarted out from under us."""
called_start = 0
called_stop = 0
_callback = None
mock_discovered: list[Any] = []
class MockBleakScanner:
def __init__(self, detection_callback, *args, **kwargs):
nonlocal _callback
_callback = detection_callback
async def start(self, *args, **kwargs):
"""Mock Start."""
nonlocal called_start
called_start += 1
async def stop(self, *args, **kwargs):
"""Mock Start."""
nonlocal called_stop
called_stop += 1
@property
def discovered_devices(self):
"""Mock discovered_devices."""
nonlocal mock_discovered
return mock_discovered
with patch(
"habluetooth.scanner.OriginalBleakScanner",
MockBleakScanner,
):
scanner = HaScanner(BluetoothScanningMode.ACTIVE, "hci0", "AA:BB:CC:DD:EE:FF")
scanner.async_setup()
await scanner.async_start()
assert called_start == 1
start_time_monotonic = time.monotonic()
mock_discovered = [MagicMock()]
# Ensure we don't restart the scanner if we don't need to
with patch_bluetooth_time(
start_time_monotonic + 10,
):
async_fire_time_changed(utcnow() + SCANNER_WATCHDOG_INTERVAL)
assert called_start == 1
# Fire a callback to reset the timer
with patch_bluetooth_time(
start_time_monotonic,
):
_callback( # type: ignore
generate_ble_device("44:44:33:11:23:42", "any_name"),
generate_advertisement_data(local_name="any_name"),
)
# Ensure we don't restart the scanner if we don't need to
with patch_bluetooth_time(
start_time_monotonic + 20,
):
async_fire_time_changed(utcnow() + SCANNER_WATCHDOG_INTERVAL)
await asyncio.sleep(0)
assert called_start == 1
# We hit the timer, so we restart the scanner
with patch_bluetooth_time(
start_time_monotonic + SCANNER_WATCHDOG_TIMEOUT + 20,
):
async_fire_time_changed(
utcnow() + SCANNER_WATCHDOG_INTERVAL + timedelta(seconds=20)
)
await asyncio.sleep(0)
assert called_start == 2
await scanner.async_stop()
@pytest.mark.asyncio
@pytest.mark.skipif(IS_WINDOWS)
async def test_adapter_recovery() -> None:
"""Test we can recover when the adapter stops responding."""
called_start = 0
called_stop = 0
_callback = None
mock_discovered: list[Any] = []
class MockBleakScanner:
async def start(self, *args, **kwargs):
"""Mock Start."""
nonlocal called_start
called_start += 1
async def stop(self, *args, **kwargs):
"""Mock Start."""
nonlocal called_stop
called_stop += 1
@property
def discovered_devices(self):
"""Mock discovered_devices."""
nonlocal mock_discovered
return mock_discovered
def register_detection_callback(
self, callback: AdvertisementDataCallback
) -> None:
"""Mock Register Detection Callback."""
nonlocal _callback
_callback = callback
mock_scanner = MockBleakScanner()
start_time_monotonic = time.monotonic()
with (
patch_bluetooth_time(
start_time_monotonic,
),
patch(
"habluetooth.scanner.OriginalBleakScanner",
return_value=mock_scanner,
),
):
scanner = HaScanner(BluetoothScanningMode.ACTIVE, "hci0", "AA:BB:CC:DD:EE:FF")
scanner.async_setup()
await scanner.async_start()
assert called_start == 1
mock_discovered = [MagicMock()]
# Ensure we don't restart the scanner if we don't need to
with patch_bluetooth_time(
start_time_monotonic + 10,
):
async_fire_time_changed(utcnow() + SCANNER_WATCHDOG_INTERVAL)
await asyncio.sleep(0)
assert called_start == 1
# Ensure we don't restart the scanner if we don't need to
with patch_bluetooth_time(
start_time_monotonic + 20,
):
async_fire_time_changed(utcnow() + SCANNER_WATCHDOG_INTERVAL)
await asyncio.sleep(0)
assert called_start == 1
# We hit the timer with no detections, so we
# reset the adapter and restart the scanner
with (
patch_bluetooth_time(
start_time_monotonic
+ SCANNER_WATCHDOG_TIMEOUT
+ SCANNER_WATCHDOG_INTERVAL.total_seconds(),
),
patch(
"habluetooth.util.recover_adapter", return_value=True
) as mock_recover_adapter,
):
async_fire_time_changed(utcnow() + SCANNER_WATCHDOG_INTERVAL)
await asyncio.sleep(0)
assert len(mock_recover_adapter.mock_calls) == 1
assert mock_recover_adapter.call_args_list[0][0] == (
0,
"AA:BB:CC:DD:EE:FF",
True,
)
assert called_start == 2
await scanner.async_stop()
@pytest.mark.asyncio
@pytest.mark.skipif(IS_WINDOWS)
async def test_adapter_scanner_fails_to_start_first_time() -> None:
"""
Test we can recover when the adapter stops responding.
The first recovery fails.
"""
called_start = 0
called_stop = 0
_callback = None
mock_discovered: list[Any] = []
class MockBleakScanner:
async def start(self, *args, **kwargs):
"""Mock Start."""
nonlocal called_start
called_start += 1
if called_start == 1:
return # Start ok the first time
if called_start < 4:
raise BleakError("Failed to start")
async def stop(self, *args, **kwargs):
"""Mock Start."""
nonlocal called_stop
called_stop += 1
@property
def discovered_devices(self):
"""Mock discovered_devices."""
nonlocal mock_discovered
return mock_discovered
def register_detection_callback(
self, callback: AdvertisementDataCallback
) -> None:
"""Mock Register Detection Callback."""
nonlocal _callback
_callback = callback
mock_scanner = MockBleakScanner()
start_time_monotonic = time.monotonic()
with (
patch_bluetooth_time(
start_time_monotonic,
),
patch(
"habluetooth.scanner.OriginalBleakScanner",
return_value=mock_scanner,
),
):
scanner = HaScanner(BluetoothScanningMode.ACTIVE, "hci0", "AA:BB:CC:DD:EE:FF")
scanner.async_setup()
await scanner.async_start()
assert called_start == 1
mock_discovered = [MagicMock()]
# Ensure we don't restart the scanner if we don't need to
with patch_bluetooth_time(
start_time_monotonic + 10,
):
async_fire_time_changed(utcnow() + SCANNER_WATCHDOG_INTERVAL)
await asyncio.sleep(0)
assert called_start == 1
# Ensure we don't restart the scanner if we don't need to
with patch_bluetooth_time(
start_time_monotonic + 20,
):
async_fire_time_changed(utcnow() + SCANNER_WATCHDOG_INTERVAL)
await asyncio.sleep(0)
assert called_start == 1
# We hit the timer with no detections,
# so we reset the adapter and restart the scanner
with (
patch_bluetooth_time(
start_time_monotonic
+ SCANNER_WATCHDOG_TIMEOUT
+ SCANNER_WATCHDOG_INTERVAL.total_seconds(),
),
patch(
"habluetooth.util.recover_adapter", return_value=True
) as mock_recover_adapter,
):
async_fire_time_changed(utcnow() + SCANNER_WATCHDOG_INTERVAL)
await asyncio.sleep(0)
assert len(mock_recover_adapter.mock_calls) == 1
assert called_start == 4
assert scanner.scanning is True
now_monotonic = time.monotonic()
# We hit the timer again the previous start call failed, make sure
# we try again
with (
patch_bluetooth_time(
now_monotonic
+ SCANNER_WATCHDOG_TIMEOUT * 2
+ SCANNER_WATCHDOG_INTERVAL.total_seconds(),
),
patch(
"habluetooth.util.recover_adapter", return_value=True
) as mock_recover_adapter,
):
async_fire_time_changed(utcnow() + SCANNER_WATCHDOG_INTERVAL)
await asyncio.sleep(0)
assert len(mock_recover_adapter.mock_calls) == 1
assert called_start == 5
await scanner.async_stop()
@pytest.mark.asyncio
async def test_adapter_fails_to_start_and_takes_a_bit_to_init(
caplog: pytest.LogCaptureFixture,
) -> None:
"""Test we can recover the adapter at startup and we wait for Dbus to init."""
called_start = 0
called_stop = 0
_callback = None
mock_discovered: list[Any] = []
class MockBleakScanner:
async def start(self, *args, **kwargs):
"""Mock Start."""
nonlocal called_start
called_start += 1
if called_start == 1:
raise BleakError("org.freedesktop.DBus.Error.UnknownObject")
if called_start == 2:
raise BleakError("org.bluez.Error.InProgress")
if called_start == 3:
raise BleakError("org.bluez.Error.InProgress")
async def stop(self, *args, **kwargs):
"""Mock Start."""
nonlocal called_stop
called_stop += 1
@property
def discovered_devices(self):
"""Mock discovered_devices."""
nonlocal mock_discovered
return mock_discovered
def register_detection_callback(
self, callback: AdvertisementDataCallback
) -> None:
"""Mock Register Detection Callback."""
nonlocal _callback
_callback = callback
mock_scanner = MockBleakScanner()
start_time_monotonic = time.monotonic()
with (
patch(
"habluetooth.scanner.ADAPTER_INIT_TIME",
0,
),
patch_bluetooth_time(
start_time_monotonic,
),
patch(
"habluetooth.scanner.OriginalBleakScanner",
return_value=mock_scanner,
),
patch(
"habluetooth.util.recover_adapter", return_value=True
) as mock_recover_adapter,
):
scanner = HaScanner(BluetoothScanningMode.ACTIVE, "hci0", "AA:BB:CC:DD:EE:FF")
scanner.async_setup()
await scanner.async_start()
assert called_start == 4
assert len(mock_recover_adapter.mock_calls) == 1
assert "Waiting for adapter to initialize" in caplog.text
await scanner.async_stop()
@pytest.mark.asyncio
async def test_restart_takes_longer_than_watchdog_time(
caplog: pytest.LogCaptureFixture,
) -> None:
"""
Test we do not try to recover the adapter again.
If the restart is still in progress.
"""
release_start_event = asyncio.Event()
called_start = 0
class MockBleakScanner:
async def start(self, *args, **kwargs):
"""Mock Start."""
nonlocal called_start
called_start += 1
if called_start == 1:
return
await release_start_event.wait()
async def stop(self, *args, **kwargs):
"""Mock Start."""
@property
def discovered_devices(self):
"""Mock discovered_devices."""
return []
def register_detection_callback(
self, callback: AdvertisementDataCallback
) -> None:
"""Mock Register Detection Callback."""
mock_scanner = MockBleakScanner()
start_time_monotonic = time.monotonic()
with (
patch(
"habluetooth.scanner.ADAPTER_INIT_TIME",
0,
),
patch_bluetooth_time(
start_time_monotonic,
),
patch(
"habluetooth.scanner.OriginalBleakScanner",
return_value=mock_scanner,
),
patch("habluetooth.util.recover_adapter", return_value=True),
):
scanner = HaScanner(BluetoothScanningMode.ACTIVE, "hci0", "AA:BB:CC:DD:EE:FF")
scanner.async_setup()
await scanner.async_start()
assert called_start == 1
# Now force a recover adapter 2x
for _ in range(2):
with patch_bluetooth_time(
start_time_monotonic
+ SCANNER_WATCHDOG_TIMEOUT
+ SCANNER_WATCHDOG_INTERVAL.total_seconds(),
):
async_fire_time_changed(utcnow() + SCANNER_WATCHDOG_INTERVAL)
await asyncio.sleep(0)
# Now release the start event
release_start_event.set()
assert "already restarting" in caplog.text
await scanner.async_stop()
@pytest.mark.asyncio
@pytest.mark.skipif("platform.system() != 'Darwin'")
async def test_setup_and_stop_macos() -> None:
"""Test we enable use_bdaddr on MacOS."""
init_kwargs = None
class MockBleakScanner:
def __init__(self, *args, **kwargs):
"""Init the scanner."""
nonlocal init_kwargs
init_kwargs = kwargs
async def start(self, *args, **kwargs):
"""Start the scanner."""
async def stop(self, *args, **kwargs):
"""Stop the scanner."""
def register_detection_callback(self, *args, **kwargs):
"""Register a callback."""
with patch(
"habluetooth.scanner.OriginalBleakScanner",
MockBleakScanner,
):
scanner = HaScanner(BluetoothScanningMode.ACTIVE, "hci0", "AA:BB:CC:DD:EE:FF")
scanner.async_setup()
await scanner.async_start()
assert init_kwargs == {
"detection_callback": ANY,
"scanning_mode": "active",
"cb": {"use_bdaddr": True},
}
await scanner.async_stop()
@pytest.mark.asyncio
async def test_adapter_init_fails_fallback_to_passive(
caplog: pytest.LogCaptureFixture,
) -> None:
"""Test we fallback to passive when adapter init fails."""
called_start = 0
called_stop = 0
_callback = None
mock_discovered: list[Any] = []
class MockBleakScanner:
async def start(self, *args, **kwargs):
"""Mock Start."""
nonlocal called_start
called_start += 1
if called_start == 1:
raise BleakError("org.freedesktop.DBus.Error.UnknownObject")
if called_start == 2:
raise BleakError("org.bluez.Error.InProgress")
if called_start == 3:
raise BleakError("org.bluez.Error.InProgress")
async def stop(self, *args, **kwargs):
"""Mock Start."""
nonlocal called_stop
called_stop += 1
@property
def discovered_devices(self):
"""Mock discovered_devices."""
nonlocal mock_discovered
return mock_discovered
def register_detection_callback(
self, callback: AdvertisementDataCallback
) -> None:
"""Mock Register Detection Callback."""
nonlocal _callback
_callback = callback
@property
def discovered_devices_and_advertisement_data(self) -> dict[str, Any]:
"""Mock discovered_devices."""
return {}
mock_scanner = MockBleakScanner()
start_time_monotonic = time.monotonic()
with (
patch(
"habluetooth.scanner.IS_LINUX",
True,
),
patch(
"habluetooth.scanner.ADAPTER_INIT_TIME",
0,
),
patch_bluetooth_time(
start_time_monotonic,
),
patch(
"habluetooth.scanner.OriginalBleakScanner",
return_value=mock_scanner,
),
patch(
"habluetooth.util.recover_adapter", return_value=True
) as mock_recover_adapter,
):
scanner = HaScanner(BluetoothScanningMode.ACTIVE, "hci0", "AA:BB:CC:DD:EE:FF")
scanner.async_setup()
await scanner.async_start()
assert called_start == 4
assert len(mock_recover_adapter.mock_calls) == 1
assert "Waiting for adapter to initialize" in caplog.text
assert (
"Successful fall-back to passive scanning mode after active scanning failed"
in caplog.text
)
assert await scanner.async_diagnostics() == {
"adapter": "hci0",
"connectable": True,
"current_mode": BluetoothScanningMode.PASSIVE,
"discovered_devices_and_advertisement_data": [],
"last_detection": ANY,
"monotonic_time": ANY,
"name": "hci0 (AA:BB:CC:DD:EE:FF)",
"requested_mode": BluetoothScanningMode.ACTIVE,
"scanning": True,
"source": "AA:BB:CC:DD:EE:FF",
"start_time": ANY,
"type": "HaScanner",
}
await scanner.async_stop()
assert await scanner.async_diagnostics() == {
"adapter": "hci0",
"connectable": True,
"current_mode": BluetoothScanningMode.PASSIVE,
"discovered_devices_and_advertisement_data": [],
"last_detection": ANY,
"monotonic_time": ANY,
"name": "hci0 (AA:BB:CC:DD:EE:FF)",
"requested_mode": BluetoothScanningMode.ACTIVE,
"scanning": False,
"source": "AA:BB:CC:DD:EE:FF",
"start_time": ANY,
"type": "HaScanner",
}
@pytest.mark.asyncio
@pytest.mark.skipif(NOT_POSIX)
async def test_scanner_with_bluez_mgmt_side_channel(mock_btmgmt_socket: Mock) -> None:
"""Test scanner receiving advertisements via BlueZ management side channel."""
# Mock capability check for the entire test
with patch.object(MGMTBluetoothCtl, "_check_capabilities", return_value=True):
# Create a custom manager that tracks discovered devices
class TestBluetoothManager(BluetoothManager):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.discovered_infos = []
def _discover_service_info(
self, service_info: BluetoothServiceInfoBleak
) -> None:
"""Track discovered service info."""
self.discovered_infos.append(service_info)
# Create manager and setup mgmt controller
adapters = FakeBluetoothAdapters()
slot_manager = BleakSlotManager()
manager = TestBluetoothManager(adapters, slot_manager)
set_manager(manager)
# Set up the manager first
await manager.async_setup()
# Create and setup the mgmt controller with the manager's side channel scanners
mgmt_ctl = MGMTBluetoothCtl(
timeout=5.0, scanners=manager._side_channel_scanners
)
# Mock the protocol setup
mock_protocol = Mock(spec=BluetoothMGMTProtocol)
mock_transport = Mock()
mock_protocol.transport = mock_transport
async def mock_setup():
mgmt_ctl.protocol = mock_protocol
mgmt_ctl._on_connection_lost_future = (
asyncio.get_running_loop().create_future()
)
mgmt_ctl.setup = mock_setup # type: ignore[method-assign]
# Inject mgmt controller into manager
manager._mgmt_ctl = mgmt_ctl
manager.has_advertising_side_channel = True
# Verify get_bluez_mgmt_ctl returns our controller
assert manager.get_bluez_mgmt_ctl() is mgmt_ctl
# Register scanner
scanner = HaScanner(BluetoothScanningMode.ACTIVE, "hci0", "AA:BB:CC:DD:EE:FF")
scanner.async_setup()
manager.async_register_scanner(scanner, connection_slots=2)
# Start scanner - should be created without detection callback
with patch("habluetooth.scanner.OriginalBleakScanner") as mock_scanner_class:
mock_scanner = Mock()
mock_scanner.start = AsyncMock()
mock_scanner.stop = AsyncMock()
mock_scanner.discovered_devices = []
mock_scanner_class.return_value = mock_scanner
await scanner.async_start()
# Verify scanner was created without detection callback
# since side channel is available
mock_scanner_class.assert_called_once()
call_kwargs = mock_scanner_class.call_args[1]
assert (
"detection_callback" not in call_kwargs
or call_kwargs["detection_callback"] is None
)
# Now simulate advertisement data coming through the mgmt protocol
# The manager should have registered the scanner with mgmt_ctl
assert 0 in mgmt_ctl.scanners # hci0 is index 0
assert mgmt_ctl.scanners[0] is scanner
# Simulate the protocol calling the scanner's raw advertisement handler
test_address = b"\xaa\xbb\xcc\xdd\xee\xff"
test_rssi = -60
test_flags = 0x06
# Create valid advertisement data with flags
# Each AD structure is: length (1 byte), type (1 byte), data
test_data = (
b"\x02\x01\x06" # Length=2, Type=0x01 (Flags), Data=0x06
# Length=8, Type=0x09 (Complete Local Name), Data="TestDev"
b"\x08\x09TestDev"
)
# Call the method that the protocol would call
scanner._async_on_raw_bluez_advertisement(
test_address,
1, # address_type: BDADDR_LE_PUBLIC
test_rssi,
test_flags,
test_data,
)
# Allow time for processing
await asyncio.sleep(0)
# Verify the device was discovered in the base scanner
assert len(scanner._previous_service_info) == 1
assert "FF:EE:DD:CC:BB:AA" in scanner._previous_service_info
service_info = scanner._previous_service_info["FF:EE:DD:CC:BB:AA"]
assert service_info.address == "FF:EE:DD:CC:BB:AA"
assert service_info.rssi == test_rssi
assert service_info.name == "TestDev"
# Verify the manager also received the advertisement
assert len(manager.discovered_infos) == 1
assert manager.discovered_infos[0] is service_info
await scanner.async_stop()
manager.async_stop()
@pytest.mark.asyncio
@pytest.mark.skipif(NOT_POSIX)
async def test_scanner_without_bluez_mgmt_side_channel() -> None:
"""Test scanner uses normal detection callback when side channel unavailable."""
# Create manager without BlueZ mgmt support
class TestBluetoothManager(BluetoothManager):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.discovered_infos = []
def _discover_service_info(
self, service_info: BluetoothServiceInfoBleak
) -> None:
"""Track discovered service info."""
self.discovered_infos.append(service_info)
adapters = FakeBluetoothAdapters()
slot_manager = BleakSlotManager()
manager = TestBluetoothManager(adapters, slot_manager)
set_manager(manager)
# Setup without mgmt controller
await manager.async_setup()
assert manager.has_advertising_side_channel is False
# Register scanner
scanner = HaScanner(BluetoothScanningMode.ACTIVE, "hci0", "AA:BB:CC:DD:EE:FF")
scanner.async_setup()
manager.async_register_scanner(scanner, connection_slots=2)
# Start scanner - should be created with detection callback
with patch("habluetooth.scanner.OriginalBleakScanner") as mock_scanner_class:
mock_scanner = Mock()
mock_scanner.start = AsyncMock()
mock_scanner.stop = AsyncMock()
mock_scanner.discovered_devices = []
mock_scanner_class.return_value = mock_scanner
await scanner.async_start()
# Verify scanner was created with detection callback since no side channel
mock_scanner_class.assert_called_once()
call_kwargs = mock_scanner_class.call_args[1]
assert "detection_callback" in call_kwargs
assert call_kwargs["detection_callback"] is not None
assert call_kwargs["detection_callback"] == scanner._async_detection_callback
await scanner.async_stop()
manager.async_stop()
@pytest.mark.asyncio
@pytest.mark.skipif(NOT_POSIX)
async def test_bluez_mgmt_protocol_data_flow(mock_btmgmt_socket: Mock) -> None:
"""Test data flow from BlueZ protocol through manager to scanner."""
# Mock capability check for the entire test
with patch.object(MGMTBluetoothCtl, "_check_capabilities", return_value=True):
# Create manager
class TestBluetoothManager(BluetoothManager):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.discovered_infos = []
def _discover_service_info(
self, service_info: BluetoothServiceInfoBleak
) -> None:
"""Track discovered service info."""
self.discovered_infos.append(service_info)
adapters = FakeBluetoothAdapters()
slot_manager = BleakSlotManager()
manager = TestBluetoothManager(adapters, slot_manager)
set_manager(manager)
# Set up manager first
await manager.async_setup()
# Create mgmt controller with the manager's side channel scanners dictionary
mgmt_ctl = MGMTBluetoothCtl(
timeout=5.0, scanners=manager._side_channel_scanners
)
# We'll capture the protocol when it's created
captured_protocol: BluetoothMGMTProtocol | None = None
async def mock_create_connection(sock, protocol_factory, *args, **kwargs):
nonlocal captured_protocol
captured_protocol = protocol_factory()
mock_transport = Mock()
captured_protocol.connection_made(mock_transport)
return mock_transport, captured_protocol
with patch.object(
asyncio.get_running_loop(),
"_create_connection_transport",
mock_create_connection,
):
await mgmt_ctl.setup()
# Set mgmt controller on manager
manager._mgmt_ctl = mgmt_ctl
manager.has_advertising_side_channel = True
# Register scanners for hci0 and hci1
scanner0 = HaScanner(BluetoothScanningMode.ACTIVE, "hci0", "AA:BB:CC:DD:EE:00")
scanner0.async_setup()
manager.async_register_scanner(scanner0, connection_slots=2)
scanner1 = HaScanner(BluetoothScanningMode.ACTIVE, "hci1", "AA:BB:CC:DD:EE:01")
scanner1.async_setup()
manager.async_register_scanner(scanner1, connection_slots=2)
# Start scanners
with patch("habluetooth.scanner.OriginalBleakScanner") as mock_scanner_class:
mock_scanner = Mock()
mock_scanner.start = AsyncMock()
mock_scanner.stop = AsyncMock()
mock_scanner.discovered_devices = []
mock_scanner_class.return_value = mock_scanner
await scanner0.async_start()
await scanner1.async_start()
# Verify scanners are registered in mgmt_ctl
assert 0 in mgmt_ctl.scanners
assert 1 in mgmt_ctl.scanners
assert mgmt_ctl.scanners[0] is scanner0
assert mgmt_ctl.scanners[1] is scanner1
# Test DEVICE_FOUND event for hci0
test_address = b"\x11\x22\x33\x44\x55\x66"
rssi_byte = b"\xc4" # -60 in signed byte
event_data = (
test_address
+ b"\x01" # address_type
+ rssi_byte
+ b"\x06\x00\x00\x00" # flags
+ b"\x03\x00" # data_len
+ b"\x02\x01\x06" # minimal adv data
)
packet = (
DEVICE_FOUND.to_bytes(2, "little")
+ b"\x00\x00" # controller_idx 0 (hci0)
+ len(event_data).to_bytes(2, "little")
+ event_data
)
# Feed packet to protocol
assert captured_protocol is not None
captured_protocol.data_received(packet)
# Verify device discovered on scanner0 only
assert len(scanner0._previous_service_info) == 1
assert "66:55:44:33:22:11" in scanner0._previous_service_info
assert len(scanner1._previous_service_info) == 0
# Test ADV_MONITOR_DEVICE_FOUND event for hci1
test_address2 = b"\xaa\xbb\xcc\xdd\xee\x02"
monitor_handle = b"\x01\x00"
rssi_byte2 = b"\xba" # -70 in signed byte
event_data2 = (
monitor_handle
+ test_address2
+ b"\x02" # address_type (random)
+ rssi_byte2
+ b"\x06\x00\x00\x00" # flags
+ b"\x03\x00" # data_len
+ b"\x02\x01\x06" # minimal adv data
)
packet2 = (
ADV_MONITOR_DEVICE_FOUND.to_bytes(2, "little")
+ b"\x01\x00" # controller_idx 1 (hci1)
+ len(event_data2).to_bytes(2, "little")
+ event_data2
)
assert captured_protocol is not None
captured_protocol.data_received(packet2)
# Verify device discovered on scanner1 only
assert len(scanner0._previous_service_info) == 1 # Still just the first device
assert len(scanner1._previous_service_info) == 1
assert "02:EE:DD:CC:BB:AA" in scanner1._previous_service_info
# Verify RSSI values
info0 = scanner0._previous_service_info["66:55:44:33:22:11"]
assert info0.rssi == -60
info1 = scanner1._previous_service_info["02:EE:DD:CC:BB:AA"]
assert info1.rssi == -70
await scanner0.async_stop()
await scanner1.async_stop()
manager.async_stop()
@pytest.mark.asyncio
@pytest.mark.skipif(NOT_POSIX)
async def test_mgmt_permission_error_fallback() -> None:
"""Test that permission errors in MGMT setup fall back to BlueZ-only mode."""
# Create manager
class TestBluetoothManager(BluetoothManager):
def _discover_service_info(
self, service_info: BluetoothServiceInfoBleak
) -> None:
"""Track discovered service info."""
pass
adapters = FakeBluetoothAdapters()
slot_manager = BleakSlotManager()
manager = TestBluetoothManager(adapters, slot_manager)
# Mock MGMTBluetoothCtl setup to raise PermissionError
with (
patch("habluetooth.manager.MGMTBluetoothCtl") as mock_mgmt_cls,
patch("habluetooth.manager.IS_LINUX", True),
):
mock_mgmt = Mock()
mock_mgmt.setup = AsyncMock(
side_effect=PermissionError(
"Missing NET_ADMIN/NET_RAW capabilities for Bluetooth management"
)
)
mock_mgmt_cls.return_value = mock_mgmt
# Setup should complete without raising the exception
await manager.async_setup()
# Verify MGMT was attempted but then set to None
mock_mgmt.setup.assert_called_once()
assert manager._mgmt_ctl is None
assert manager.has_advertising_side_channel is False
def test_usb_scanner_type() -> None:
"""Test that USB adapters get USB scanner type."""
manager = get_manager()
# Mock cached adapters with USB adapter
mock_adapters: dict[str, dict[str, Any]] = {
"hci0": {
"address": "00:1A:7D:DA:71:04",
"adapter_type": "usb",
"manufacturer": "TestManufacturer",
"product": "USB Bluetooth Adapter",
}
}
with patch.object(manager, "_adapters", mock_adapters):
scanner = HaScanner(BluetoothScanningMode.ACTIVE, "hci0", "00:1A:7D:DA:71:04")
assert scanner.details.scanner_type is HaScannerType.USB
def test_uart_scanner_type() -> None:
"""Test that UART adapters get UART scanner type."""
manager = get_manager()
# Mock cached adapters with UART adapter
mock_adapters: dict[str, dict[str, Any]] = {
"hci0": {
"address": "00:1A:7D:DA:71:04",
"adapter_type": "uart",
"manufacturer": "TestManufacturer",
"product": "UART Bluetooth Module",
}
}
with patch.object(manager, "_adapters", mock_adapters):
scanner = HaScanner(BluetoothScanningMode.ACTIVE, "hci0", "00:1A:7D:DA:71:04")
assert scanner.details.scanner_type is HaScannerType.UART
def test_unknown_scanner_type_no_cached_adapters() -> None:
"""Test that scanners get UNKNOWN type when no adapter info is cached."""
manager = get_manager()
# No cached adapters
with patch.object(manager, "_adapters", None):
scanner = HaScanner(BluetoothScanningMode.ACTIVE, "hci0", "00:1A:7D:DA:71:04")
assert scanner.details.scanner_type is HaScannerType.UNKNOWN
def test_unknown_scanner_type_adapter_not_found() -> None:
"""Test that scanners get UNKNOWN type when adapter is not in cache."""
manager = get_manager()
# Cached adapters but not the one we're looking for
mock_adapters: dict[str, dict[str, Any]] = {
"hci1": {
"address": "11:22:33:44:55:66",
"adapter_type": "usb",
}
}
with patch.object(manager, "_adapters", mock_adapters):
scanner = HaScanner(BluetoothScanningMode.ACTIVE, "hci0", "00:1A:7D:DA:71:04")
assert scanner.details.scanner_type is HaScannerType.UNKNOWN
def test_unknown_scanner_type_no_adapter_type() -> None:
"""Test that scanners get UNKNOWN type when adapter_type is None."""
manager = get_manager()
# Cached adapter without adapter_type field
mock_adapters: dict[str, dict[str, Any]] = {
"hci0": {
"address": "00:1A:7D:DA:71:04",
"adapter_type": None,
"manufacturer": "TestManufacturer",
}
}
with patch.object(manager, "_adapters", mock_adapters):
scanner = HaScanner(BluetoothScanningMode.ACTIVE, "hci0", "00:1A:7D:DA:71:04")
assert scanner.details.scanner_type is HaScannerType.UNKNOWN
@pytest.mark.asyncio
async def test_scanner_type_with_real_adapter_data() -> None:
"""Test scanner type detection with realistic adapter data."""
# Create a custom manager for this test
manager = BluetoothManager(bluetooth_adapters=MagicMock())
set_manager(manager)
# Simulate real USB adapter data from Linux
usb_adapter_data: dict[str, dict[str, Any]] = {
"hci0": {
"address": "00:1A:7D:DA:71:04",
"sw_version": "homeassistant",
"hw_version": "usb:v1D6Bp0246d053F",
"passive_scan": False,
"manufacturer": "XTech",
"product": "Bluetooth 4.0 USB Adapter",
"vendor_id": "0a12",
"product_id": "0001",
"adapter_type": "usb",
}
}
manager._adapters = usb_adapter_data
# Create USB scanner
usb_scanner = HaScanner(BluetoothScanningMode.ACTIVE, "hci0", "00:1A:7D:DA:71:04")
assert usb_scanner.details.scanner_type is HaScannerType.USB
assert usb_scanner.details.adapter == "hci0"
# Simulate real UART adapter data
uart_adapter_data: dict[str, dict[str, Any]] = {
"hci1": {
"address": "AA:BB:CC:DD:EE:FF",
"sw_version": "homeassistant",
"hw_version": "uart:ttyUSB0",
"passive_scan": False,
"manufacturer": "cyber-blue(HK)Ltd",
"product": "Bluetooth 4.0 UART Module",
"vendor_id": None,
"product_id": None,
"adapter_type": "uart",
}
}
manager._adapters = uart_adapter_data
# Create UART scanner
uart_scanner = HaScanner(BluetoothScanningMode.PASSIVE, "hci1", "AA:BB:CC:DD:EE:FF")
assert uart_scanner.details.scanner_type is HaScannerType.UART
assert uart_scanner.details.adapter == "hci1"
# Test with macOS/Windows adapter (no adapter_type)
macos_adapter_data = {
"Core Bluetooth": {
"address": "00:00:00:00:00:00",
"passive_scan": False,
"sw_version": "18.7.0",
"manufacturer": "Apple",
"product": "Unknown MacOS Model",
"vendor_id": "Unknown",
"product_id": "Unknown",
"adapter_type": None,
}
}
manager._adapters = macos_adapter_data
# Create scanner with unknown adapter type
macos_scanner = HaScanner(
BluetoothScanningMode.ACTIVE, "Core Bluetooth", "00:00:00:00:00:00"
)
assert macos_scanner.details.scanner_type is HaScannerType.UNKNOWN
@pytest.mark.asyncio
async def test_scanner_type_updates_after_adapter_refresh() -> None:
"""Test scanner type is UNKNOWN initially, determined after adapters load."""
# Create a custom manager for this test
manager = BluetoothManager(bluetooth_adapters=MagicMock())
set_manager(manager)
# Initially no adapters cached
manager._adapters = None # type: ignore[assignment]
# Create scanner - should be UNKNOWN
scanner = HaScanner(BluetoothScanningMode.ACTIVE, "hci0", "00:1A:7D:DA:71:04")
assert scanner.details.scanner_type is HaScannerType.UNKNOWN
# Now simulate adapter data becoming available
manager._adapters = {
"hci0": {
"address": "00:1A:7D:DA:71:04",
"adapter_type": "usb",
"manufacturer": "TestManufacturer",
}
}
# Create a new scanner with the same adapter - should now be USB
scanner2 = HaScanner(BluetoothScanningMode.ACTIVE, "hci0", "00:1A:7D:DA:71:04")
assert scanner2.details.scanner_type is HaScannerType.USB
# Note: The first scanner still has UNKNOWN since scanner_type is set at init
assert scanner.details.scanner_type is HaScannerType.UNKNOWN
def test_multiple_scanner_types_simultaneously() -> None:
"""Test that multiple scanners can have different types at the same time."""
manager = get_manager()
# Set up adapters with different types
mock_adapters = {
"hci0": {
"address": "00:1A:7D:DA:71:04",
"adapter_type": "usb",
},
"hci1": {
"address": "AA:BB:CC:DD:EE:FF",
"adapter_type": "uart",
},
"hci2": {
"address": "11:22:33:44:55:66",
"adapter_type": None,
},
}
with patch.object(manager, "_adapters", mock_adapters):
# Create scanners of different types
usb_scanner = HaScanner(
BluetoothScanningMode.ACTIVE, "hci0", "00:1A:7D:DA:71:04"
)
uart_scanner = HaScanner(
BluetoothScanningMode.ACTIVE, "hci1", "AA:BB:CC:DD:EE:FF"
)
unknown_scanner = HaScanner(
BluetoothScanningMode.ACTIVE, "hci2", "11:22:33:44:55:66"
)
# Verify each has the correct type
assert usb_scanner.details.scanner_type is HaScannerType.USB
assert uart_scanner.details.scanner_type is HaScannerType.UART
assert unknown_scanner.details.scanner_type is HaScannerType.UNKNOWN
# Verify they all have different types
types = {
usb_scanner.details.scanner_type,
uart_scanner.details.scanner_type,
unknown_scanner.details.scanner_type,
}
assert len(types) == 3 # All different
def test_ha_scanner_get_allocations_no_slot_manager() -> None:
"""Test HaScanner.get_allocations returns None when manager has no slot_manager."""
scanner = HaScanner(BluetoothScanningMode.ACTIVE, "hci0", "AA:BB:CC:DD:EE:FF")
manager = get_manager()
# Mock slot_manager as None
with patch.object(manager, "slot_manager", None):
assert scanner.get_allocations() is None
def test_ha_scanner_get_allocations_with_slot_manager() -> None:
"""Test HaScanner.get_allocations returns allocation info from BleakSlotManager."""
from bleak_retry_connector import Allocations
scanner = HaScanner(BluetoothScanningMode.ACTIVE, "hci0", "AA:BB:CC:DD:EE:FF")
manager = get_manager()
# Create mock allocations
mock_allocations = Allocations(
adapter="hci0",
slots=5,
free=3,
allocated=["11:22:33:44:55:66", "AA:BB:CC:DD:EE:FF"],
)
# Mock slot_manager
mock_slot_manager = Mock(spec=BleakSlotManager)
mock_slot_manager.get_allocations.return_value = mock_allocations
with patch.object(manager, "slot_manager", mock_slot_manager):
allocations = scanner.get_allocations()
assert allocations is not None
assert allocations == mock_allocations
mock_slot_manager.get_allocations.assert_called_once_with("hci0")
def test_ha_scanner_get_allocations_updates_dynamically() -> None:
"""Test that HaScanner.get_allocations returns current values as they change."""
from bleak_retry_connector import Allocations
scanner = HaScanner(BluetoothScanningMode.ACTIVE, "hci0", "AA:BB:CC:DD:EE:FF")
manager = get_manager()
# Mock slot_manager
mock_slot_manager = Mock(spec=BleakSlotManager)
# Initial state - 3 free slots
mock_slot_manager.get_allocations.return_value = Allocations(
adapter="hci0", slots=3, free=3, allocated=[]
)
with patch.object(manager, "slot_manager", mock_slot_manager):
# Check initial state
allocations = scanner.get_allocations()
assert allocations is not None
assert allocations.free == 3
assert allocations.allocated == []
# Update mock to simulate connection made
mock_slot_manager.get_allocations.return_value = Allocations(
adapter="hci0", slots=3, free=2, allocated=["11:22:33:44:55:66"]
)
# Check updated state
allocations = scanner.get_allocations()
assert allocations is not None
assert allocations.free == 2
assert allocations.allocated == ["11:22:33:44:55:66"]
# Update mock to simulate another connection
mock_slot_manager.get_allocations.return_value = Allocations(
adapter="hci0",
slots=3,
free=1,
allocated=["11:22:33:44:55:66", "AA:BB:CC:DD:EE:FF"],
)
# Check final state
allocations = scanner.get_allocations()
assert allocations is not None
assert allocations.free == 1
assert len(allocations.allocated) == 2
@pytest.mark.asyncio
async def test_on_scanner_start_callback(
async_mock_manager_with_scanner_callbacks: MockBluetoothManagerWithCallbacks,
) -> None:
"""Test that on_scanner_start is called when a local scanner starts."""
manager = async_mock_manager_with_scanner_callbacks
# Create a local scanner (it will get the manager from get_manager())
scanner = HaScanner(
mode=BluetoothScanningMode.ACTIVE,
adapter="hci0",
address="00:00:00:00:00:00",
)
# Register scanner with manager
manager.async_register_scanner(scanner)
# Setup the scanner
scanner.async_setup()
# Directly call _on_start_success to test the callback
# (In real usage, this is called by HaScanner._async_start_attempt
# after successful start)
scanner._on_start_success()
# Verify the callback was called
assert len(manager.scanner_start_calls) == 1
assert manager.scanner_start_calls[0] is scanner
Bluetooth-Devices-habluetooth-21accde/tests/test_storage.py 0000664 0000000 0000000 00000041750 15070247161 0024350 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):
"""Test discovered_device_advertisement_data_from_dict with corrupt data."""
now = time.time()
result = discovered_device_advertisement_data_from_dict(
{
"connectable": True,
"discovered_device_advertisement_datas": {
"AA:BB:CC:DD:EE:FF": {
"advertisement_data": {
"local_name": "Test Device",
"manufacturer_data": {"76": "0215aabbccddeeff"},
"service_data": {
"0000180d-0000-1000-8000-00805f9b34fb": "00000000"
},
"service_uuids": ["0000180d-0000-1000-8000-00805f9b34fb"],
},
"device": { # type: ignore[typeddict-item]
"address": "AA:BB:CC:DD:EE:FF",
"details": {"details": "test"},
},
}
},
"discovered_device_timestamps": {"AA:BB:CC:DD:EE:FF": now},
"expire_seconds": 100,
}
)
assert result is None
assert "Error deserializing discovered_device_advertisement_data" in caplog.text
def test_backward_compatibility_rssi_in_device_dict():
"""Test that devices with RSSI in storage can still be loaded."""
now = time.time()
# Simulate old storage format where RSSI was stored in the device dict
result = discovered_device_advertisement_data_from_dict(
{
"connectable": True,
"discovered_device_advertisement_datas": {
"AA:BB:CC:DD:EE:FF": {
"advertisement_data": {
"local_name": "Test Device",
"manufacturer_data": {"76": "0215aabbccddeeff"},
"rssi": -50,
"service_data": {
"0000180d-0000-1000-8000-00805f9b34fb": "00000000"
},
"service_uuids": ["0000180d-0000-1000-8000-00805f9b34fb"],
"tx_power": 50,
"platform_data": ["Test Device", ""],
},
"device": {
"address": "AA:BB:CC:DD:EE:FF",
"details": {"details": "test"},
"name": "Test Device",
"rssi": -50, # Old format included RSSI here
},
}
},
"discovered_device_timestamps": {"AA:BB:CC:DD:EE:FF": now},
"expire_seconds": 100,
"discovered_device_raw": {},
}
)
# Should successfully deserialize without errors
assert result is not None
assert result.connectable is True
assert result.expire_seconds == 100
# Check that the device was properly created
ble_device, adv_data = result.discovered_device_advertisement_datas[
"AA:BB:CC:DD:EE:FF"
]
assert ble_device.address == "AA:BB:CC:DD:EE:FF"
assert ble_device.name == "Test Device"
assert adv_data.rssi == -50
Bluetooth-Devices-habluetooth-21accde/tests/test_wrappers.py 0000664 0000000 0000000 00000206275 15070247161 0024554 0 ustar 00root root 0000000 0000000 """Tests for bluetooth wrappers."""
from __future__ import annotations
import asyncio
import logging
from collections.abc import Callable, Generator
from contextlib import contextmanager, suppress
from typing import Any
from unittest.mock import Mock, patch
import bleak
import pytest
from bleak.backends.device import BLEDevice
from bleak.backends.scanner import AdvertisementData
from bleak.exc import BleakError
from bleak_retry_connector import Allocations
from bluetooth_data_tools import monotonic_time_coarse as MONOTONIC_TIME
from habluetooth import BaseHaRemoteScanner, HaBluetoothConnector
from habluetooth import get_manager as _get_manager
from habluetooth.manager import BluetoothManager
from habluetooth.usage import (
install_multiple_bleak_catcher,
uninstall_multiple_bleak_catcher,
)
from habluetooth.wrappers import HaBleakScannerWrapper
from . import (
HCI0_SOURCE_ADDRESS,
generate_advertisement_data,
generate_ble_device,
inject_advertisement,
patch_discovered_devices,
)
@contextmanager
def mock_shutdown(manager: BluetoothManager) -> Generator[None, None, None]:
"""Mock shutdown of the HomeAssistantBluetoothManager."""
manager.shutdown = True
yield
manager.shutdown = False
class FakeScanner(BaseHaRemoteScanner):
"""Fake scanner."""
def __init__(
self,
scanner_id: str,
name: str,
connector: Any,
connectable: bool,
) -> None:
"""Initialize the scanner."""
super().__init__(scanner_id, name, connector, connectable)
self._details: dict[str, str | HaBluetoothConnector] = {}
def __repr__(self) -> str:
"""Return the representation."""
return f"FakeScanner({self.name})"
def inject_advertisement(
self, device: BLEDevice, advertisement_data: AdvertisementData
) -> None:
"""Inject an advertisement."""
self._async_on_advertisement(
device.address,
advertisement_data.rssi,
device.name,
advertisement_data.service_uuids,
advertisement_data.service_data,
advertisement_data.manufacturer_data,
advertisement_data.tx_power,
device.details | {"scanner_specific_data": "test"},
MONOTONIC_TIME(),
)
class BaseFakeBleakClient:
"""Base class for fake bleak clients."""
def __init__(self, address_or_ble_device: BLEDevice | str, **kwargs: Any) -> None:
"""Initialize the fake bleak client."""
self._device_path = "/dev/test"
self._device = address_or_ble_device
assert isinstance(address_or_ble_device, BLEDevice)
self._address = address_or_ble_device.address
async def disconnect(self, *args, **kwargs):
"""Disconnect."""
async def get_services(self, *args, **kwargs):
"""Get services."""
return []
class FakeBleakClient(BaseFakeBleakClient):
"""Fake bleak client."""
async def connect(self, *args, **kwargs):
"""Connect."""
return True
@property
def is_connected(self):
return False
class FakeBleakClientFailsToConnect(BaseFakeBleakClient):
"""Fake bleak client that fails to connect."""
async def connect(self, *args, **kwargs):
"""Connect."""
return
@property
def is_connected(self):
return False
class FakeBleakClientRaisesOnConnect(BaseFakeBleakClient):
"""Fake bleak client that raises on connect."""
async def connect(self, *args, **kwargs):
"""Connect."""
raise ConnectionError("Test exception")
def _generate_ble_device_and_adv_data(
interface: str, mac: str, rssi: int
) -> tuple[BLEDevice, AdvertisementData]:
"""Generate a BLE device with adv data."""
return (
generate_ble_device(
mac,
"any",
delegate="",
details={"path": f"/org/bluez/{interface}/dev_{mac}"},
),
generate_advertisement_data(rssi=rssi),
)
@pytest.fixture(name="install_bleak_catcher")
def install_bleak_catcher_fixture():
"""Fixture that installs the bleak catcher."""
install_multiple_bleak_catcher()
yield
uninstall_multiple_bleak_catcher()
@pytest.fixture(name="mock_platform_client")
def mock_platform_client_fixture():
"""Fixture that mocks the platform client."""
with patch(
"habluetooth.wrappers.get_platform_client_backend_type",
return_value=FakeBleakClient,
):
yield
@pytest.fixture(name="mock_platform_client_that_fails_to_connect")
def mock_platform_client_that_fails_to_connect_fixture():
"""Fixture that mocks the platform client that fails to connect."""
with patch(
"habluetooth.wrappers.get_platform_client_backend_type",
return_value=FakeBleakClientFailsToConnect,
):
yield
@pytest.fixture(name="mock_platform_client_that_raises_on_connect")
def mock_platform_client_that_raises_on_connect_fixture():
"""Fixture that mocks the platform client that fails to connect."""
with patch(
"habluetooth.wrappers.get_platform_client_backend_type",
return_value=FakeBleakClientRaisesOnConnect,
):
yield
def _generate_scanners_with_fake_devices():
"""Generate scanners with fake devices."""
manager = _get_manager()
hci0_device_advs = {}
for i in range(10):
device, adv_data = _generate_ble_device_and_adv_data(
"hci0", f"00:00:00:00:00:{i:02x}", rssi=-60
)
hci0_device_advs[device.address] = (device, adv_data)
hci1_device_advs = {}
for i in range(10):
device, adv_data = _generate_ble_device_and_adv_data(
"hci1", f"00:00:00:00:00:{i:02x}", rssi=-80
)
hci1_device_advs[device.address] = (device, adv_data)
scanner_hci0 = FakeScanner("00:00:00:00:00:01", "hci0", None, True)
scanner_hci1 = FakeScanner("00:00:00:00:00:02", "hci1", None, True)
for device, adv_data in hci0_device_advs.values():
scanner_hci0.inject_advertisement(device, adv_data)
for device, adv_data in hci1_device_advs.values():
scanner_hci1.inject_advertisement(device, adv_data)
cancel_hci0 = manager.async_register_scanner(scanner_hci0, connection_slots=2)
cancel_hci1 = manager.async_register_scanner(scanner_hci1, connection_slots=1)
return hci0_device_advs, cancel_hci0, cancel_hci1
@pytest.mark.asyncio
async def test_test_switch_adapters_when_out_of_slots(
two_adapters: None,
enable_bluetooth: None,
install_bleak_catcher: None,
mock_platform_client: None,
) -> None:
"""Ensure we try another scanner when one runs out of slots."""
manager = _get_manager()
# hci0 has an rssi of -60, hci1 has an rssi of -80
hci0_device_advs, cancel_hci0, cancel_hci1 = _generate_scanners_with_fake_devices()
# hci0 has 2 slots, hci1 has 1 slot
with (
patch.object(manager.slot_manager, "release_slot") as release_slot_mock,
patch.object(
manager.slot_manager, "allocate_slot", return_value=True
) as allocate_slot_mock,
):
ble_device = hci0_device_advs["00:00:00:00:00:01"][0]
with patch.object(FakeBleakClient, "is_connected", return_value=True):
client = bleak.BleakClient(ble_device)
await client.connect()
assert allocate_slot_mock.call_count == 1
assert release_slot_mock.call_count == 0
# All adapters are out of slots
with (
patch.object(manager.slot_manager, "release_slot") as release_slot_mock,
patch.object(
manager.slot_manager, "allocate_slot", return_value=False
) as allocate_slot_mock,
):
ble_device = hci0_device_advs["00:00:00:00:00:02"][0]
client = bleak.BleakClient(ble_device)
with pytest.raises(bleak.exc.BleakError):
await client.connect()
assert allocate_slot_mock.call_count == 2
assert release_slot_mock.call_count == 0
# When hci0 runs out of slots, we should try hci1
def _allocate_slot_mock(ble_device: BLEDevice) -> bool:
return "hci1" in ble_device.details["path"]
with (
patch.object(manager.slot_manager, "release_slot") as release_slot_mock,
patch.object( # type: ignore
manager.slot_manager, "allocate_slot", _allocate_slot_mock
) as allocate_slot_mock,
):
ble_device = hci0_device_advs["00:00:00:00:00:03"][0]
with patch.object(FakeBleakClient, "is_connected", return_value=True):
client = bleak.BleakClient(ble_device)
await client.connect()
assert release_slot_mock.call_count == 0
cancel_hci0()
cancel_hci1()
@pytest.mark.asyncio
async def test_release_slot_on_connect_failure(
two_adapters: None,
enable_bluetooth: None,
install_bleak_catcher: None,
mock_platform_client_that_raises_on_connect: None,
) -> None:
"""Ensure the slot gets released on connection failure."""
manager = _get_manager()
hci0_device_advs, cancel_hci0, cancel_hci1 = _generate_scanners_with_fake_devices()
# hci0 has 2 slots, hci1 has 1 slot
with (
patch.object(manager.slot_manager, "release_slot") as release_slot_mock,
patch.object(
manager.slot_manager, "allocate_slot", return_value=True
) as allocate_slot_mock,
):
ble_device = hci0_device_advs["00:00:00:00:00:01"][0]
client = bleak.BleakClient(ble_device)
with pytest.raises(ConnectionError):
await client.connect()
assert allocate_slot_mock.call_count == 1
assert release_slot_mock.call_count == 1
cancel_hci0()
cancel_hci1()
@pytest.mark.asyncio
async def test_release_slot_on_connect_exception(
two_adapters: None,
enable_bluetooth: None,
install_bleak_catcher: None,
mock_platform_client_that_raises_on_connect: None,
) -> None:
"""Ensure the slot gets released on connection exception."""
manager = _get_manager()
hci0_device_advs, cancel_hci0, cancel_hci1 = _generate_scanners_with_fake_devices()
# hci0 has 2 slots, hci1 has 1 slot
with (
patch.object(manager.slot_manager, "release_slot") as release_slot_mock,
patch.object(
manager.slot_manager, "allocate_slot", return_value=True
) as allocate_slot_mock,
):
ble_device = hci0_device_advs["00:00:00:00:00:01"][0]
client = bleak.BleakClient(ble_device)
with pytest.raises(ConnectionError) as exc_info:
await client.connect()
assert str(exc_info.value) == "Test exception"
assert allocate_slot_mock.call_count == 1
assert release_slot_mock.call_count == 1
cancel_hci0()
cancel_hci1()
@pytest.mark.asyncio
async def test_switch_adapters_on_failure(
two_adapters: None,
enable_bluetooth: None,
install_bleak_catcher: None,
) -> None:
"""Ensure we try the next best adapter after a failure."""
hci0_device_advs, cancel_hci0, cancel_hci1 = _generate_scanners_with_fake_devices()
ble_device = hci0_device_advs["00:00:00:00:00:01"][0]
client = bleak.BleakClient(ble_device)
class FakeBleakClientFailsHCI0Only(BaseFakeBleakClient):
"""Fake bleak client that fails to connect on hci0."""
async def connect(self, *args: Any, **kwargs: Any) -> None:
"""Connect."""
assert isinstance(self._device, BLEDevice)
if "/hci0/" in self._device.details["path"]:
raise BleakError("Failed to connect on hci0")
@property
def is_connected(self) -> bool:
return True
class FakeBleakClientFailsHCI1Only(BaseFakeBleakClient):
"""Fake bleak client that fails to connect on hci1."""
async def connect(self, *args: Any, **kwargs: Any) -> None:
"""Connect."""
assert isinstance(self._device, BLEDevice)
if "/hci1/" in self._device.details["path"]:
raise BleakError("Failed to connect on hci1")
@property
def is_connected(self) -> bool:
return True
with patch(
"habluetooth.wrappers.get_platform_client_backend_type",
return_value=FakeBleakClientFailsHCI0Only,
):
# Should try to connect to hci0 first
with pytest.raises(BleakError):
await client.connect()
assert not client.is_connected
# Should try to connect with hci0 again
with pytest.raises(BleakError):
await client.connect()
assert not client.is_connected
# After two tries we should switch to hci1
await client.connect()
assert client.is_connected
# ..and we remember that hci1 works as long as the client doesn't change
await client.connect()
assert client.is_connected
# If we replace the client, we should remember hci0 is failing
client = bleak.BleakClient(ble_device)
await client.connect()
assert client.is_connected
with patch(
"habluetooth.wrappers.get_platform_client_backend_type",
return_value=FakeBleakClientFailsHCI1Only,
):
# Should try to connect to hci1 first
await client.connect()
assert client.is_connected
# Should work with hci0 on next attempt
await client.connect()
assert client.is_connected
# Next attempt should also use hci0
await client.connect()
assert client.is_connected
cancel_hci0()
cancel_hci1()
@pytest.mark.asyncio
async def test_switch_adapters_on_connecting(
two_adapters: None,
enable_bluetooth: None,
install_bleak_catcher: None,
) -> None:
"""Ensure we try the next best adapter after a failure."""
# hci0 has an rssi of -60, hci1 has an rssi of -80
hci0_device_advs, cancel_hci0, cancel_hci1 = _generate_scanners_with_fake_devices()
ble_device = hci0_device_advs["00:00:00:00:00:01"][0]
client = bleak.BleakClient(ble_device)
class FakeBleakClientSlowHCI0Connnect(BaseFakeBleakClient):
"""Fake bleak client that connects instantly on hci1 and slow on hci0."""
valid = False
async def connect(self, *args: Any, **kwargs: Any) -> None:
"""Connect."""
assert isinstance(self._device, BLEDevice)
if "/hci0/" in self._device.details["path"]:
await asyncio.sleep(0.4)
self.valid = True
else:
self.valid = True
@property
def is_connected(self) -> bool:
return self.valid
with patch(
"habluetooth.wrappers.get_platform_client_backend_type",
return_value=FakeBleakClientSlowHCI0Connnect,
):
task = asyncio.create_task(client.connect())
await asyncio.sleep(0.1)
assert not task.done()
task2 = asyncio.create_task(client.connect())
await asyncio.sleep(0.1)
assert task2.done()
await task2
assert client.is_connected
task3 = asyncio.create_task(client.connect())
await asyncio.sleep(0.1)
assert task3.done()
await task3
assert client.is_connected
await task
assert client.is_connected
cancel_hci0()
cancel_hci1()
@pytest.mark.asyncio
@pytest.mark.usefixtures("enable_bluetooth", "install_bleak_catcher")
async def test_single_adapter_connection_history(
caplog: pytest.LogCaptureFixture,
) -> None:
"""Test connection history failure count."""
manager = _get_manager()
scanner_hci0 = FakeScanner(HCI0_SOURCE_ADDRESS, "hci0", None, True)
unsub_hci0 = manager.async_register_scanner(scanner_hci0, connection_slots=2)
ble_device, adv_data = _generate_ble_device_and_adv_data(
"hci0", "00:00:00:00:00:11", rssi=-60
)
scanner_hci0.inject_advertisement(ble_device, adv_data)
service_info = manager.async_last_service_info(
ble_device.address, connectable=False
)
assert service_info is not None
assert service_info.source == HCI0_SOURCE_ADDRESS
client = bleak.BleakClient(ble_device)
class FakeBleakClientFastConnect(BaseFakeBleakClient):
"""Fake bleak client that connects instantly on hci1 and slow on hci0."""
valid = False
async def connect(self, *args: Any, **kwargs: Any) -> None:
"""Connect."""
assert isinstance(self._device, BLEDevice)
self.valid = "/hci0/" in self._device.details["path"]
@property
def is_connected(self) -> bool:
return self.valid
with patch(
"habluetooth.wrappers.get_platform_client_backend_type",
return_value=FakeBleakClientFastConnect,
):
await client.connect()
unsub_hci0()
@pytest.mark.asyncio
async def test_passing_subclassed_str_as_address(
two_adapters: None,
enable_bluetooth: None,
install_bleak_catcher: None,
) -> None:
"""Ensure the client wrapper can handle a subclassed str as the address."""
_, cancel_hci0, cancel_hci1 = _generate_scanners_with_fake_devices()
class SubclassedStr(str):
__slots__ = ()
address = SubclassedStr("00:00:00:00:00:01")
client = bleak.BleakClient(address)
class FakeBleakClient(BaseFakeBleakClient):
"""Fake bleak client."""
async def connect(self, *args, **kwargs):
"""Connect."""
return
@property
def is_connected(self) -> bool:
return True
with patch(
"habluetooth.wrappers.get_platform_client_backend_type",
return_value=FakeBleakClient,
):
await client.connect()
cancel_hci0()
cancel_hci1()
@pytest.mark.asyncio
async def test_find_device_by_address(
two_adapters: None,
enable_bluetooth: None,
install_bleak_catcher: None,
) -> None:
"""Ensure the client wrapper can handle a subclassed str as the address."""
_, _cancel_hci0, _cancel_hci1 = _generate_scanners_with_fake_devices()
device = await bleak.BleakScanner.find_device_by_address("00:00:00:00:00:01")
assert device.address == "00:00:00:00:00:01"
device = await bleak.BleakScanner().find_device_by_address("00:00:00:00:00:01")
assert device.address == "00:00:00:00:00:01"
@pytest.mark.asyncio
async def test_discover(
two_adapters: None,
enable_bluetooth: None,
install_bleak_catcher: None,
) -> None:
"""Ensure the discover is implemented."""
_, _cancel_hci0, _cancel_hci1 = _generate_scanners_with_fake_devices()
devices = await bleak.BleakScanner.discover()
assert any(device.address == "00:00:00:00:00:01" for device in devices)
devices_adv = await bleak.BleakScanner.discover(return_adv=True)
assert "00:00:00:00:00:01" in devices_adv
@pytest.mark.asyncio
async def test_raise_after_shutdown(
two_adapters: None,
enable_bluetooth: None,
install_bleak_catcher: None,
mock_platform_client_that_raises_on_connect: None,
) -> None:
"""Ensure the slot gets released on connection exception."""
manager = _get_manager()
hci0_device_advs, cancel_hci0, cancel_hci1 = _generate_scanners_with_fake_devices()
# hci0 has 2 slots, hci1 has 1 slot
with mock_shutdown(manager):
ble_device = hci0_device_advs["00:00:00:00:00:01"][0]
client = bleak.BleakClient(ble_device)
with pytest.raises(BleakError, match="shutdown"):
await client.connect()
cancel_hci0()
cancel_hci1()
@pytest.mark.usefixtures("enable_bluetooth")
@pytest.mark.asyncio
async def test_wrapped_instance_with_filter(
register_hci0_scanner: None,
) -> None:
"""Test wrapped instance with a filter as if it was normal BleakScanner."""
detected = []
def _device_detected(
device: BLEDevice, advertisement_data: AdvertisementData
) -> None:
"""Handle a detected device."""
detected.append((device, advertisement_data))
switchbot_device = generate_ble_device("44:44:33:11:23:45", "wohand")
switchbot_adv = generate_advertisement_data(
local_name="wohand",
service_uuids=["cba20d00-224d-11e6-9fb8-0002a5d5c51b"],
manufacturer_data={89: b"\xd8.\xad\xcd\r\x85"},
service_data={"00000d00-0000-1000-8000-00805f9b34fb": b"H\x10c"},
)
switchbot_adv_2 = generate_advertisement_data(
local_name="wohand",
service_uuids=["cba20d00-224d-11e6-9fb8-0002a5d5c51b"],
manufacturer_data={89: b"\xd8.\xad\xcd\r\x84"},
service_data={"00000d00-0000-1000-8000-00805f9b34fb": b"H\x10c"},
)
empty_device = generate_ble_device("11:22:33:44:55:66", "empty")
empty_adv = generate_advertisement_data(local_name="empty")
assert _get_manager() is not None
scanner = HaBleakScannerWrapper(
filters={"UUIDs": ["cba20d00-224d-11e6-9fb8-0002a5d5c51b"]}
)
scanner.register_detection_callback(_device_detected)
inject_advertisement(switchbot_device, switchbot_adv_2)
await asyncio.sleep(0)
discovered = await scanner.discover(timeout=0)
assert len(discovered) == 1
assert discovered == [switchbot_device]
assert len(detected) == 1
scanner.register_detection_callback(_device_detected)
# We should get a reply from the history when we register again
assert len(detected) == 2
scanner.register_detection_callback(_device_detected)
# We should get a reply from the history when we register again
assert len(detected) == 3
with patch_discovered_devices([]):
discovered = await scanner.discover(timeout=0)
assert len(discovered) == 0
assert discovered == []
inject_advertisement(switchbot_device, switchbot_adv)
assert len(detected) == 4
# The filter we created in the wrapped scanner with should be respected
# and we should not get another callback
inject_advertisement(empty_device, empty_adv)
assert len(detected) == 4
@pytest.mark.usefixtures("enable_bluetooth")
@pytest.mark.asyncio
async def test_wrapped_instance_with_service_uuids(
register_hci0_scanner: None,
) -> None:
"""Test wrapped instance with a service_uuids list as normal BleakScanner."""
detected = []
def _device_detected(
device: BLEDevice, advertisement_data: AdvertisementData
) -> None:
"""Handle a detected device."""
detected.append((device, advertisement_data))
switchbot_device = generate_ble_device("44:44:33:11:23:45", "wohand")
switchbot_adv = generate_advertisement_data(
local_name="wohand",
service_uuids=["cba20d00-224d-11e6-9fb8-0002a5d5c51b"],
manufacturer_data={89: b"\xd8.\xad\xcd\r\x85"},
service_data={"00000d00-0000-1000-8000-00805f9b34fb": b"H\x10c"},
)
switchbot_adv_2 = generate_advertisement_data(
local_name="wohand",
service_uuids=["cba20d00-224d-11e6-9fb8-0002a5d5c51b"],
manufacturer_data={89: b"\xd8.\xad\xcd\r\x84"},
service_data={"00000d00-0000-1000-8000-00805f9b34fb": b"H\x10c"},
)
empty_device = generate_ble_device("11:22:33:44:55:66", "empty")
empty_adv = generate_advertisement_data(local_name="empty")
assert _get_manager() is not None
scanner = HaBleakScannerWrapper(
service_uuids=["cba20d00-224d-11e6-9fb8-0002a5d5c51b"]
)
scanner.register_detection_callback(_device_detected)
inject_advertisement(switchbot_device, switchbot_adv)
inject_advertisement(switchbot_device, switchbot_adv_2)
await asyncio.sleep(0)
assert len(detected) == 2
# The UUIDs list we created in the wrapped scanner with should be respected
# and we should not get another callback
inject_advertisement(empty_device, empty_adv)
assert len(detected) == 2
@pytest.mark.usefixtures("enable_bluetooth")
@pytest.mark.asyncio
async def test_wrapped_instance_with_service_uuids_with_coro_callback(
register_hci0_scanner: None,
) -> None:
"""
Test wrapped instance with a service_uuids list as normal BleakScanner.
Verify that coro callbacks are supported.
"""
detected = []
async def _device_detected(
device: BLEDevice, advertisement_data: AdvertisementData
) -> None:
"""Handle a detected device."""
detected.append((device, advertisement_data))
switchbot_device = generate_ble_device("44:44:33:11:23:45", "wohand")
switchbot_adv = generate_advertisement_data(
local_name="wohand",
service_uuids=["cba20d00-224d-11e6-9fb8-0002a5d5c51b"],
manufacturer_data={89: b"\xd8.\xad\xcd\r\x85"},
service_data={"00000d00-0000-1000-8000-00805f9b34fb": b"H\x10c"},
)
switchbot_adv_2 = generate_advertisement_data(
local_name="wohand",
service_uuids=["cba20d00-224d-11e6-9fb8-0002a5d5c51b"],
manufacturer_data={89: b"\xd8.\xad\xcd\r\x84"},
service_data={"00000d00-0000-1000-8000-00805f9b34fb": b"H\x10c"},
)
empty_device = generate_ble_device("11:22:33:44:55:66", "empty")
empty_adv = generate_advertisement_data(local_name="empty")
assert _get_manager() is not None
scanner = HaBleakScannerWrapper(
service_uuids=["cba20d00-224d-11e6-9fb8-0002a5d5c51b"]
)
scanner.register_detection_callback(_device_detected)
inject_advertisement(switchbot_device, switchbot_adv)
inject_advertisement(switchbot_device, switchbot_adv_2)
await asyncio.sleep(0)
assert len(detected) == 2
# The UUIDs list we created in the wrapped scanner with should be respected
# and we should not get another callback
inject_advertisement(empty_device, empty_adv)
assert len(detected) == 2
@pytest.mark.usefixtures("enable_bluetooth")
@pytest.mark.asyncio
async def test_wrapped_instance_with_broken_callbacks(
register_hci0_scanner: None,
) -> None:
"""Test broken callbacks do not cause the scanner to fail."""
detected: list[tuple[BLEDevice, AdvertisementData]] = []
def _device_detected(
device: BLEDevice, advertisement_data: AdvertisementData
) -> None:
"""Handle a detected device."""
if detected:
raise ValueError
detected.append((device, advertisement_data))
switchbot_device = generate_ble_device("44:44:33:11:23:45", "wohand")
switchbot_adv = generate_advertisement_data(
local_name="wohand",
service_uuids=["cba20d00-224d-11e6-9fb8-0002a5d5c51b"],
manufacturer_data={89: b"\xd8.\xad\xcd\r\x85"},
service_data={"00000d00-0000-1000-8000-00805f9b34fb": b"H\x10c"},
)
assert _get_manager() is not None
scanner = HaBleakScannerWrapper(
service_uuids=["cba20d00-224d-11e6-9fb8-0002a5d5c51b"]
)
scanner.register_detection_callback(_device_detected)
inject_advertisement(switchbot_device, switchbot_adv)
await asyncio.sleep(0)
inject_advertisement(switchbot_device, switchbot_adv)
await asyncio.sleep(0)
assert len(detected) == 1
@pytest.mark.usefixtures("enable_bluetooth")
@pytest.mark.asyncio
async def test_wrapped_instance_changes_uuids(
register_hci0_scanner: None,
) -> None:
"""Test consumers can use the wrapped instance can change the uuids later."""
detected = []
def _device_detected(
device: BLEDevice, advertisement_data: AdvertisementData
) -> None:
"""Handle a detected device."""
detected.append((device, advertisement_data))
switchbot_device = generate_ble_device("44:44:33:11:23:45", "wohand")
switchbot_adv = generate_advertisement_data(
local_name="wohand",
service_uuids=["cba20d00-224d-11e6-9fb8-0002a5d5c51b"],
manufacturer_data={89: b"\xd8.\xad\xcd\r\x85"},
service_data={"00000d00-0000-1000-8000-00805f9b34fb": b"H\x10c"},
)
switchbot_adv_2 = generate_advertisement_data(
local_name="wohand",
service_uuids=["cba20d00-224d-11e6-9fb8-0002a5d5c51b"],
manufacturer_data={89: b"\xd8.\xad\xcd\r\x84"},
service_data={"00000d00-0000-1000-8000-00805f9b34fb": b"H\x10c"},
)
empty_device = generate_ble_device("11:22:33:44:55:66", "empty")
empty_adv = generate_advertisement_data(local_name="empty")
assert _get_manager() is not None
scanner = HaBleakScannerWrapper()
scanner.set_scanning_filter(service_uuids=["cba20d00-224d-11e6-9fb8-0002a5d5c51b"])
scanner.register_detection_callback(_device_detected)
inject_advertisement(switchbot_device, switchbot_adv)
inject_advertisement(switchbot_device, switchbot_adv_2)
await asyncio.sleep(0)
assert len(detected) == 2
# The UUIDs list we created in the wrapped scanner with should be respected
# and we should not get another callback
inject_advertisement(empty_device, empty_adv)
assert len(detected) == 2
@pytest.mark.usefixtures("enable_bluetooth")
@pytest.mark.asyncio
async def test_wrapped_instance_changes_filters(
register_hci0_scanner: None,
) -> None:
"""Test consumers can use the wrapped instance can change the filter later."""
detected = []
def _device_detected(
device: BLEDevice, advertisement_data: AdvertisementData
) -> None:
"""Handle a detected device."""
detected.append((device, advertisement_data))
switchbot_device = generate_ble_device("44:44:33:11:23:42", "wohand")
switchbot_adv = generate_advertisement_data(
local_name="wohand",
service_uuids=["cba20d00-224d-11e6-9fb8-0002a5d5c51b"],
manufacturer_data={89: b"\xd8.\xad\xcd\r\x85"},
service_data={"00000d00-0000-1000-8000-00805f9b34fb": b"H\x10c"},
)
switchbot_adv_2 = generate_advertisement_data(
local_name="wohand",
service_uuids=["cba20d00-224d-11e6-9fb8-0002a5d5c51b"],
manufacturer_data={89: b"\xd8.\xad\xcd\r\x84"},
service_data={"00000d00-0000-1000-8000-00805f9b34fb": b"H\x10c"},
)
empty_device = generate_ble_device("11:22:33:44:55:62", "empty")
empty_adv = generate_advertisement_data(local_name="empty")
assert _get_manager() is not None
scanner = HaBleakScannerWrapper()
scanner.set_scanning_filter(
filters={"UUIDs": ["cba20d00-224d-11e6-9fb8-0002a5d5c51b"]}
)
scanner.register_detection_callback(_device_detected)
inject_advertisement(switchbot_device, switchbot_adv)
inject_advertisement(switchbot_device, switchbot_adv_2)
await asyncio.sleep(0)
assert len(detected) == 2
# The UUIDs list we created in the wrapped scanner with should be respected
# and we should not get another callback
inject_advertisement(empty_device, empty_adv)
assert len(detected) == 2
@pytest.mark.usefixtures("enable_bluetooth")
@pytest.mark.asyncio
async def test_wrapped_instance_unsupported_filter(
register_hci0_scanner: None,
caplog: pytest.LogCaptureFixture,
) -> None:
"""Test we want when their filter is ineffective."""
assert _get_manager() is not None
scanner = HaBleakScannerWrapper()
scanner.set_scanning_filter(
filters={
"unsupported": ["cba20d00-224d-11e6-9fb8-0002a5d5c51b"],
"DuplicateData": True,
}
)
assert "Only UUIDs filters are supported" in caplog.text
@pytest.mark.asyncio
async def test_client_with_services_parameter(
two_adapters: None,
enable_bluetooth: None,
install_bleak_catcher: None,
mock_platform_client: None,
) -> None:
"""Test that services parameter is passed correctly to the backend."""
hci0_device_advs, cancel_hci0, cancel_hci1 = _generate_scanners_with_fake_devices()
ble_device = hci0_device_advs["00:00:00:00:00:01"][0]
test_services = [
"00001800-0000-1000-8000-00805f9b34fb",
"00001801-0000-1000-8000-00805f9b34fb",
]
# Track what services were passed to the backend
services_passed_to_backend = None
class FakeBleakClientTracksServices(BaseFakeBleakClient):
"""Fake bleak client that tracks services parameter."""
def __init__(
self, address_or_ble_device: BLEDevice | str, **kwargs: Any
) -> None:
"""Initialize and capture services."""
super().__init__(address_or_ble_device, **kwargs)
nonlocal services_passed_to_backend
services_passed_to_backend = kwargs.get("services")
async def connect(self, *args, **kwargs):
"""Connect."""
return True
@property
def is_connected(self):
return True
with patch(
"habluetooth.wrappers.get_platform_client_backend_type",
return_value=FakeBleakClientTracksServices,
):
client = bleak.BleakClient(ble_device, services=test_services)
await client.connect()
# Verify services were normalized and passed as a set
assert services_passed_to_backend is not None
assert isinstance(services_passed_to_backend, set)
assert services_passed_to_backend == {
"00001800-0000-1000-8000-00805f9b34fb",
"00001801-0000-1000-8000-00805f9b34fb",
}
cancel_hci0()
cancel_hci1()
@pytest.mark.asyncio
async def test_client_with_pair_parameter(
two_adapters: None,
enable_bluetooth: None,
install_bleak_catcher: None,
mock_platform_client: None,
) -> None:
"""Test that pair parameter is set correctly on the wrapper."""
hci0_device_advs, cancel_hci0, cancel_hci1 = _generate_scanners_with_fake_devices()
ble_device = hci0_device_advs["00:00:00:00:00:01"][0]
# Test default pair=False
client = bleak.BleakClient(ble_device)
assert client._pair_before_connect is False
# Test pair=True
client = bleak.BleakClient(ble_device, pair=True)
assert client._pair_before_connect is True
cancel_hci0()
cancel_hci1()
@pytest.mark.asyncio
async def test_client_services_normalization(
two_adapters: None,
enable_bluetooth: None,
install_bleak_catcher: None,
mock_platform_client: None,
) -> None:
"""Test that service UUIDs are normalized correctly."""
hci0_device_advs, cancel_hci0, cancel_hci1 = _generate_scanners_with_fake_devices()
ble_device = hci0_device_advs["00:00:00:00:00:01"][0]
# Test with short UUIDs that need normalization
test_services = ["1800", "1801", "CBA20D00-224D-11E6-9FB8-0002A5D5C51B"]
services_passed_to_backend = None
class FakeBleakClientTracksServices(BaseFakeBleakClient):
"""Fake bleak client that tracks services parameter."""
def __init__(
self, address_or_ble_device: BLEDevice | str, **kwargs: Any
) -> None:
"""Initialize and capture services."""
super().__init__(address_or_ble_device, **kwargs)
nonlocal services_passed_to_backend
services_passed_to_backend = kwargs.get("services")
async def connect(self, *args, **kwargs):
"""Connect."""
return True
@property
def is_connected(self):
return True
with patch(
"habluetooth.wrappers.get_platform_client_backend_type",
return_value=FakeBleakClientTracksServices,
):
client = bleak.BleakClient(ble_device, services=test_services)
await client.connect()
# Verify services were normalized
assert services_passed_to_backend is not None
assert isinstance(services_passed_to_backend, set)
assert services_passed_to_backend == {
"00001800-0000-1000-8000-00805f9b34fb",
"00001801-0000-1000-8000-00805f9b34fb",
"cba20d00-224d-11e6-9fb8-0002a5d5c51b", # Should be lowercased
}
cancel_hci0()
cancel_hci1()
@pytest.mark.asyncio
async def test_client_with_none_services(
two_adapters: None,
enable_bluetooth: None,
install_bleak_catcher: None,
mock_platform_client: None,
) -> None:
"""Test that None services parameter is handled correctly."""
hci0_device_advs, cancel_hci0, cancel_hci1 = _generate_scanners_with_fake_devices()
ble_device = hci0_device_advs["00:00:00:00:00:01"][0]
services_passed_to_backend = "not_set"
class FakeBleakClientTracksServices(BaseFakeBleakClient):
"""Fake bleak client that tracks services parameter."""
def __init__(
self, address_or_ble_device: BLEDevice | str, **kwargs: Any
) -> None:
"""Initialize and capture services."""
super().__init__(address_or_ble_device, **kwargs)
nonlocal services_passed_to_backend
services_passed_to_backend = kwargs.get("services", "not_set")
async def connect(self, *args, **kwargs):
"""Connect."""
return True
@property
def is_connected(self):
return True
with patch(
"habluetooth.wrappers.get_platform_client_backend_type",
return_value=FakeBleakClientTracksServices,
):
# Test with no services parameter (default None)
client = bleak.BleakClient(ble_device)
await client.connect()
assert services_passed_to_backend is None
# Reset the captured value
services_passed_to_backend = "not_set" # type: ignore[unreachable]
with patch(
"habluetooth.wrappers.get_platform_client_backend_type",
return_value=FakeBleakClientTracksServices,
):
# Test with explicit None
client = bleak.BleakClient(ble_device, services=None)
await client.connect()
assert services_passed_to_backend is None
cancel_hci0()
cancel_hci1()
@pytest.mark.usefixtures("enable_bluetooth")
@pytest.mark.asyncio
async def test_passive_only_scanner_error_message() -> None:
"""Test error message when all scanners are passive-only (like Shelly)."""
manager = _get_manager()
# Register a passive-only scanner (connectable=False)
scanner = FakeScanner(
"passive_scanner_1", "shelly_plus1pm_e86bea01020c", None, False
)
cancel = manager.async_register_scanner(scanner)
# Inject an advertisement from this passive scanner
device = generate_ble_device(
"00:00:00:00:00:01", "Test Device", {"source": "passive_scanner_1"}
)
adv_data = generate_advertisement_data(
local_name="Test Device",
service_uuids=[],
rssi=-50,
)
scanner.inject_advertisement(device, adv_data)
await asyncio.sleep(0) # Let the advertisement be processed
# Try to connect - should fail with our custom error message
client = bleak.BleakClient("00:00:00:00:00:01")
with pytest.raises(
BleakError,
match=r"00:00:00:00:00:01: No connectable Bluetooth adapters\. "
r"Shelly devices are passive-only and cannot connect\. "
r"Need local Bluetooth adapter or ESPHome proxy\. "
r"Available: shelly_plus1pm_e86bea01020c \(passive_scanner_1\)",
):
await client.connect()
cancel()
@pytest.mark.usefixtures("enable_bluetooth")
@pytest.mark.asyncio
async def test_passive_scanner_with_active_scanner() -> None:
"""Test normal error when there's a mix of passive and active scanners."""
manager = _get_manager()
# Register a passive-only scanner
passive_scanner = FakeScanner("passive_scanner", "shelly_device", None, False)
cancel_passive = manager.async_register_scanner(passive_scanner)
# Register an active scanner with no available slots
active_scanner = FakeScanner("active_scanner", "esphome_device", None, True)
cancel_active = manager.async_register_scanner(active_scanner)
# Inject advertisements from both scanners
device1 = generate_ble_device(
"00:00:00:00:00:02", "Test Device", {"source": "passive_scanner"}
)
device2 = generate_ble_device(
"00:00:00:00:00:02", "Test Device", {"source": "active_scanner"}
)
adv_data = generate_advertisement_data(
local_name="Test Device",
service_uuids=[],
rssi=-50,
)
passive_scanner.inject_advertisement(device1, adv_data)
active_scanner.inject_advertisement(device2, adv_data)
await asyncio.sleep(0) # Let the advertisements be processed
# Mock the slot allocation to fail (simulating no available slots)
with patch.object(manager.slot_manager, "allocate_slot", return_value=False):
# Should get the normal "no available slot" error, not the passive-only error
client = bleak.BleakClient("00:00:00:00:00:02")
with pytest.raises(
BleakError,
match=(
"No backend with an available connection slot that can reach "
"address 00:00:00:00:00:02 was found"
),
):
await client.connect()
cancel_passive()
cancel_active()
@pytest.mark.asyncio
async def test_connection_params_loading_with_bluez_mgmt(
two_adapters: None,
enable_bluetooth: None,
install_bleak_catcher: None,
caplog: pytest.LogCaptureFixture,
) -> None:
"""Test that connection parameters are loaded when mgmt API is available."""
manager = _get_manager()
hci0_device_advs, cancel_hci0, cancel_hci1 = _generate_scanners_with_fake_devices()
# Mock the bluez mgmt controller
mock_mgmt_ctl = Mock()
mock_mgmt_ctl.load_conn_params.return_value = True
class FakeBleakClientTracksConnect(BaseFakeBleakClient):
"""Fake bleak client that tracks connect."""
connected = False
async def connect(self, *args, **kwargs):
"""Connect."""
self.connected = True
# Simulate service discovery
await asyncio.sleep(0)
@property
def is_connected(self) -> bool:
return self.connected
# Test with debug logging enabled
with (
caplog.at_level(logging.DEBUG),
patch.object(manager, "get_bluez_mgmt_ctl", return_value=mock_mgmt_ctl),
patch(
"habluetooth.wrappers.get_platform_client_backend_type",
return_value=FakeBleakClientTracksConnect,
),
):
ble_device = hci0_device_advs["00:00:00:00:00:01"][0]
client = bleak.BleakClient(ble_device)
await client.connect()
# Verify load_conn_params was called twice (fast before connect, medium after)
assert mock_mgmt_ctl.load_conn_params.call_count == 2
# First call should be for FAST params
first_call = mock_mgmt_ctl.load_conn_params.call_args_list[0]
assert first_call[0][0] == 0 # adapter_idx
assert first_call[0][1] == "00:00:00:00:00:01" # address
assert first_call[0][2] == 1 # BDADDR_LE_PUBLIC (default)
assert first_call[0][3].value == "fast" # ConnectParams.FAST
# Second call should be for MEDIUM params
second_call = mock_mgmt_ctl.load_conn_params.call_args_list[1]
assert second_call[0][0] == 0 # adapter_idx
assert second_call[0][1] == "00:00:00:00:00:01" # address
assert second_call[0][2] == 1 # BDADDR_LE_PUBLIC
assert second_call[0][3].value == "medium" # ConnectParams.MEDIUM
# Verify debug logging
assert "Loaded ConnectParams.FAST connection parameters" in caplog.text
assert "Loaded ConnectParams.MEDIUM connection parameters" in caplog.text
cancel_hci0()
cancel_hci1()
@pytest.mark.asyncio
async def test_connection_params_not_loaded_without_mgmt(
two_adapters: None,
enable_bluetooth: None,
install_bleak_catcher: None,
caplog: pytest.LogCaptureFixture,
) -> None:
"""Test that connection parameters are not loaded when mgmt API is unavailable."""
manager = _get_manager()
hci0_device_advs, cancel_hci0, cancel_hci1 = _generate_scanners_with_fake_devices()
class FakeBleakClientTracksConnect(BaseFakeBleakClient):
"""Fake bleak client that tracks connect."""
connected = False
async def connect(self, *args, **kwargs):
"""Connect."""
self.connected = True
await asyncio.sleep(0)
@property
def is_connected(self) -> bool:
return self.connected
with (
caplog.at_level(logging.DEBUG),
patch.object(manager, "get_bluez_mgmt_ctl", return_value=None),
patch(
"habluetooth.wrappers.get_platform_client_backend_type",
return_value=FakeBleakClientTracksConnect,
),
):
ble_device = hci0_device_advs["00:00:00:00:00:01"][0]
client = bleak.BleakClient(ble_device)
await client.connect()
# Verify no connection parameters were loaded
assert "connection parameters" not in caplog.text
cancel_hci0()
cancel_hci1()
@pytest.mark.asyncio
async def test_get_device_address_type_random(
two_adapters: None,
enable_bluetooth: None,
install_bleak_catcher: None,
) -> None:
"""Test _get_device_address_type returns BDADDR_LE_RANDOM for random address."""
_hci0_device_advs, cancel_hci0, cancel_hci1 = _generate_scanners_with_fake_devices()
# Create a device with random address type
device = generate_ble_device(
"00:00:00:00:00:02",
"Test Device",
{
"path": "/org/bluez/hci0/dev_00_00_00_00_00_02",
"props": {"AddressType": "random"},
},
)
from habluetooth.const import BDADDR_LE_RANDOM
from habluetooth.wrappers import _get_device_address_type
assert _get_device_address_type(device) == BDADDR_LE_RANDOM
cancel_hci0()
cancel_hci1()
@pytest.mark.asyncio
async def test_get_device_address_type_public(
two_adapters: None,
enable_bluetooth: None,
install_bleak_catcher: None,
) -> None:
"""Test _get_device_address_type returns BDADDR_LE_PUBLIC for public address."""
hci0_device_advs, cancel_hci0, cancel_hci1 = _generate_scanners_with_fake_devices()
# Create a device with public address type (default)
device = hci0_device_advs["00:00:00:00:00:01"][0]
from habluetooth.const import BDADDR_LE_PUBLIC
from habluetooth.wrappers import _get_device_address_type
assert _get_device_address_type(device) == BDADDR_LE_PUBLIC
cancel_hci0()
cancel_hci1()
@pytest.mark.asyncio
async def test_connection_params_loading_fails_silently(
two_adapters: None,
enable_bluetooth: None,
install_bleak_catcher: None,
caplog: pytest.LogCaptureFixture,
) -> None:
"""Test that connection still succeeds even if loading params fails."""
manager = _get_manager()
hci0_device_advs, cancel_hci0, cancel_hci1 = _generate_scanners_with_fake_devices()
# Mock the bluez mgmt controller to fail loading params
mock_mgmt_ctl = Mock()
mock_mgmt_ctl.load_conn_params.return_value = False
class FakeBleakClientTracksConnect(BaseFakeBleakClient):
"""Fake bleak client that tracks connect."""
connected = False
async def connect(self, *args, **kwargs):
"""Connect."""
self.connected = True
await asyncio.sleep(0)
@property
def is_connected(self) -> bool:
return self.connected
with (
caplog.at_level(logging.DEBUG),
patch.object(manager, "get_bluez_mgmt_ctl", return_value=mock_mgmt_ctl),
patch(
"habluetooth.wrappers.get_platform_client_backend_type",
return_value=FakeBleakClientTracksConnect,
),
):
ble_device = hci0_device_advs["00:00:00:00:00:01"][0]
client = bleak.BleakClient(ble_device)
# Connection should succeed even though param loading failed
await client.connect()
# Verify load_conn_params was called
assert mock_mgmt_ctl.load_conn_params.call_count == 2
# But no success message should be logged
assert "Loaded" not in caplog.text
cancel_hci0()
cancel_hci1()
@pytest.mark.asyncio
async def test_connection_params_no_adapter_idx(
two_adapters: None,
enable_bluetooth: None,
install_bleak_catcher: None,
caplog: pytest.LogCaptureFixture,
) -> None:
"""Test that connection params are not loaded if scanner has no adapter_idx."""
manager = _get_manager()
# Mock the bluez mgmt controller
mock_mgmt_ctl = Mock()
mock_mgmt_ctl.load_conn_params.return_value = True
class FakeBleakClientTracksConnect(BaseFakeBleakClient):
"""Fake bleak client that tracks connect."""
connected = False
async def connect(self, *args, **kwargs):
"""Connect."""
self.connected = True
await asyncio.sleep(0)
@property
def is_connected(self) -> bool:
return self.connected
# Create a fake connector for the remote scanner
fake_connector = HaBluetoothConnector(
client=FakeBleakClientTracksConnect, source="any", can_connect=lambda: True
)
# Create a scanner without adapter_idx (e.g., remote scanner)
remote_scanner = FakeScanner(
"remote_scanner", "ESPHome Device", fake_connector, True
)
cancel_remote = manager.async_register_scanner(remote_scanner)
# Inject advertisement
device = generate_ble_device(
"00:00:00:00:00:03", "Test Device", {"source": "remote_scanner"}
)
adv_data = generate_advertisement_data(
local_name="Test Device",
service_uuids=[],
rssi=-50,
)
remote_scanner.inject_advertisement(device, adv_data)
await asyncio.sleep(0)
# Remote scanner should already have adapter_idx returning None
with (
caplog.at_level(logging.DEBUG),
patch.object(manager, "get_bluez_mgmt_ctl", return_value=mock_mgmt_ctl),
):
client = bleak.BleakClient("00:00:00:00:00:03")
await client.connect()
# Verify load_conn_params was NOT called since adapter_idx is None
assert mock_mgmt_ctl.load_conn_params.call_count == 0
cancel_remote()
@pytest.mark.asyncio
async def test_connection_path_scoring_with_slots_and_logging(
caplog: pytest.LogCaptureFixture,
) -> None:
"""Test connection path scoring and logging reflects slot availability."""
from bleak_retry_connector import Allocations
manager = _get_manager()
class FakeBleakClientNoConnect(BaseFakeBleakClient):
"""Fake bleak client that doesn't connect."""
async def connect(self, *args, **kwargs):
"""Don't actually connect."""
raise BleakError("Test - connection not needed")
# Create fake connectors
fake_connector_1 = HaBluetoothConnector(
client=FakeBleakClientNoConnect, source="scanner1", can_connect=lambda: True
)
fake_connector_2 = HaBluetoothConnector(
client=FakeBleakClientNoConnect, source="scanner2", can_connect=lambda: True
)
fake_connector_3 = HaBluetoothConnector(
client=FakeBleakClientNoConnect, source="scanner3", can_connect=lambda: True
)
# Create scanners with different sources
scanner1 = FakeScanner("scanner1", "Scanner 1", fake_connector_1, True)
scanner2 = FakeScanner("scanner2", "Scanner 2", fake_connector_2, True)
scanner3 = FakeScanner("scanner3", "Scanner 3", fake_connector_3, True)
# Mock get_allocations for each scanner using patch.object
with (
patch.object(
scanner1,
"get_allocations",
return_value=Allocations(
adapter="scanner1",
slots=3,
free=1, # Only 1 slot free - should get penalty
allocated=["AA:BB:CC:DD:EE:01", "AA:BB:CC:DD:EE:02"],
),
),
patch.object(
scanner2,
"get_allocations",
return_value=Allocations(
adapter="scanner2",
slots=3,
free=2, # 2 slots free - no penalty
allocated=["AA:BB:CC:DD:EE:03"],
),
),
patch.object(
scanner3,
"get_allocations",
return_value=Allocations(
adapter="scanner3",
slots=3,
free=3, # All slots free - no penalty
allocated=[],
),
),
):
cancel1 = manager.async_register_scanner(scanner1)
cancel2 = manager.async_register_scanner(scanner2)
cancel3 = manager.async_register_scanner(scanner3)
# Inject advertisements with different RSSI values
device1 = generate_ble_device(
"00:00:00:00:00:01", "Test Device", {"source": "scanner1"}
)
adv_data1 = generate_advertisement_data(local_name="Test Device", rssi=-60)
scanner1.inject_advertisement(device1, adv_data1)
device2 = generate_ble_device(
"00:00:00:00:00:01", "Test Device", {"source": "scanner2"}
)
adv_data2 = generate_advertisement_data(local_name="Test Device", rssi=-65)
scanner2.inject_advertisement(device2, adv_data2)
device3 = generate_ble_device(
"00:00:00:00:00:01", "Test Device", {"source": "scanner3"}
)
adv_data3 = generate_advertisement_data(local_name="Test Device", rssi=-70)
scanner3.inject_advertisement(device3, adv_data3)
await asyncio.sleep(0)
# Try to connect with logging enabled
with caplog.at_level(logging.INFO):
client = bleak.BleakClient("00:00:00:00:00:01")
with suppress(BleakError):
await client.connect()
# Check that the log contains the connection paths with correct scoring
log_text = caplog.text
assert "Found 3 connection path(s)" in log_text
# Extract the log line with connection paths
for line in caplog.text.splitlines():
if "Found 3 connection path(s)" in line:
# rssi_diff = best_rssi - second_best_rssi = -60 - (-65) = 5
# Scanner 1 has best RSSI (-60) but only 1 slot free, so with penalty:
# score = -60 - (5 * 0.76) = -63.8
assert "Scanner 1" in line
assert "(slots=1/3 free)" in line
assert "(score=-63.8)" in line
# Scanner 2 has RSSI -65 with 2 slots free, no penalty:
# score = -65
assert "Scanner 2" in line
assert "(slots=2/3 free)" in line
# Check for both -65 and -65.0
assert ("(score=-65)" in line) or ("(score=-65.0)" in line)
# Scanner 3 has RSSI -70 with all slots free, no penalty:
# score = -70
assert "Scanner 3" in line
assert "(slots=3/3 free)" in line
# Check for both -70 and -70.0
assert ("(score=-70)" in line) or ("(score=-70.0)" in line)
# Verify order: Scanner 1 should be first (best score -63.8),
# then Scanner 2 (-65), then Scanner 3 (-70)
scanner1_pos = line.find("Scanner 1")
scanner2_pos = line.find("Scanner 2")
scanner3_pos = line.find("Scanner 3")
assert scanner1_pos < scanner2_pos < scanner3_pos, (
f"Expected Scanner 1 before Scanner 2 before Scanner 3, "
f"but got positions {scanner1_pos}, {scanner2_pos}, {scanner3_pos}"
)
break
else:
pytest.fail("Could not find connection path log line")
cancel1()
cancel2()
cancel3()
@pytest.mark.asyncio
async def test_connection_path_scoring_no_slots_available(
caplog: pytest.LogCaptureFixture,
) -> None:
"""Test that scanners with no free slots are excluded."""
manager = _get_manager()
class FakeBleakClientNoConnect(BaseFakeBleakClient):
"""Fake bleak client that doesn't connect."""
async def connect(self, *args, **kwargs):
"""Don't actually connect."""
raise BleakError("Test - connection not needed")
# Create fake connectors
fake_connector_1 = HaBluetoothConnector(
client=FakeBleakClientNoConnect, source="scanner1", can_connect=lambda: True
)
fake_connector_2 = HaBluetoothConnector(
client=FakeBleakClientNoConnect, source="scanner2", can_connect=lambda: True
)
# Create scanners
scanner1 = FakeScanner("scanner1", "Scanner 1", fake_connector_1, True)
scanner2 = FakeScanner("scanner2", "Scanner 2", fake_connector_2, True)
# Mock get_allocations - scanner1 has no free slots
with (
patch.object(
scanner1,
"get_allocations",
return_value=Allocations(
adapter="scanner1",
slots=3,
free=0, # No slots available - should be excluded
allocated=[
"AA:BB:CC:DD:EE:01",
"AA:BB:CC:DD:EE:02",
"AA:BB:CC:DD:EE:03",
],
),
),
patch.object(
scanner2,
"get_allocations",
return_value=Allocations(
adapter="scanner2", slots=3, free=3, allocated=[] # All slots free
),
),
):
cancel1 = manager.async_register_scanner(scanner1)
cancel2 = manager.async_register_scanner(scanner2)
# Inject advertisements
device1 = generate_ble_device(
"00:00:00:00:00:02", "Test Device", {"source": "scanner1"}
)
adv_data1 = generate_advertisement_data(
local_name="Test Device", rssi=-50
) # Better RSSI
scanner1.inject_advertisement(device1, adv_data1)
device2 = generate_ble_device(
"00:00:00:00:00:02", "Test Device", {"source": "scanner2"}
)
adv_data2 = generate_advertisement_data(
local_name="Test Device", rssi=-70
) # Worse RSSI
scanner2.inject_advertisement(device2, adv_data2)
await asyncio.sleep(0)
# Try to connect with logging enabled
with caplog.at_level(logging.INFO):
client = bleak.BleakClient("00:00:00:00:00:02")
with suppress(BleakError):
await client.connect()
# Check that only scanner2 is in the connection paths
log_text = caplog.text
assert (
"Found 1 connection path(s)" in log_text
or "Found 2 connection path(s)" in log_text
)
# If both are shown, scanner1 should have bad score (NO_RSSI_VALUE = -127)
for line in caplog.text.splitlines():
if "connection path(s)" in line:
if "Scanner 1" in line:
# Scanner 1 should show 0 free slots and bad score
assert "(slots=0/3 free)" in line
assert "(score=-127)" in line # NO_RSSI_VALUE
# Scanner 2 should be present with normal score
assert "Scanner 2" in line
assert "(slots=3/3 free)" in line
# Check for both -70 and -70.0
assert ("(score=-70)" in line) or ("(score=-70.0)" in line)
break
cancel1()
cancel2()
@pytest.mark.asyncio
async def test_thundering_herd_connection_slots() -> None:
"""
Test thundering herd scenario with limited connection slots.
Simulates 7 devices trying to connect simultaneously to 3 proxies:
- Proxy 1 & 2: Good signal (-60 RSSI), 3 slots each
- Proxy 3: Bad signal (-95 RSSI), 3 slots each
Expected behavior:
- First 6 devices should connect to proxy1 and proxy2 (3 each)
- 7th device should connect to proxy3 (bad signal) when others are full
"""
from bleak_retry_connector import Allocations
manager = _get_manager()
# Track which backend each device connected to
connection_tracker = {}
class FakeBleakClientThunderingHerd(BaseFakeBleakClient):
"""Fake bleak client for thundering herd test."""
def __init__(self, address_or_ble_device, *args, **kwargs):
"""Initialize with tracking."""
super().__init__(address_or_ble_device, *args, **kwargs)
self._connected = False
# Track the device and source
if isinstance(address_or_ble_device, BLEDevice):
self._address = address_or_ble_device.address
self._source = address_or_ble_device.details.get("source")
else:
self._address = str(address_or_ble_device)
self._source = None
async def connect(self, *args, **kwargs):
"""Simulate connection and record which backend was used."""
# Small delay to simulate connection time
await asyncio.sleep(0.01)
self._connected = True
# Record which backend this device connected to
if self._address and self._source:
connection_tracker[self._address] = self._source
return True
@property
def is_connected(self) -> bool:
"""Return connection state."""
return self._connected
# Create fake connectors for 3 proxies
fake_connector_1 = HaBluetoothConnector(
client=FakeBleakClientThunderingHerd,
source="proxy1",
can_connect=lambda: True,
)
fake_connector_2 = HaBluetoothConnector(
client=FakeBleakClientThunderingHerd,
source="proxy2",
can_connect=lambda: True,
)
fake_connector_3 = HaBluetoothConnector(
client=FakeBleakClientThunderingHerd,
source="proxy3",
can_connect=lambda: True,
)
# Create 3 scanners (proxies) with 3 connection slots each
proxy1 = FakeScanner("proxy1", "Proxy 1 (Good)", fake_connector_1, True)
proxy2 = FakeScanner("proxy2", "Proxy 2 (Good)", fake_connector_2, True)
proxy3 = FakeScanner("proxy3", "Proxy 3 (Bad)", fake_connector_3, True)
# Track actual slot allocations dynamically
proxy_allocations: dict[str, set[str]] = {
"proxy1": set(),
"proxy2": set(),
"proxy3": set(),
}
def get_proxy_allocations(proxy_name: str) -> Allocations:
"""Get allocations for a specific proxy."""
allocated = proxy_allocations[proxy_name]
return Allocations(
adapter=proxy_name,
slots=3,
free=3 - len(allocated),
allocated=list(allocated),
)
# Mock methods to track allocations
def make_add_connecting(proxy_name: str) -> Callable[[str], None]:
def _add_connecting(addr: str) -> None:
proxy_allocations[proxy_name].add(addr)
return _add_connecting
def make_finished_connecting(proxy_name: str) -> Callable[[str, bool], None]:
def _finished_connecting(addr: str, success: bool) -> None:
if not success:
proxy_allocations[proxy_name].discard(addr)
return _finished_connecting
# Mock get_allocations and connection tracking
with (
patch.object(
proxy1, "get_allocations", lambda: get_proxy_allocations("proxy1")
),
patch.object(
proxy2, "get_allocations", lambda: get_proxy_allocations("proxy2")
),
patch.object(
proxy3, "get_allocations", lambda: get_proxy_allocations("proxy3")
),
patch.object(proxy1, "_add_connecting", make_add_connecting("proxy1")),
patch.object(proxy2, "_add_connecting", make_add_connecting("proxy2")),
patch.object(proxy3, "_add_connecting", make_add_connecting("proxy3")),
patch.object(
proxy1, "_finished_connecting", make_finished_connecting("proxy1")
),
patch.object(
proxy2, "_finished_connecting", make_finished_connecting("proxy2")
),
patch.object(
proxy3, "_finished_connecting", make_finished_connecting("proxy3")
),
):
cancel1 = manager.async_register_scanner(proxy1)
cancel2 = manager.async_register_scanner(proxy2)
cancel3 = manager.async_register_scanner(proxy3)
# Create 7 devices to connect
device_addresses = [f"AA:BB:CC:DD:EE:0{i}" for i in range(1, 8)]
# Inject advertisements for all devices on all proxies
for i, address in enumerate(device_addresses, 1):
# Good signal on proxy1
device1 = generate_ble_device(address, f"Device {i}", {"source": "proxy1"})
adv_data1 = generate_advertisement_data(local_name=f"Device {i}", rssi=-60)
proxy1.inject_advertisement(device1, adv_data1)
# Good signal on proxy2 (exactly same as proxy1)
device2 = generate_ble_device(address, f"Device {i}", {"source": "proxy2"})
adv_data2 = generate_advertisement_data(local_name=f"Device {i}", rssi=-60)
proxy2.inject_advertisement(device2, adv_data2)
# Bad signal on proxy3
device3 = generate_ble_device(address, f"Device {i}", {"source": "proxy3"})
adv_data3 = generate_advertisement_data(local_name=f"Device {i}", rssi=-95)
proxy3.inject_advertisement(device3, adv_data3)
await asyncio.sleep(0)
# Clear the connection tracker before starting
connection_tracker.clear()
async def connect_device(address: str) -> tuple[str, str | None]:
"""Try to connect to a device."""
client = bleak.BleakClient(address)
try:
await client.connect()
# The connection tracker should have recorded which backend was used
return address, connection_tracker.get(address, "unknown")
except BleakError:
# Connection failed (no available backend)
return address, None
# Simulate thundering herd - all devices try to connect at once
tasks = [connect_device(addr) for addr in device_addresses]
results = await asyncio.gather(*tasks, return_exceptions=True)
# Process results
connection_results = {}
for result in results:
if isinstance(result, tuple):
address, proxy = result
connection_results[address] = proxy
# Count connections per proxy
proxy1_connections = [
addr for addr, p in connection_results.items() if p == "proxy1"
]
proxy2_connections = [
addr for addr, p in connection_results.items() if p == "proxy2"
]
proxy3_connections = [
addr for addr, p in connection_results.items() if p == "proxy3"
]
failed_connections = [
addr for addr, p in connection_results.items() if p is None
]
# Verify constraints
# 1. No proxy should exceed its slot limit
assert (
len(proxy1_connections) <= 3
), f"Proxy1 exceeded slot limit: {len(proxy1_connections)} > 3"
assert (
len(proxy2_connections) <= 3
), f"Proxy2 exceeded slot limit: {len(proxy2_connections)} > 3"
assert (
len(proxy3_connections) <= 3
), f"Proxy3 exceeded slot limit: {len(proxy3_connections)} > 3"
# 2. Good signal proxies should be preferred and fill up first
good_proxy_total = len(proxy1_connections) + len(proxy2_connections)
assert (
good_proxy_total == 6
), f"Expected exactly 6 connections on good proxies, got {good_proxy_total}"
# 3. All 7 devices should connect (6 to good proxies, 1 to bad proxy)
total_connected = (
len(proxy1_connections) + len(proxy2_connections) + len(proxy3_connections)
)
assert (
total_connected == 7
), f"Expected all 7 devices to connect, but only {total_connected} did"
# 4. The 7th device should go to proxy3 since good ones are full
assert (
len(proxy3_connections) == 1
), f"Expected exactly 1 connection on proxy3, got {len(proxy3_connections)}"
# 5. Verify good distribution across proxy1 and proxy2
# Both should have roughly equal load (3 connections each)
assert (
len(proxy1_connections) == 3
), f"Expected proxy1 to have 3 connections, got {len(proxy1_connections)}"
assert (
len(proxy2_connections) == 3
), f"Expected proxy2 to have 3 connections, got {len(proxy2_connections)}"
# 6. No connections should fail
assert (
len(failed_connections) == 0
), f"Expected no failed connections, but {len(failed_connections)} failed"
# Clean up
cancel1()
cancel2()
cancel3()