pax_global_header 0000666 0000000 0000000 00000000064 15005442573 0014517 g ustar 00root root 0000000 0000000 52 comment=76c04adc5db1e7a039e5077d5c691bf3c9699829
habluetooth-3.48.2/ 0000775 0000000 0000000 00000000000 15005442573 0014133 5 ustar 00root root 0000000 0000000 habluetooth-3.48.2/.all-contributorsrc 0000664 0000000 0000000 00000000462 15005442573 0017766 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
}
habluetooth-3.48.2/.copier-answers.yml 0000664 0000000 0000000 00000001053 15005442573 0017674 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
habluetooth-3.48.2/.editorconfig 0000664 0000000 0000000 00000000444 15005442573 0016612 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
habluetooth-3.48.2/.github/ 0000775 0000000 0000000 00000000000 15005442573 0015473 5 ustar 00root root 0000000 0000000 habluetooth-3.48.2/.github/ISSUE_TEMPLATE/ 0000775 0000000 0000000 00000000000 15005442573 0017656 5 ustar 00root root 0000000 0000000 habluetooth-3.48.2/.github/ISSUE_TEMPLATE/1-bug_report.md 0000664 0000000 0000000 00000000422 15005442573 0022504 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.
habluetooth-3.48.2/.github/ISSUE_TEMPLATE/2-feature-request.md 0000664 0000000 0000000 00000000672 15005442573 0023465 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.
habluetooth-3.48.2/.github/dependabot.yml 0000664 0000000 0000000 00000001344 15005442573 0020325 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"
habluetooth-3.48.2/.github/labels.toml 0000664 0000000 0000000 00000003515 15005442573 0017636 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"
habluetooth-3.48.2/.github/workflows/ 0000775 0000000 0000000 00000000000 15005442573 0017530 5 ustar 00root root 0000000 0000000 habluetooth-3.48.2/.github/workflows/ci.yml 0000664 0000000 0000000 00000020306 15005442573 0020647 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@11bd71901bbe5b1630ceea73d27597364c9af683 # v4
- uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5
with:
python-version: 3.x
- uses: pre-commit/action@2c7b3805fd2a0fd8c1884dcaebf91fc102a13ecd # v3.0.1
# Make sure commit messages follow the conventional commits convention:
# https://www.conventionalcommits.org
commitlint:
name: Lint Commit Messages
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4
with:
fetch-depth: 0
- uses: wagoid/commitlint-github-action@b948419dd99f3fd78a6548d48f94e3df7f6bf3ed # v6.2.1
test:
strategy:
fail-fast: false
matrix:
python-version:
- "3.11"
- "3.12"
- "3.13"
os:
- ubuntu-latest
- macOS-latest
- windows-latest
extension:
- "skip_cython"
- "use_cython"
runs-on: ${{ matrix.os }}
steps:
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4
- name: Install poetry
run: pipx install poetry
- name: Set up Python
uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5
with:
python-version: ${{ matrix.python-version }}
cache: "poetry"
- name: Install Dependencies
run: |
if [ "${{ matrix.extension }}" = "skip_cython" ]; then
SKIP_CYTHON=1 poetry install --only=main,dev
else
REQUIRE_CYTHON=1 poetry install --only=main,dev
fi
shell: bash
- name: Test with Pytest
run: poetry run pytest --cov-report=xml
shell: bash
- name: Upload coverage to Codecov
uses: codecov/codecov-action@ad3126e916f78f00edff4ed0317cf185271ccc2d # v5
with:
token: ${{ secrets.CODECOV_TOKEN }}
benchmark:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4
- name: Install poetry
run: pipx install poetry
- name: Setup Python 3.13
uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5
with:
python-version: 3.13
cache: "poetry"
- name: Install Dependencies
run: |
REQUIRE_CYTHON=1 poetry install --only=main,dev
shell: bash
- name: Run benchmarks
uses: CodSpeedHQ/action@0010eb0ca6e89b80c88e8edaaa07cfe5f3e6664d # v3
with:
token: ${{ secrets.CODSPEED_TOKEN }}
run: poetry run pytest --no-cov -vvvvv --codspeed
release:
needs:
- test
- lint
- commitlint
runs-on: ubuntu-latest
environment: release
concurrency: release
permissions:
id-token: write
contents: write
outputs:
released: ${{ steps.release.outputs.released }}
newest_release_tag: ${{ steps.release.outputs.tag }}
steps:
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4
with:
fetch-depth: 0
ref: ${{ github.head_ref || github.ref_name }}
# Do a dry run of PSR
- name: Test release
uses: python-semantic-release/python-semantic-release@26bb37cfab71a5a372e3db0f48a6eac57519a4a6 # v9.21.0
if: github.ref_name != 'main'
with:
root_options: --noop
# On main branch: actual PSR + upload to PyPI & GitHub
- name: Release
uses: python-semantic-release/python-semantic-release@26bb37cfab71a5a372e3db0f48a6eac57519a4a6 # v9.21.0
id: release
if: github.ref_name == 'main'
with:
github_token: ${{ secrets.GITHUB_TOKEN }}
- name: Publish package distributions to PyPI
uses: pypa/gh-action-pypi-publish@76f52bc884231f62b9a034ebfe128415bbaabdfc # release/v1
if: steps.release.outputs.released == 'true'
- name: Publish package distributions to GitHub Releases
uses: python-semantic-release/upload-to-gh-release@0a92b5d7ebfc15a84f9801ebd1bf706343d43711 # main
if: steps.release.outputs.released == 'true'
with:
github_token: ${{ secrets.GITHUB_TOKEN }}
build_wheels:
needs: [release]
if: needs.release.outputs.released == 'true'
name: Wheels for ${{ matrix.os }} (${{ matrix.musl == 'musllinux' && 'musllinux' || 'manylinux' }}) ${{ matrix.qemu }} ${{ matrix.pyver }}
runs-on: ${{ matrix.os }}
strategy:
matrix:
os:
[
windows-latest,
ubuntu-24.04-arm,
ubuntu-latest,
macos-13,
macos-latest,
]
qemu: [""]
musl: [""]
pyver: [""]
include:
- os: ubuntu-latest
musl: "musllinux"
- os: ubuntu-24.04-arm
musl: "musllinux"
# qemu is slow, make a single
# runner per Python version
- os: ubuntu-latest
qemu: armv7l
musl: "musllinux"
pyver: cp311
- os: ubuntu-latest
qemu: armv7l
musl: "musllinux"
pyver: cp312
- os: ubuntu-latest
qemu: armv7l
musl: "musllinux"
pyver: cp313
# qemu is slow, make a single
# runner per Python version
- os: ubuntu-latest
qemu: armv7l
musl: ""
pyver: cp311
- os: ubuntu-latest
qemu: armv7l
musl: ""
pyver: cp312
- os: ubuntu-latest
qemu: armv7l
musl: ""
pyver: cp313
steps:
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4
with:
ref: ${{ needs.release.outputs.newest_release_tag }}
fetch-depth: 0
# Used to host cibuildwheel
- name: Set up Python
uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5
with:
python-version: "3.12"
- name: Set up QEMU
if: ${{ matrix.qemu }}
uses: docker/setup-qemu-action@29109295f81e9208d7d86ff1c6c12d2833863392 # v3
with:
platforms: all
# This should be temporary
# xref https://github.com/docker/setup-qemu-action/issues/188
# xref https://github.com/tonistiigi/binfmt/issues/215
image: tonistiigi/binfmt:qemu-v8.1.5
id: qemu
- name: Prepare emulation
if: ${{ matrix.qemu }}
run: |
if [[ -n "${{ matrix.qemu }}" ]]; then
# Build emulated architectures only if QEMU is set,
# use default "auto" otherwise
echo "CIBW_ARCHS_LINUX=${{ matrix.qemu }}" >> $GITHUB_ENV
fi
- name: Limit to a specific Python version on slow QEMU
if: ${{ matrix.pyver }}
run: |
if [[ -n "${{ matrix.pyver }}" ]]; then
echo "CIBW_BUILD=${{ matrix.pyver }}*" >> $GITHUB_ENV
fi
- name: Build wheels
uses: pypa/cibuildwheel@faf86a6ed7efa889faf6996aa23820831055001a # v2.23.3
env:
CIBW_SKIP: cp36-* cp37-* cp38-* cp39-* cp310-* pp* ${{ matrix.musl == 'musllinux' && '*manylinux*' || '*musllinux*' }}
REQUIRE_CYTHON: 1
- uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
with:
name: wheels-${{ matrix.os }}-${{ matrix.musl }}-${{ matrix.pyver }}-${{ matrix.qemu }}
path: ./wheelhouse/*.whl
upload_pypi:
needs: [build_wheels]
runs-on: ubuntu-latest
environment: release
permissions:
id-token: write # IMPORTANT: this permission is mandatory for trusted publishing
steps:
- uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4
with:
# unpacks default artifact into dist/
# if `name: artifact` is omitted, the action will create extra parent dir
path: dist
pattern: wheels-*
merge-multiple: true
- uses: pypa/gh-action-pypi-publish@76f52bc884231f62b9a034ebfe128415bbaabdfc # v1.12.4
habluetooth-3.48.2/.github/workflows/hacktoberfest.yml 0000664 0000000 0000000 00000000534 15005442573 0023101 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 }}
habluetooth-3.48.2/.github/workflows/issue-manager.yml 0000664 0000000 0000000 00000001340 15005442573 0023011 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."
}
}
habluetooth-3.48.2/.github/workflows/poetry-upgrade.yml 0000664 0000000 0000000 00000000337 15005442573 0023225 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 }}
habluetooth-3.48.2/.gitignore 0000664 0000000 0000000 00000004110 15005442573 0016117 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
# Distribution / packaging
.Python
build/
develop-eggs/
dist/
downloads/
eggs/
.eggs/
lib/
lib64/
parts/
sdist/
var/
wheels/
share/python-wheels/
*.egg-info/
.installed.cfg
*.egg
MANIFEST
# PyInstaller
# Usually these files are written by a python script from a template
# before PyInstaller builds the exe, so as to inject date/other infos into it.
*.manifest
*.spec
# Installer logs
pip-log.txt
pip-delete-this-directory.txt
# Unit test / coverage reports
htmlcov/
.tox/
.nox/
.coverage
.coverage.*
.cache
nosetests.xml
coverage.xml
*.cover
*.py,cover
.hypothesis/
.pytest_cache/
cover/
# Translations
*.mo
*.pot
# Django stuff:
*.log
local_settings.py
db.sqlite3
db.sqlite3-journal
# Flask stuff:
instance/
.webassets-cache
# Scrapy stuff:
.scrapy
# Sphinx documentation
docs/_build/
# PyBuilder
.pybuilder/
target/
# Jupyter Notebook
.ipynb_checkpoints
# IPython
profile_default/
ipython_config.py
# pyenv
# For a library or package, you might want to ignore these files since the code is
# intended to run in multiple environments; otherwise, check them in:
# .python-version
# pipenv
# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
# However, in case of collaboration, if having platform-specific dependencies or dependencies
# having no cross-platform support, pipenv may install dependencies that don't work, or not
# install all needed dependencies.
#Pipfile.lock
# PEP 582; used by e.g. github.com/David-OConnor/pyflow
__pypackages__/
# Celery stuff
celerybeat-schedule
celerybeat.pid
# SageMath parsed files
*.sage.py
# Environments
.env
.venv
env/
venv/
ENV/
env.bak/
venv.bak/
# Spyder {{package_name}} settings
.spyderproject
.spyproject
# Rope {{package_name}} settings
.ropeproject
# mkdocs documentation
/site
# mypy
.mypy_cache/
.dmypy.json
dmypy.json
# Pyre type checker
.pyre/
# pytype static type analyzer
.pytype/
# Cython debug symbols
cython_debug/
habluetooth-3.48.2/.gitpod.yml 0000664 0000000 0000000 00000000306 15005442573 0016221 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
habluetooth-3.48.2/.idea/ 0000775 0000000 0000000 00000000000 15005442573 0015113 5 ustar 00root root 0000000 0000000 habluetooth-3.48.2/.idea/habluetooth.iml 0000664 0000000 0000000 00000000515 15005442573 0020135 0 ustar 00root root 0000000 0000000
habluetooth-3.48.2/.idea/watcherTasks.xml 0000664 0000000 0000000 00000005253 15005442573 0020305 0 ustar 00root root 0000000 0000000
habluetooth-3.48.2/.idea/workspace.xml 0000664 0000000 0000000 00000002736 15005442573 0017643 0 ustar 00root root 0000000 0000000
habluetooth-3.48.2/.pre-commit-config.yaml 0000664 0000000 0000000 00000003070 15005442573 0020414 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.6.0
hooks:
- id: commitizen
stages: [commit-msg]
- repo: https://github.com/pre-commit/pre-commit-hooks
rev: v5.0.0
hooks:
- id: debug-statements
- id: check-builtin-literals
- id: check-case-conflict
- id: check-docstring-first
- id: check-json
- id: check-toml
- id: check-xml
- id: check-yaml
- id: detect-private-key
- id: end-of-file-fixer
- id: trailing-whitespace
- repo: https://github.com/python-poetry/poetry
rev: 2.1.2
hooks:
- id: poetry-check
- repo: https://github.com/pre-commit/mirrors-prettier
rev: v4.0.0-alpha.8
hooks:
- id: prettier
args: ["--tab-width", "2"]
- repo: https://github.com/astral-sh/ruff-pre-commit
rev: v0.11.7
hooks:
- id: ruff
args: [--fix, --exit-non-zero-on-fix]
- repo: https://github.com/psf/black
rev: 25.1.0
hooks:
- id: black
- repo: https://github.com/codespell-project/codespell
rev: v2.4.1
hooks:
- id: codespell
- repo: https://github.com/pre-commit/mirrors-mypy
rev: v1.15.0
hooks:
- id: mypy
additional_dependencies: []
habluetooth-3.48.2/.readthedocs.yml 0000664 0000000 0000000 00000001051 15005442573 0017216 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
habluetooth-3.48.2/CHANGELOG.md 0000664 0000000 0000000 00000055635 15005442573 0015762 0 ustar 00root root 0000000 0000000 # Changelog
## v3.48.2 (2025-05-03)
### Bug fixes
- Remove duplicate _connecting slot from basehascanner (#229) ([`230bb03`](https://github.com/Bluetooth-Devices/habluetooth/commit/230bb038eea8ae07a3fe798ec15792489d06cd66))
## v3.48.1 (2025-05-03)
### Bug fixes
- Pin cython to <3.1 (#228) ([`21dc734`](https://github.com/Bluetooth-Devices/habluetooth/commit/21dc7340c548713c4539d8d8a067a2a574623906))
## v3.48.0 (2025-05-03)
### Features
- Refactor scanner history to live on the scanner itself (#227) ([`ea0d2fc`](https://github.com/Bluetooth-Devices/habluetooth/commit/ea0d2fc088832a1b3f8c7859c82e2e05bf1261f9))
## v3.47.1 (2025-05-03)
### Bug fixes
- Ensure logging does not fail when there is only a single scanner (#225) ([`d81378e`](https://github.com/Bluetooth-Devices/habluetooth/commit/d81378e6b4adedead6d04ab23be7b655cd3785fb))
## v3.47.0 (2025-05-03)
### Bug fixes
- Require bluetooth-auto-recovery >= 1.5.1 (#224) ([`8164ce5`](https://github.com/Bluetooth-Devices/habluetooth/commit/8164ce512084fe898cb80c5e44f664dde4751113))
### Features
- Avoid thundering heard of connections (#223) ([`943cc20`](https://github.com/Bluetooth-Devices/habluetooth/commit/943cc2043731f8d6fbb541f4d7ffcd37d8c6b4f3))
## v3.46.0 (2025-05-03)
### Features
- Improve recovery when adapter has gone silent and needs a usb reset (#222) ([`a4dd395`](https://github.com/Bluetooth-Devices/habluetooth/commit/a4dd395b7a8e70cb0ae94d97422d35eb638daaa5))
## v3.45.0 (2025-04-29)
### Features
- Improve performance of _async_on_advertisement_internal (#220) ([`be0b5a6`](https://github.com/Bluetooth-Devices/habluetooth/commit/be0b5a6d0da07f2f881984c92c0c7671117d3e5a))
## v3.44.0 (2025-04-28)
### Features
- Save the raw data in storage (#217) ([`eaf4107`](https://github.com/Bluetooth-Devices/habluetooth/commit/eaf41072ecc915b2de23ad3c9a03148f4b313f17))
## v3.43.0 (2025-04-28)
### Features
- Migrate storage code from bluetooth_adapters (#216) ([`5d671f9`](https://github.com/Bluetooth-Devices/habluetooth/commit/5d671f95b9a7964bfa871c7b42061a71a98ce80e))
## v3.42.0 (2025-04-27)
### Features
- Add raw field to bluetoothserviceinfobleak (#214) ([`343f18b`](https://github.com/Bluetooth-Devices/habluetooth/commit/343f18bfbbf3ebbee31e64beab60b2686700797f))
## v3.41.0 (2025-04-27)
### Features
- Add new _async_on_raw_advertisement base scanner api (#213) ([`fb2a487`](https://github.com/Bluetooth-Devices/habluetooth/commit/fb2a487c06cf102c17509410f916b5c06728df98))
## v3.40.0 (2025-04-27)
### Features
- Require bluetooth-data-tools 1.28.0 or later (#212) ([`e154136`](https://github.com/Bluetooth-Devices/habluetooth/commit/e154136db9f15d33c6de3d89bf9e4e53e03c690a))
## v3.39.0 (2025-04-17)
### Features
- Improve performance of _async_on_advertisement (#209) ([`0fc0500`](https://github.com/Bluetooth-Devices/habluetooth/commit/0fc0500d74cdc3d320111df979bef784a51a2eac))
## v3.38.1 (2025-04-14)
### Bug fixes
- Add missing dbus-fast dep on linux (#207) ([`5746448`](https://github.com/Bluetooth-Devices/habluetooth/commit/57464488482626577e9f84c42ab1ff100b7857b3))
## v3.38.0 (2025-03-22)
### Bug fixes
- Use project.license key (#196) ([`1decf97`](https://github.com/Bluetooth-Devices/habluetooth/commit/1decf9704f7db33bc8094880651321c1b58420c8))
### Features
- Improve performance of previous source checks (#192) ([`8d96528`](https://github.com/Bluetooth-Devices/habluetooth/commit/8d96528f605231f3089319c789390d784c45b4c5))
## v3.37.0 (2025-03-21)
### Features
- Improve performance of _prefer_previous_adv_from_different_source (#191) ([`73ec210`](https://github.com/Bluetooth-Devices/habluetooth/commit/73ec2107375be217ffb0310194be8c3d4f20e150))
## v3.36.0 (2025-03-21)
### Features
- Improve performance of filtering apple data (#188) ([`9f56840`](https://github.com/Bluetooth-Devices/habluetooth/commit/9f568405ae987de0fb3953d6ae7b39eabacde9ef))
## v3.35.0 (2025-03-21)
### Features
- Optimize previous local name matching (#187) ([`fadb722`](https://github.com/Bluetooth-Devices/habluetooth/commit/fadb722b8ded2bc15bd56b641a963d4c4d19838e))
## v3.34.1 (2025-03-21)
### Bug fixes
- Revert adding _async_on_advertisements (#185) ([`4bc3cb8`](https://github.com/Bluetooth-Devices/habluetooth/commit/4bc3cb89baf52570deec4f27ed3cd935249525ec))
## v3.34.0 (2025-03-21)
### Features
- Rename _async_on_raw_advertisement to _async_on_raw_advertisements (#184) ([`b3acb88`](https://github.com/Bluetooth-Devices/habluetooth/commit/b3acb882d888a33567ece3e7f9d0fa1d2b4c6acd))
## v3.33.0 (2025-03-21)
### Features
- Add _async_on_raw_advertisement (#183) ([`24d128f`](https://github.com/Bluetooth-Devices/habluetooth/commit/24d128fe4854135647e9a41c7eeaf1784fbda0bf))
## v3.32.0 (2025-03-15)
### Features
- Improve performance of dispatching discovery info to subclasses (#181) ([`d0fae7d`](https://github.com/Bluetooth-Devices/habluetooth/commit/d0fae7ddd9158903f6621888cc4c75480822ae35))
## v3.31.0 (2025-03-15)
### Features
- Avoid building on demand advertisementdata if there are no bleak callbacks (#180) ([`ae977b9`](https://github.com/Bluetooth-Devices/habluetooth/commit/ae977b9d53c29c581ff6394a2078d2a2b01066dd))
## v3.30.0 (2025-03-15)
### Features
- Improve performance of on demand advertisementdata construction (#179) ([`ab005cb`](https://github.com/Bluetooth-Devices/habluetooth/commit/ab005cbef5e2ece74a0facd502fca7173ba2b1fc))
## v3.29.0 (2025-03-15)
### Features
- Improve performance for device with large manufacturer data history (#170) ([`ec1f6aa`](https://github.com/Bluetooth-Devices/habluetooth/commit/ec1f6aa7989cea2a589029362461dba4f7a8f0db))
## v3.28.0 (2025-03-15)
### Features
- Improve performance of local name checks (#173) ([`9f57d2f`](https://github.com/Bluetooth-Devices/habluetooth/commit/9f57d2fcc23595b376d5785162c21633514f44bd))
## v3.27.0 (2025-03-14)
### Features
- Improve performance of base_scanner (#168) ([`5b8c59c`](https://github.com/Bluetooth-Devices/habluetooth/commit/5b8c59c7ffadead5997fa457b07ff37ec8ec31b5))
## v3.26.0 (2025-03-14)
### Features
- Improve manager performance (#167) ([`e0bdace`](https://github.com/Bluetooth-Devices/habluetooth/commit/e0bdace8180ff3ac450447be99f700fd647fb659))
## v3.25.1 (2025-03-13)
### Bug fixes
- Downgrade scanner gone quiet logger to debug (#166) ([`d450ffc`](https://github.com/Bluetooth-Devices/habluetooth/commit/d450ffca38dec015f44b5be08af484fe8ca09866))
## v3.25.0 (2025-03-05)
### Bug fixes
- Use trusted publishing for wheels (#163) ([`c726687`](https://github.com/Bluetooth-Devices/habluetooth/commit/c726687affb0025037676b76cf4ecefdef0da23f))
### Features
- Add armv7l to wheel builds (#162) ([`e394707`](https://github.com/Bluetooth-Devices/habluetooth/commit/e394707b6b7ffc54e6dc5b8c038a08c5404f1777))
- Reduce wheel sizes (#161) ([`5e6b644`](https://github.com/Bluetooth-Devices/habluetooth/commit/5e6b64476ff2db7a215d1b0d58ef01c04b839d34))
## v3.24.1 (2025-02-27)
### Bug fixes
- Update scanner discover signature for newer bleak (#157) ([`a071cb8`](https://github.com/Bluetooth-Devices/habluetooth/commit/a071cb8e3f921da30055b94a74a4b0aa339e53de))
## v3.24.0 (2025-02-22)
### Features
- Improve logging of scanner failures and time_since_last_detection (#155) ([`f0ff045`](https://github.com/Bluetooth-Devices/habluetooth/commit/f0ff04586849bda3933fbe98e8e1335c308999c4))
## v3.23.0 (2025-02-21)
### Features
- Add debug logging for connection paths (#154) ([`562d469`](https://github.com/Bluetooth-Devices/habluetooth/commit/562d46912e7596febc3ebcc0301280e6f334172b))
## v3.22.1 (2025-02-20)
### Bug fixes
- Try to force stop discovery if its stuck on (#153) ([`e28d836`](https://github.com/Bluetooth-Devices/habluetooth/commit/e28d836d28f0b8062831ee209ba54a7735c4d5ae))
## v3.22.0 (2025-02-18)
### Features
- Allow remote scanners to set current and requested mode (#151) ([`a39ba18`](https://github.com/Bluetooth-Devices/habluetooth/commit/a39ba184e0d01f983133534e4fd7c1b6202210fb))
## v3.21.1 (2025-02-04)
### Bug fixes
- Update poetry to v2 (#147) ([`aefe36e`](https://github.com/Bluetooth-Devices/habluetooth/commit/aefe36e2507566224267f371511c1f1c748a37a9))
## v3.21.0 (2025-02-01)
### Features
- Reduce remote scanner adv processing overhead (#140) ([`7bf302b`](https://github.com/Bluetooth-Devices/habluetooth/commit/7bf302bac3855cf7e229dd2744acce513b2e2ee4))
## v3.20.1 (2025-02-01)
### Bug fixes
- Remove unused centralbluetoothmanager in models (#138) ([`7466034`](https://github.com/Bluetooth-Devices/habluetooth/commit/74660343b30fec50b927fdddd92e72eacb4da6cf))
- Precision loss when comparing advs from different sources (#136) ([`02279a9`](https://github.com/Bluetooth-Devices/habluetooth/commit/02279a95ca5b590768bd631bf39ee507a64db7ad))
## v3.20.0 (2025-02-01)
### Features
- Reduce adv tracker overhead (#137) ([`69168a6`](https://github.com/Bluetooth-Devices/habluetooth/commit/69168a64572ab3fba696d2afedeb015953afb0cc))
## v3.19.0 (2025-02-01)
### Features
- Reduce overhead to convert non-connectable bluetoothserviceinfobleak to connectable (#135) ([`37fc839`](https://github.com/Bluetooth-Devices/habluetooth/commit/37fc839d5fc73ff6f784ec8041606be82d58322b))
## v3.18.0 (2025-02-01)
### Features
- Refactor scanner_adv_received to reduce ref counting (#134) ([`a1945ce`](https://github.com/Bluetooth-Devices/habluetooth/commit/a1945cedc2373082814e8f4b4426a50c79788305))
## v3.17.1 (2025-01-31)
### Bug fixes
- Ensure allocations are available if the adapter never makes any connections (#131) ([`b3dfa48`](https://github.com/Bluetooth-Devices/habluetooth/commit/b3dfa48dba2482c16f61fceaf9a0f58ea55df982))
## v3.17.0 (2025-01-31)
### Features
- Remove the need to call set_manager to set up (#130) ([`1312bf7`](https://github.com/Bluetooth-Devices/habluetooth/commit/1312bf7d978ff585e66d99bde766e85773fce006))
## v3.16.0 (2025-01-31)
### Features
- Allow bluetoothmanager to be created with defaults (#129) ([`70b2f69`](https://github.com/Bluetooth-Devices/habluetooth/commit/70b2f6952fbd3ecd499a4c66ec305869158a428e))
## v3.15.0 (2025-01-31)
### Features
- Include findmy packets in wanted adverts (#127) ([`5217850`](https://github.com/Bluetooth-Devices/habluetooth/commit/5217850934bfed5d8e70f8b43c84cd97cf53cdac))
## v3.14.0 (2025-01-29)
### Features
- Add allocations to diagnostics (#126) ([`aa41088`](https://github.com/Bluetooth-Devices/habluetooth/commit/aa4108872478720ab4cbcf52c5add015441fe72d))
## v3.13.0 (2025-01-28)
### Features
- Add async_register_scanner_registration_callback and async_current_scanners to the manager (#125) ([`99fcb46`](https://github.com/Bluetooth-Devices/habluetooth/commit/99fcb46a73ea6cb8f01817263d01a342365be78f))
## v3.12.0 (2025-01-22)
### Features
- Add support for connection allocations for non-connectable scanners (#120) ([`d76b7c9`](https://github.com/Bluetooth-Devices/habluetooth/commit/d76b7c9624b6c4e6beedc1bd56dd1a3c0df70eec))
## v3.11.2 (2025-01-22)
### Bug fixes
- Re-release again for failed arm runners (#119) ([`af2bb50`](https://github.com/Bluetooth-Devices/habluetooth/commit/af2bb50879713378a32339e490a57b56083a4fa7))
## v3.11.1 (2025-01-22)
### Bug fixes
- Re-release due to failed github action (#118) ([`90e2192`](https://github.com/Bluetooth-Devices/habluetooth/commit/90e2192ff75c13ccf610fd06a61e64d60dfd1a18))
## v3.11.0 (2025-01-22)
### Features
- Add api for getting current slot allocations (#116) ([`0a9bef9`](https://github.com/Bluetooth-Devices/habluetooth/commit/0a9bef927c5f29c3e724fb60aa06706b6d896f82))
## v3.10.0 (2025-01-21)
### Features
- Add support for getting callbacks when adapter allocations change (#115) ([`c6fd2ba`](https://github.com/Bluetooth-Devices/habluetooth/commit/c6fd2babf0c6438ff85220edef95df3d3b4fae9c))
## v3.9.2 (2025-01-20)
### Bug fixes
- Increase rssi switch value to 16 (#111) ([`db367db`](https://github.com/Bluetooth-Devices/habluetooth/commit/db367dbef3fa883348a72cf17e29d9c26a09de53))
## v3.9.1 (2025-01-20)
### Bug fixes
- Increase rssi switch threshold for advertisements (#110) ([`297c269`](https://github.com/Bluetooth-Devices/habluetooth/commit/297c2693f9a2c007f0e70175c24416c8bb7da099))
## v3.9.0 (2025-01-17)
### Features
- Switch to native arm runners for wheel builds (#106) ([`bf7e98b`](https://github.com/Bluetooth-Devices/habluetooth/commit/bf7e98b099597916bb7566eb03472023f8acef97))
## v3.8.0 (2025-01-10)
### Features
- Add async_register_disappeared_callback (#102) ([`ec1d445`](https://github.com/Bluetooth-Devices/habluetooth/commit/ec1d4456ca15c6fca3248f2e5d73fcb1ba9d36c6))
## v3.7.0 (2025-01-05)
### Bug fixes
- Publish workflow (#99) ([`341c8a4`](https://github.com/Bluetooth-Devices/habluetooth/commit/341c8a4b72fb2818a3bed44632048d8570fc3b67))
### Features
- Start building wheels for python 3.13 (#97) ([`26dd831`](https://github.com/Bluetooth-Devices/habluetooth/commit/26dd831c28f3c0dfe0745769749e795e7937c7df))
- Add codspeed benchmarks (#79) ([`5905fbd`](https://github.com/Bluetooth-Devices/habluetooth/commit/5905fbd2c54adea04c0e55fe8a299f771e6f31ed))
### Unknown
## v3.6.0 (2024-10-20)
### Features
- Speed up creation of advertisementdata namedtuple (#75) ([`28f7e60`](https://github.com/Bluetooth-Devices/habluetooth/commit/28f7e6093c3985da16e537bc9d989d839ad80c56))
## v3.5.0 (2024-10-05)
### Features
- Add support for python 3.13 (#71) ([`b8a4783`](https://github.com/Bluetooth-Devices/habluetooth/commit/b8a4783a43f6e771321974d2c085e5e0dda9e195))
## v3.4.1 (2024-09-22)
### Bug fixes
- Ensure build system required cython 3 (#69) ([`dc85d2f`](https://github.com/Bluetooth-Devices/habluetooth/commit/dc85d2fd1b8c8e4d8eb4515aa60af06782fc8722))
## v3.4.0 (2024-09-02)
### Features
- Add a fast cython init path for bluetoothserviceinfobleak (#48) ([`f532ed2`](https://github.com/Bluetooth-Devices/habluetooth/commit/f532ed215b429f0bbd14dacc30f87c53f22af245))
## v3.3.2 (2024-08-20)
### Bug fixes
- Disable 3.13 wheels (#64) ([`9e8bbff`](https://github.com/Bluetooth-Devices/habluetooth/commit/9e8bbff6179e08bd6e05341ff48fff3adc5c6157))
## v3.3.1 (2024-08-20)
### Bug fixes
- Bump cibuildwheel to fix wheel builds (#63) ([`68d838a`](https://github.com/Bluetooth-Devices/habluetooth/commit/68d838a1d2adab9efe1fb5eba65e81b5dcc9a351))
## v3.3.0 (2024-08-20)
### Bug fixes
- Cleanup advertisementmonitor mapper (#61) ([`7d3483d`](https://github.com/Bluetooth-Devices/habluetooth/commit/7d3483d87d3e03c19cf528a1838acce5b194533e))
### Features
- Override devicefound and devicelost for passive monitoring (#60) ([`a802859`](https://github.com/Bluetooth-Devices/habluetooth/commit/a8028596bf3576a35750ae8575f173c75f918f28))
## v3.2.0 (2024-07-27)
### Features
- Small speed ups to scanner detection callback (#55) ([`7a5129a`](https://github.com/Bluetooth-Devices/habluetooth/commit/7a5129a40a12382c089453880210c41bb0f28a32))
## v3.1.3 (2024-06-24)
### Bug fixes
- Wheel builds (#50) ([`b9a8eec`](https://github.com/Bluetooth-Devices/habluetooth/commit/b9a8eec4f79c2098c0ec318b6b1ff7e3376febf2))
## v3.1.2 (2024-06-24)
### Bug fixes
- Fix license classifier (#49) ([`04aaaa1`](https://github.com/Bluetooth-Devices/habluetooth/commit/04aaaa186c755b869c8d75678f563f6a9c089829))
## v3.1.1 (2024-05-23)
### Bug fixes
- Missing classmethod decorator on find_device_by_address (#47) ([`aa08b13`](https://github.com/Bluetooth-Devices/habluetooth/commit/aa08b136660cddea7c356274c21f20b6d0eef1fa))
## v3.1.0 (2024-05-22)
### Features
- Speed up dispatching bleak callbacks (#46) ([`cbc8b26`](https://github.com/Bluetooth-Devices/habluetooth/commit/cbc8b26f90b9ea4f2a8569c0625b527dd37ef180))
## v3.0.1 (2024-05-03)
### Bug fixes
- Ensure lazy advertisement uses none when name is not present (#44) ([`c300f73`](https://github.com/Bluetooth-Devices/habluetooth/commit/c300f73ba82d3549ea4c156ef11023e9478c8b6c))
## v3.0.0 (2024-05-02)
### Features
- Make generation of advertisementdata lazy (#42) ([`25f8437`](https://github.com/Bluetooth-Devices/habluetooth/commit/25f843795927ad663a1d5ef1fa9472ec366b9da5))
## v2.8.1 (2024-05-02)
### Bug fixes
- Add missing find_device_by_address mapping (#43) ([`cc8e57e`](https://github.com/Bluetooth-Devices/habluetooth/commit/cc8e57eef7b97a6f2a30488a64d156cb5023c6c6))
## v2.8.0 (2024-04-17)
### Features
- Add support for recovering failed adapters after reboot (#40) ([`04948c3`](https://github.com/Bluetooth-Devices/habluetooth/commit/04948c337adf0f7b291e4e33618a7eae6dc4ebc2))
## v2.7.0 (2024-04-17)
### Features
- Improve fallback to passive mode when active mode fails (#39) ([`17ecc01`](https://github.com/Bluetooth-Devices/habluetooth/commit/17ecc012e096bec0113efea9ceb6a21bb50023fe))
## v2.6.0 (2024-04-17)
### Features
- Speed up stopping the scanner when its stuck setting up (#37) ([`bba8b51`](https://github.com/Bluetooth-Devices/habluetooth/commit/bba8b514490d98dca1020bbfefd9dc1e6a79af5f))
## v2.5.3 (2024-04-17)
### Bug fixes
- Ensure scanner is stopped on cancellation (#36) ([`a21d70a`](https://github.com/Bluetooth-Devices/habluetooth/commit/a21d70a1ac88135eade61c0abc8912c5b04a6b8b))
## v2.5.2 (2024-04-16)
### Bug fixes
- Ensure discovered_devices returns an empty list for offline scanners (#35) ([`2350543`](https://github.com/Bluetooth-Devices/habluetooth/commit/23505437c98529f692ab2dc0f5c3bdb5c9b7e3bd))
## v2.5.1 (2024-04-16)
### Bug fixes
- Wheel builds (#34) ([`5bd671a`](https://github.com/Bluetooth-Devices/habluetooth/commit/5bd671a159292dffe30a69639411926d0bc28123))
## v2.5.0 (2024-04-16)
### Features
- Fallback to passive scanning if active cannot start (#33) ([`3fae981`](https://github.com/Bluetooth-Devices/habluetooth/commit/3fae98162e6b0279375823a3b6e60ee51b87c1bb))
## v2.4.2 (2024-02-29)
### Bug fixes
- Android beacons in passive mode with flags 0x02 (#31) ([`8330e18`](https://github.com/Bluetooth-Devices/habluetooth/commit/8330e187550ec00ed415d3650a2c231921fb8ae7))
## v2.4.1 (2024-02-23)
### Bug fixes
- Avoid concurrent refreshes of adapters (#30) ([`d355b17`](https://github.com/Bluetooth-Devices/habluetooth/commit/d355b1768705706dec7062ad5d6267089d87a88e))
## v2.4.0 (2024-01-22)
### Features
- Improve error reporting resolution suggestions (#29) ([`afff5ba`](https://github.com/Bluetooth-Devices/habluetooth/commit/afff5ba4dfd8a5582174b367ae5ed9c9953b81e9))
## v2.3.1 (2024-01-22)
### Bug fixes
- Ensure unavailable callbacks can be removed from fired callbacks (#28) ([`65e7706`](https://github.com/Bluetooth-Devices/habluetooth/commit/65e7706ef4cdb99f9df5a00f666ab1d30e92e3b1))
## v2.3.0 (2024-01-22)
### Features
- Reduce overhead to remove callbacks by using sets to store callbacks (#27) ([`05ceb85`](https://github.com/Bluetooth-Devices/habluetooth/commit/05ceb85901b17f72988068997c7f39bc0179dca2))
## v2.2.0 (2024-01-14)
### Features
- Improve remote scanner performance (#26) ([`c549b1c`](https://github.com/Bluetooth-Devices/habluetooth/commit/c549b1cf9bbbda0c39dfce92d2888d5b990211da))
## v2.1.0 (2024-01-10)
### Features
- Add support for windows (#25) ([`788dd77`](https://github.com/Bluetooth-Devices/habluetooth/commit/788dd77ffac6664083821d5ba8b264725a3baaff))
## v2.0.2 (2024-01-04)
### Bug fixes
- Handle subclassed str in the client wrapper (#24) ([`f18a30e`](https://github.com/Bluetooth-Devices/habluetooth/commit/f18a30e48fe064993dc64f3af01c5d64b676a82f))
## v2.0.1 (2023-12-31)
### Bug fixes
- Switching scanners too quickly (#23) ([`bd53685`](https://github.com/Bluetooth-Devices/habluetooth/commit/bd536854457bd8b27f9e91921965b88b0ff798c3))
## v2.0.0 (2023-12-21)
### Features
- Simplify async_register_scanner by removing connectable argument (#22) ([`10ac6da`](https://github.com/Bluetooth-Devices/habluetooth/commit/10ac6da0672c121b5f0246ed688e98111adc7339))
## v1.0.0 (2023-12-12)
### Features
- Eliminate the need to pass the new_info_callback (#21) ([`65c54a6`](https://github.com/Bluetooth-Devices/habluetooth/commit/65c54a68500be6053677511ffd21ce3dca4b6991))
## v0.11.1 (2023-12-11)
### Bug fixes
- Do not schedule an expire when restoring devices (#20) ([`144cf15`](https://github.com/Bluetooth-Devices/habluetooth/commit/144cf15050a68cca66e7a2e24a5ddc7b87c32e41))
## v0.11.0 (2023-12-11)
### Features
- Relocate bluetoothserviceinfobleak (#18) ([`4f4f32d`](https://github.com/Bluetooth-Devices/habluetooth/commit/4f4f32d78d6abe21e28171f54ff5f3b17c8fb702))
## v0.10.0 (2023-12-07)
### Features
- Small speed ups to base_scanner (#17) ([`e1ff7e9`](https://github.com/Bluetooth-Devices/habluetooth/commit/e1ff7e9fb91a274b1a4bf6943a26e2a3f19780e7))
## v0.9.0 (2023-12-06)
### Features
- Speed up processing incoming service infos (#16) ([`55f6522`](https://github.com/Bluetooth-Devices/habluetooth/commit/55f6522ffc2adaf7e203ff4d2c1b13adc5d8c6a2))
## v0.8.0 (2023-12-06)
### Features
- Auto build the cythonized manager (#15) ([`c3441e5`](https://github.com/Bluetooth-Devices/habluetooth/commit/c3441e5095d62e6e70c2c879c4b5c109a87f463c))
- Add cython implementation for manager (#14) ([`266a602`](https://github.com/Bluetooth-Devices/habluetooth/commit/266a6022fb433ef9399f72e87b18b86897524784))
## v0.7.0 (2023-12-05)
### Features
- Port bluetooth manager from ha (#13) ([`757640a`](https://github.com/Bluetooth-Devices/habluetooth/commit/757640a7b7f60072588168501148ba750316f170))
## v0.6.1 (2023-12-04)
### Bug fixes
- Add missing cythonize for the adv tracker (#12) ([`8140195`](https://github.com/Bluetooth-Devices/habluetooth/commit/8140195a27ef83ea89ca643a5899d80839e574ae))
## v0.6.0 (2023-12-04)
### Features
- Port advertisement_tracker (#11) ([`378667b`](https://github.com/Bluetooth-Devices/habluetooth/commit/378667bce851b5076ee79ff223a72501c5575325))
## v0.5.1 (2023-12-04)
### Bug fixes
- Remove slots to keep hascanner patchable (#10) ([`d068f48`](https://github.com/Bluetooth-Devices/habluetooth/commit/d068f480d292619a1fc49a1256be98bdc6efadd6))
## v0.5.0 (2023-12-03)
### Features
- Port local scanner support from ha (#9) ([`1b1d0e4`](https://github.com/Bluetooth-Devices/habluetooth/commit/1b1d0e4bc17a44a1b20382da6ae28ea8e50e80b7))
## v0.4.0 (2023-12-03)
### Features
- Add more typing for incoming bluetooth data (#8) ([`de590e5`](https://github.com/Bluetooth-Devices/habluetooth/commit/de590e5c886801ff4a87f99c118be8855f337bd0))
## v0.3.0 (2023-12-03)
### Features
- Refactor to be able to use __pyx_pyobject_fastcall (#7) ([`e15074b`](https://github.com/Bluetooth-Devices/habluetooth/commit/e15074b172242f44f641e5232ebdf6297537a2b8))
- Add basic pxd (#6) ([`fd97d07`](https://github.com/Bluetooth-Devices/habluetooth/commit/fd97d07db7c0e8e0e877e1544fd0e392d14448b3))
## v0.2.0 (2023-12-03)
### Features
- Add cython pxd for base_scanner (#5) ([`0195710`](https://github.com/Bluetooth-Devices/habluetooth/commit/0195710bc25c8c3cc68b17a8f31cf281494fdc22))
## v0.1.0 (2023-12-03)
### Features
- Port base scanner from ha (#2) ([`e01a57b`](https://github.com/Bluetooth-Devices/habluetooth/commit/e01a57b6e0003ea8fe64b8e6e11ce09a35c1ada2))
## v0.0.1 (2023-12-02)
### Bug fixes
- Reserve name (#1) ([`5493984`](https://github.com/Bluetooth-Devices/habluetooth/commit/5493984483902039ca396498122e6094524bbae6))
habluetooth-3.48.2/CONTRIBUTING.md 0000664 0000000 0000000 00000007432 15005442573 0016372 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
habluetooth-3.48.2/LICENSE 0000664 0000000 0000000 00000026121 15005442573 0015142 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.
habluetooth-3.48.2/README.md 0000664 0000000 0000000 00000007604 15005442573 0015421 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.
habluetooth-3.48.2/build_ext.py 0000664 0000000 0000000 00000003277 15005442573 0016475 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",
]
EXTENSIONS = [
Extension(
ext.removeprefix("src/").removesuffix(".py").replace("/", "."),
[ext],
language="c",
extra_compile_args=["-O3", "-g0"],
)
for ext in TO_CYTHONIZE
]
class BuildExt(build_ext):
"""Build extension."""
def build_extensions(self) -> None:
"""Build extensions."""
try:
super().build_extensions()
except Exception as ex: # nosec
_LOGGER.debug("Failed to build extensions: %s", ex, exc_info=True)
pass
def build(setup_kwargs: Any) -> None:
"""Build optional cython modules."""
if os.environ.get("SKIP_CYTHON", False):
return
try:
from Cython.Build import cythonize
setup_kwargs.update(
{
"ext_modules": cythonize(
EXTENSIONS,
compiler_directives={"language_level": "3"}, # Python 3
),
"cmdclass": {"build_ext": BuildExt},
}
)
setup_kwargs["exclude_package_data"] = {
pkg: ["*.c"] for pkg in setup_kwargs["packages"]
}
except Exception:
if os.environ.get("REQUIRE_CYTHON"):
raise
pass
habluetooth-3.48.2/commitlint.config.mjs 0000664 0000000 0000000 00000000362 15005442573 0020272 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],
},
};
habluetooth-3.48.2/docs/ 0000775 0000000 0000000 00000000000 15005442573 0015063 5 ustar 00root root 0000000 0000000 habluetooth-3.48.2/docs/Makefile 0000664 0000000 0000000 00000001372 15005442573 0016526 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)
habluetooth-3.48.2/docs/_static/ 0000775 0000000 0000000 00000000000 15005442573 0016511 5 ustar 00root root 0000000 0000000 habluetooth-3.48.2/docs/_static/.gitkeep 0000664 0000000 0000000 00000000000 15005442573 0020130 0 ustar 00root root 0000000 0000000 habluetooth-3.48.2/docs/changelog.md 0000664 0000000 0000000 00000000060 15005442573 0017330 0 ustar 00root root 0000000 0000000 (changelog)=
```{include} ../CHANGELOG.md
```
habluetooth-3.48.2/docs/conf.py 0000664 0000000 0000000 00000001220 15005442573 0016355 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 = "3.48.2"
# General configuration
extensions = [
"myst_parser",
]
# The suffix of source filenames.
source_suffix = [
".rst",
".md",
]
templates_path = [
"_templates",
]
exclude_patterns = [
"_build",
"Thumbs.db",
".DS_Store",
]
# Options for HTML output
html_theme = "furo"
html_static_path = ["_static"]
habluetooth-3.48.2/docs/contributing.md 0000664 0000000 0000000 00000000066 15005442573 0020116 0 ustar 00root root 0000000 0000000 (contributing)=
```{include} ../CONTRIBUTING.md
```
habluetooth-3.48.2/docs/index.md 0000664 0000000 0000000 00000000350 15005442573 0016512 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
```
habluetooth-3.48.2/docs/installation.md 0000664 0000000 0000000 00000000415 15005442573 0020106 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.
habluetooth-3.48.2/docs/make.bat 0000664 0000000 0000000 00000001375 15005442573 0016476 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
habluetooth-3.48.2/docs/usage.md 0000664 0000000 0000000 00000000326 15005442573 0016512 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
habluetooth-3.48.2/poetry.lock 0000664 0000000 0000000 00000522675 15005442573 0016350 0 ustar 00root root 0000000 0000000 # This file is automatically @generated by Poetry 2.1.1 and should not be changed by hand.
[[package]]
name = "aiooui"
version = "0.1.9"
description = "Async OUI lookups"
optional = false
python-versions = "<4.0,>=3.9"
groups = ["main"]
files = [
{file = "aiooui-0.1.9-cp310-cp310-manylinux_2_31_x86_64.whl", hash = "sha256:64d904b43f14dd1d8d9fcf1684d9e2f558bc5e0bd68dc10023c93355c9027907"},
{file = "aiooui-0.1.9-py3-none-any.whl", hash = "sha256:737a5e62d8726540218c2b70e5f966d9912121e4644f3d490daf8f3c18b182e5"},
{file = "aiooui-0.1.9.tar.gz", hash = "sha256:e8c8bc59ab352419e0747628b4cce7c4e04d492574c1971e223401126389c5d8"},
]
[[package]]
name = "alabaster"
version = "1.0.0"
description = "A light, configurable Sphinx theme"
optional = false
python-versions = ">=3.10"
groups = ["docs"]
files = [
{file = "alabaster-1.0.0-py3-none-any.whl", hash = "sha256:fc6786402dc3fcb2de3cabd5fe455a2db534b371124f1f21de8731783dec828b"},
{file = "alabaster-1.0.0.tar.gz", hash = "sha256:c00dca57bca26fa62a6d7d0a9fcce65f3e026e9bfe33e9c538fd3fbb2144fd9e"},
]
[[package]]
name = "anyio"
version = "4.9.0"
description = "High level compatibility layer for multiple asynchronous event loop implementations"
optional = false
python-versions = ">=3.9"
groups = ["docs"]
files = [
{file = "anyio-4.9.0-py3-none-any.whl", hash = "sha256:9f76d541cad6e36af7beb62e978876f3b41e3e04f2c1fbf0884604c0a9c4d93c"},
{file = "anyio-4.9.0.tar.gz", hash = "sha256:673c0c244e15788651a4ff38710fea9675823028a6f08a5eda409e0c9840a028"},
]
[package.dependencies]
idna = ">=2.8"
sniffio = ">=1.1"
typing_extensions = {version = ">=4.5", markers = "python_version < \"3.13\""}
[package.extras]
doc = ["Sphinx (>=8.2,<9.0)", "packaging", "sphinx-autodoc-typehints (>=1.2.0)", "sphinx_rtd_theme"]
test = ["anyio[trio]", "blockbuster (>=1.5.23)", "coverage[toml] (>=7)", "exceptiongroup (>=1.2.0)", "hypothesis (>=4.0)", "psutil (>=5.9)", "pytest (>=7.0)", "trustme", "truststore (>=0.9.1) ; python_version >= \"3.10\"", "uvloop (>=0.21) ; platform_python_implementation == \"CPython\" and platform_system != \"Windows\" and python_version < \"3.14\""]
trio = ["trio (>=0.26.1)"]
[[package]]
name = "async-interrupt"
version = "1.2.2"
description = "Context manager to raise an exception when a future is done"
optional = false
python-versions = ">=3.9"
groups = ["main"]
files = [
{file = "async_interrupt-1.2.2-py3-none-any.whl", hash = "sha256:0a8deb884acfb5fe55188a693ae8a4381bbbd2cb6e670dac83869489513eec2c"},
{file = "async_interrupt-1.2.2.tar.gz", hash = "sha256:be4331a029b8625777905376a6dc1370984c8c810f30b79703f3ee039d262bf7"},
]
[[package]]
name = "babel"
version = "2.17.0"
description = "Internationalization utilities"
optional = false
python-versions = ">=3.8"
groups = ["docs"]
files = [
{file = "babel-2.17.0-py3-none-any.whl", hash = "sha256:4d0b53093fdfb4b21c92b5213dba5a1b23885afa8383709427046b21c366e5f2"},
{file = "babel-2.17.0.tar.gz", hash = "sha256:0c54cffb19f690cdcc52a3b50bcbf71e07a808d1c80d549f2459b9d2cf0afb9d"},
]
[package.extras]
dev = ["backports.zoneinfo ; python_version < \"3.9\"", "freezegun (>=1.0,<2.0)", "jinja2 (>=3.0)", "pytest (>=6.0)", "pytest-cov", "pytz", "setuptools", "tzdata ; sys_platform == \"win32\""]
[[package]]
name = "beautifulsoup4"
version = "4.13.3"
description = "Screen-scraping library"
optional = false
python-versions = ">=3.7.0"
groups = ["docs"]
files = [
{file = "beautifulsoup4-4.13.3-py3-none-any.whl", hash = "sha256:99045d7d3f08f91f0d656bc9b7efbae189426cd913d830294a15eefa0ea4df16"},
{file = "beautifulsoup4-4.13.3.tar.gz", hash = "sha256:1bd32405dacc920b42b83ba01644747ed77456a65760e285fbc47633ceddaf8b"},
]
[package.dependencies]
soupsieve = ">1.2"
typing-extensions = ">=4.0.0"
[package.extras]
cchardet = ["cchardet"]
chardet = ["chardet"]
charset-normalizer = ["charset-normalizer"]
html5lib = ["html5lib"]
lxml = ["lxml"]
[[package]]
name = "bleak"
version = "0.22.3"
description = "Bluetooth Low Energy platform Agnostic Klient"
optional = false
python-versions = "<3.14,>=3.8"
groups = ["main"]
files = [
{file = "bleak-0.22.3-py3-none-any.whl", hash = "sha256:1e62a9f5e0c184826e6c906e341d8aca53793e4596eeaf4e0b191e7aca5c461c"},
{file = "bleak-0.22.3.tar.gz", hash = "sha256:3149c3c19657e457727aa53d9d6aeb89658495822cd240afd8aeca4dd09c045c"},
]
[package.dependencies]
bleak-winrt = {version = ">=1.2.0,<2.0.0", markers = "platform_system == \"Windows\" and python_version < \"3.12\""}
dbus-fast = {version = ">=1.83.0,<3", markers = "platform_system == \"Linux\""}
pyobjc-core = {version = ">=10.3,<11.0", markers = "platform_system == \"Darwin\""}
pyobjc-framework-CoreBluetooth = {version = ">=10.3,<11.0", markers = "platform_system == \"Darwin\""}
pyobjc-framework-libdispatch = {version = ">=10.3,<11.0", markers = "platform_system == \"Darwin\""}
typing-extensions = {version = ">=4.7.0", markers = "python_version < \"3.12\""}
winrt-runtime = {version = ">=2,<3", markers = "platform_system == \"Windows\" and python_version >= \"3.12\""}
"winrt-Windows.Devices.Bluetooth" = {version = ">=2,<3", markers = "platform_system == \"Windows\" and python_version >= \"3.12\""}
"winrt-Windows.Devices.Bluetooth.Advertisement" = {version = ">=2,<3", markers = "platform_system == \"Windows\" and python_version >= \"3.12\""}
"winrt-Windows.Devices.Bluetooth.GenericAttributeProfile" = {version = ">=2,<3", markers = "platform_system == \"Windows\" and python_version >= \"3.12\""}
"winrt-Windows.Devices.Enumeration" = {version = ">=2,<3", markers = "platform_system == \"Windows\" and python_version >= \"3.12\""}
"winrt-Windows.Foundation" = {version = ">=2,<3", markers = "platform_system == \"Windows\" and python_version >= \"3.12\""}
"winrt-Windows.Foundation.Collections" = {version = ">=2,<3", markers = "platform_system == \"Windows\" and python_version >= \"3.12\""}
"winrt-Windows.Storage.Streams" = {version = ">=2,<3", markers = "platform_system == \"Windows\" and python_version >= \"3.12\""}
[[package]]
name = "bleak-retry-connector"
version = "3.10.0"
description = "A connector for Bleak Clients that handles transient connection failures"
optional = false
python-versions = ">=3.10"
groups = ["main"]
files = [
{file = "bleak_retry_connector-3.10.0-py3-none-any.whl", hash = "sha256:caaf976320ef280f1145b557bf3b13697f71ef2c1070e1dc643709eb2d29fb1f"},
{file = "bleak_retry_connector-3.10.0.tar.gz", hash = "sha256:a95172bd56d2af677fb9e250291cde8c70d8f72381d423f64e48c828dffbc93b"},
]
[package.dependencies]
bleak = {version = ">=0.21.0", markers = "python_version >= \"3.10\" and python_version < \"3.14\""}
bluetooth-adapters = {version = ">=0.15.2", markers = "python_version >= \"3.10\" and python_version < \"3.14\" and platform_system == \"Linux\""}
dbus-fast = {version = ">=1.14.0", markers = "platform_system == \"Linux\""}
[[package]]
name = "bleak-winrt"
version = "1.2.0"
description = "Python WinRT bindings for Bleak"
optional = false
python-versions = "*"
groups = ["main"]
markers = "platform_system == \"Windows\" and python_version < \"3.12\""
files = [
{file = "bleak-winrt-1.2.0.tar.gz", hash = "sha256:0577d070251b9354fc6c45ffac57e39341ebb08ead014b1bdbd43e211d2ce1d6"},
{file = "bleak_winrt-1.2.0-cp310-cp310-win32.whl", hash = "sha256:a2ae3054d6843ae0cfd3b94c83293a1dfd5804393977dd69bde91cb5099fc47c"},
{file = "bleak_winrt-1.2.0-cp310-cp310-win_amd64.whl", hash = "sha256:677df51dc825c6657b3ae94f00bd09b8ab88422b40d6a7bdbf7972a63bc44e9a"},
{file = "bleak_winrt-1.2.0-cp311-cp311-win32.whl", hash = "sha256:9449cdb942f22c9892bc1ada99e2ccce9bea8a8af1493e81fefb6de2cb3a7b80"},
{file = "bleak_winrt-1.2.0-cp311-cp311-win_amd64.whl", hash = "sha256:98c1b5a6a6c431ac7f76aa4285b752fe14a1c626bd8a1dfa56f66173ff120bee"},
{file = "bleak_winrt-1.2.0-cp37-cp37m-win32.whl", hash = "sha256:623ac511696e1f58d83cb9c431e32f613395f2199b3db7f125a3d872cab968a4"},
{file = "bleak_winrt-1.2.0-cp37-cp37m-win_amd64.whl", hash = "sha256:13ab06dec55469cf51a2c187be7b630a7a2922e1ea9ac1998135974a7239b1e3"},
{file = "bleak_winrt-1.2.0-cp38-cp38-win32.whl", hash = "sha256:5a36ff8cd53068c01a795a75d2c13054ddc5f99ce6de62c1a97cd343fc4d0727"},
{file = "bleak_winrt-1.2.0-cp38-cp38-win_amd64.whl", hash = "sha256:810c00726653a962256b7acd8edf81ab9e4a3c66e936a342ce4aec7dbd3a7263"},
{file = "bleak_winrt-1.2.0-cp39-cp39-win32.whl", hash = "sha256:dd740047a08925bde54bec357391fcee595d7b8ca0c74c87170a5cbc3f97aa0a"},
{file = "bleak_winrt-1.2.0-cp39-cp39-win_amd64.whl", hash = "sha256:63130c11acfe75c504a79c01f9919e87f009f5e742bfc7b7a5c2a9c72bf591a7"},
]
[[package]]
name = "bluetooth-adapters"
version = "0.21.4"
description = "Tools to enumerate and find Bluetooth Adapters"
optional = false
python-versions = ">=3.9"
groups = ["main"]
files = [
{file = "bluetooth_adapters-0.21.4-py3-none-any.whl", hash = "sha256:ce2e8139cc9d7b103c21654c6309507979e469aae3efebcaeee9923080b0569b"},
{file = "bluetooth_adapters-0.21.4.tar.gz", hash = "sha256:a5a809ef7ba95ee673a78704f90ce34612deb3696269d1a6fd61f98642b99dd3"},
]
[package.dependencies]
aiooui = ">=0.1.1"
bleak = ">=0.21.1"
dbus-fast = {version = ">=1.21.0", markers = "platform_system == \"Linux\""}
uart-devices = ">=0.1.0"
usb-devices = ">=0.4.5"
[package.extras]
docs = ["Sphinx (>=5,<8)", "myst-parser (>=0.18,<3.1)", "sphinx-rtd-theme (>=1,<4)"]
[[package]]
name = "bluetooth-auto-recovery"
version = "1.5.1"
description = "Recover bluetooth adapters that are in an stuck state"
optional = false
python-versions = "<3.14,>=3.9"
groups = ["main"]
files = [
{file = "bluetooth_auto_recovery-1.5.1-py3-none-any.whl", hash = "sha256:59751902004cad9a84b5a674b051113d0a653374c1cec271945f2862b2b15c8f"},
{file = "bluetooth_auto_recovery-1.5.1.tar.gz", hash = "sha256:16eaa20e3f86cb2818ce75d107f57534559cdd82ba015b2741667bcac929d506"},
]
[package.dependencies]
bluetooth-adapters = ">=0.16.0"
btsocket = ">=0.2.0"
PyRIC = ">=0.1.6.3"
usb-devices = ">=0.4.1"
[package.extras]
docs = ["Sphinx (>=5,<8)", "myst-parser (>=0.18,<3.1)", "sphinx-rtd-theme (>=1,<4)"]
[[package]]
name = "bluetooth-data-tools"
version = "1.28.0"
description = "Tools for converting bluetooth data and packets"
optional = false
python-versions = ">=3.10"
groups = ["main"]
files = [
{file = "bluetooth_data_tools-1.28.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:8904bd36c76ad5f287cc2ee1aad1d1fd683931db69d75326802caf8f1d44add1"},
{file = "bluetooth_data_tools-1.28.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:5128eba3092f7bb838019af0812032286797cab30f928d779385828433156932"},
{file = "bluetooth_data_tools-1.28.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:89377ef7e320c925d902f2d76aa17b81ee7802156b55d2b813e8c73cb266c186"},
{file = "bluetooth_data_tools-1.28.0-cp310-cp310-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:79016b49c3590a2a5b1b524b785f4e9cd11ad85927af7025f678e87145e6affe"},
{file = "bluetooth_data_tools-1.28.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5b1014e9bd8924b3de4c30bd7ac8f49583c925a6eb49338ece4dbb0970a6d9af"},
{file = "bluetooth_data_tools-1.28.0-cp310-cp310-manylinux_2_31_armv7l.manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:7f78b9e7cb3cda03bd51118dd648465faa973c35bcf7804546c45c55864a915d"},
{file = "bluetooth_data_tools-1.28.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:08d06fe8f574de65656d44dd21777313e776aa55379b3bf6d291f07ad6a61f74"},
{file = "bluetooth_data_tools-1.28.0-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:577e37a76cf23edc1e3db61edb376db17bc3e822614016a1940d901e42b17305"},
{file = "bluetooth_data_tools-1.28.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:b601ddf5aa7edcfb7cc0a28653412e67b1a9b6dac392400b4d3c89a741198d0a"},
{file = "bluetooth_data_tools-1.28.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:4847d9d2fc9d3cd316f8bdd48791b712a91f09965669481a0a86d2404e9d0a57"},
{file = "bluetooth_data_tools-1.28.0-cp310-cp310-win32.whl", hash = "sha256:baaba54567151504adcae49a05657d9e0593d681bfa267fc9ba3934f4b4d67a1"},
{file = "bluetooth_data_tools-1.28.0-cp310-cp310-win_amd64.whl", hash = "sha256:a9c7f95b4e473b7dd4f512f88405ba531d7fc1ef8e9eab9e510781066749c9c7"},
{file = "bluetooth_data_tools-1.28.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:68c6103620326173004aa4e2cb139cc2862140d0667e75fa8564287b9e32ae17"},
{file = "bluetooth_data_tools-1.28.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:b54494d61bd3c683839c499afe6f03379b229903c6bb7f35d5b4156e9b988b40"},
{file = "bluetooth_data_tools-1.28.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:188b466850446c7b0d8ef1a23e368dbf59c78071d22028b89614ca69852a5924"},
{file = "bluetooth_data_tools-1.28.0-cp311-cp311-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:986c67d4ca321c84c97de3a51f0ccf97934dd31187a2c6db63a31b877de962bd"},
{file = "bluetooth_data_tools-1.28.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b5206ee861fd024b4415b9af4b58ba116223ddf02cc62730b162594ea6b4da89"},
{file = "bluetooth_data_tools-1.28.0-cp311-cp311-manylinux_2_31_armv7l.manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:50f4efa2520856dffb10105f4eb0dd1b8baf35d94ec0ab5301c7526787075571"},
{file = "bluetooth_data_tools-1.28.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:64ef627fd45176ad2798cdaba430e45739018dd1b72064a7643d3fe2ceafb228"},
{file = "bluetooth_data_tools-1.28.0-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:4d2ee2ce16efcf5357a5c784c377a4387aa9e47deea5374c9b5822fb1b828beb"},
{file = "bluetooth_data_tools-1.28.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:974d31c5fbe5d0d1eb4ab42b08d8d02d5c1bebaaa7c8ec04329e16ebb7ead5c4"},
{file = "bluetooth_data_tools-1.28.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:772e15e78a4f8febd1d072c52ed711add7e0095d385503ae30cf16faf1d06e07"},
{file = "bluetooth_data_tools-1.28.0-cp311-cp311-win32.whl", hash = "sha256:a3b8805e214eebc28f4f874e24fc699642a4ac5d90f5704d96b5234d05a5eaef"},
{file = "bluetooth_data_tools-1.28.0-cp311-cp311-win_amd64.whl", hash = "sha256:e869f224b554e87aac864707b2617e405f18229b71a285deea3f35832f656a5c"},
{file = "bluetooth_data_tools-1.28.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:c8a5e50677471623d88a4f343e7b6d652931140900287f15ad18831014d9590d"},
{file = "bluetooth_data_tools-1.28.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:3e156b24c31581b3b719753b42d3b60b3f6099052e1aefa2597e5d6e8f5105f8"},
{file = "bluetooth_data_tools-1.28.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9fa35b74d2dc9caf272ff1b4bad9535d0e7e1b9a802566fdb68af604bf641047"},
{file = "bluetooth_data_tools-1.28.0-cp312-cp312-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:6d6aba822495d8800c951253d07a7464b44554d96d9c3a6556a15c9d3d88d89b"},
{file = "bluetooth_data_tools-1.28.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:85a26abd3265fb4940fe0813b216da62b9c071b7a838b041d7bde1c7acd22efc"},
{file = "bluetooth_data_tools-1.28.0-cp312-cp312-manylinux_2_31_armv7l.manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:c67ca909c91c6d083d20c7ce80de942769d9e79cd35c17452ac69b4017d17885"},
{file = "bluetooth_data_tools-1.28.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:85b0af53ef31c919e90d339ed10a2f028895bc40adec7643321f51e9eb9bec27"},
{file = "bluetooth_data_tools-1.28.0-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:36eb3d010e644646c244a1a133df08e2d6ee3d7a5e44a545f19d5de52172d694"},
{file = "bluetooth_data_tools-1.28.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:a18a91101e1d516ed9dc321e0e39b8843e11bb01b1f35a6e9bff186f486b147d"},
{file = "bluetooth_data_tools-1.28.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:0c4d13cf3d72920aa8505bfae21c1f97833dbbb392ed498b9031721d4ef62ee9"},
{file = "bluetooth_data_tools-1.28.0-cp312-cp312-win32.whl", hash = "sha256:99738c528e3b4683f5581c2021c1117a34c0a9257723c63dbb910204706b57a8"},
{file = "bluetooth_data_tools-1.28.0-cp312-cp312-win_amd64.whl", hash = "sha256:0993eae6596a5a1659fa1b9a4cc06dc456579d07611d81b557648a06c64a8068"},
{file = "bluetooth_data_tools-1.28.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:59761b79523438b18d4e87ee92f6c69497289209ad81cd19b97b51f477710294"},
{file = "bluetooth_data_tools-1.28.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:9822bf7020df321d9f77a1edfc8a399a187cd5ddf2b67a1fa0d831ca35003696"},
{file = "bluetooth_data_tools-1.28.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a0d7d2694f3c5149fd91d890a16d66af5b58a6169fc7cd8566260f8d1167a814"},
{file = "bluetooth_data_tools-1.28.0-cp313-cp313-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:fe8594d2e7816b9201a7c17b48cd2c77eb27c93b5232e1a3e9d3bf708fa4c558"},
{file = "bluetooth_data_tools-1.28.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9e73d84cfc4e15dfb9f5b9f924874f11738440789a0235e89ddd6c84f32dd2e7"},
{file = "bluetooth_data_tools-1.28.0-cp313-cp313-manylinux_2_31_armv7l.manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:46c29ce3be07b89a5fcc158c981e48d2d0ef716a709609d5a819a68a663a5979"},
{file = "bluetooth_data_tools-1.28.0-cp313-cp313-manylinux_2_36_x86_64.whl", hash = "sha256:feb41dc67e94edb6b68cbe2c29a2ab4899526504019f973afb20abb3385061a3"},
{file = "bluetooth_data_tools-1.28.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:5f2d34bf815fc175b23cb9eddae2697ccc1fc778105a0c5ddc7d446bef008f29"},
{file = "bluetooth_data_tools-1.28.0-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:542ac3d039a33f7bc0e0ee390e18889165b0728d512588d0b8971e55fb0b9813"},
{file = "bluetooth_data_tools-1.28.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:6d587f0657f20285c52656343e3da4516473b09cf51c1702dbe2cb6fd2d5777f"},
{file = "bluetooth_data_tools-1.28.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:e37a22775c42d12c6efd79de812df1a6cc43566357d7378bd8a9b9a043001c9f"},
{file = "bluetooth_data_tools-1.28.0-cp313-cp313-win32.whl", hash = "sha256:64d2bc2519438e231e0a6043c4d8496e1e578998d6d38cec927851b65c192e93"},
{file = "bluetooth_data_tools-1.28.0-cp313-cp313-win_amd64.whl", hash = "sha256:3153d29eda734cad41f8f5edaa098c5acaec9b16fef71b46507569986fc2be4c"},
{file = "bluetooth_data_tools-1.28.0.tar.gz", hash = "sha256:ebfb3ff006da96cc34769e8b4d7b05982db5dd85b7dd7dda7ece9fa1fc34f352"},
]
[package.dependencies]
cryptography = ">=41.0.3"
[package.extras]
docs = ["Sphinx (>=5,<9)", "myst-parser (>=0.18,<4.1)", "sphinx-rtd-theme (>=1,<4)"]
[[package]]
name = "btsocket"
version = "0.3.0"
description = "Python library for BlueZ Bluetooth Management API"
optional = false
python-versions = "*"
groups = ["main"]
files = [
{file = "btsocket-0.3.0-py2.py3-none-any.whl", hash = "sha256:949821c1b580a88e73804ad610f5173d6ae258e7b4e389da4f94d614344f1a9c"},
{file = "btsocket-0.3.0.tar.gz", hash = "sha256:7ea495de0ff883f0d9f8eea59c72ca7fed492994df668fe476b84d814a147a0d"},
]
[package.extras]
dev = ["bumpversion", "coverage", "pycodestyle", "pygments", "sphinx", "sphinx-rtd-theme", "twine"]
docs = ["pygments", "sphinx", "sphinx-rtd-theme"]
rel = ["bumpversion", "twine"]
test = ["coverage", "pycodestyle"]
[[package]]
name = "certifi"
version = "2025.1.31"
description = "Python package for providing Mozilla's CA Bundle."
optional = false
python-versions = ">=3.6"
groups = ["docs"]
files = [
{file = "certifi-2025.1.31-py3-none-any.whl", hash = "sha256:ca78db4565a652026a4db2bcdf68f2fb589ea80d0be70e03929ed730746b84fe"},
{file = "certifi-2025.1.31.tar.gz", hash = "sha256:3d5da6925056f6f18f119200434a4780a94263f10d1c21d032a6f6b2baa20651"},
]
[[package]]
name = "cffi"
version = "1.17.1"
description = "Foreign Function Interface for Python calling C code."
optional = false
python-versions = ">=3.8"
groups = ["main", "dev"]
files = [
{file = "cffi-1.17.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:df8b1c11f177bc2313ec4b2d46baec87a5f3e71fc8b45dab2ee7cae86d9aba14"},
{file = "cffi-1.17.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:8f2cdc858323644ab277e9bb925ad72ae0e67f69e804f4898c070998d50b1a67"},
{file = "cffi-1.17.1-cp310-cp310-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:edae79245293e15384b51f88b00613ba9f7198016a5948b5dddf4917d4d26382"},
{file = "cffi-1.17.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:45398b671ac6d70e67da8e4224a065cec6a93541bb7aebe1b198a61b58c7b702"},
{file = "cffi-1.17.1-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ad9413ccdeda48c5afdae7e4fa2192157e991ff761e7ab8fdd8926f40b160cc3"},
{file = "cffi-1.17.1-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:5da5719280082ac6bd9aa7becb3938dc9f9cbd57fac7d2871717b1feb0902ab6"},
{file = "cffi-1.17.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2bb1a08b8008b281856e5971307cc386a8e9c5b625ac297e853d36da6efe9c17"},
{file = "cffi-1.17.1-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:045d61c734659cc045141be4bae381a41d89b741f795af1dd018bfb532fd0df8"},
{file = "cffi-1.17.1-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:6883e737d7d9e4899a8a695e00ec36bd4e5e4f18fabe0aca0efe0a4b44cdb13e"},
{file = "cffi-1.17.1-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:6b8b4a92e1c65048ff98cfe1f735ef8f1ceb72e3d5f0c25fdb12087a23da22be"},
{file = "cffi-1.17.1-cp310-cp310-win32.whl", hash = "sha256:c9c3d058ebabb74db66e431095118094d06abf53284d9c81f27300d0e0d8bc7c"},
{file = "cffi-1.17.1-cp310-cp310-win_amd64.whl", hash = "sha256:0f048dcf80db46f0098ccac01132761580d28e28bc0f78ae0d58048063317e15"},
{file = "cffi-1.17.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:a45e3c6913c5b87b3ff120dcdc03f6131fa0065027d0ed7ee6190736a74cd401"},
{file = "cffi-1.17.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:30c5e0cb5ae493c04c8b42916e52ca38079f1b235c2f8ae5f4527b963c401caf"},
{file = "cffi-1.17.1-cp311-cp311-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f75c7ab1f9e4aca5414ed4d8e5c0e303a34f4421f8a0d47a4d019ceff0ab6af4"},
{file = "cffi-1.17.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a1ed2dd2972641495a3ec98445e09766f077aee98a1c896dcb4ad0d303628e41"},
{file = "cffi-1.17.1-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:46bf43160c1a35f7ec506d254e5c890f3c03648a4dbac12d624e4490a7046cd1"},
{file = "cffi-1.17.1-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a24ed04c8ffd54b0729c07cee15a81d964e6fee0e3d4d342a27b020d22959dc6"},
{file = "cffi-1.17.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:610faea79c43e44c71e1ec53a554553fa22321b65fae24889706c0a84d4ad86d"},
{file = "cffi-1.17.1-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:a9b15d491f3ad5d692e11f6b71f7857e7835eb677955c00cc0aefcd0669adaf6"},
{file = "cffi-1.17.1-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:de2ea4b5833625383e464549fec1bc395c1bdeeb5f25c4a3a82b5a8c756ec22f"},
{file = "cffi-1.17.1-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:fc48c783f9c87e60831201f2cce7f3b2e4846bf4d8728eabe54d60700b318a0b"},
{file = "cffi-1.17.1-cp311-cp311-win32.whl", hash = "sha256:85a950a4ac9c359340d5963966e3e0a94a676bd6245a4b55bc43949eee26a655"},
{file = "cffi-1.17.1-cp311-cp311-win_amd64.whl", hash = "sha256:caaf0640ef5f5517f49bc275eca1406b0ffa6aa184892812030f04c2abf589a0"},
{file = "cffi-1.17.1-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:805b4371bf7197c329fcb3ead37e710d1bca9da5d583f5073b799d5c5bd1eee4"},
{file = "cffi-1.17.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:733e99bc2df47476e3848417c5a4540522f234dfd4ef3ab7fafdf555b082ec0c"},
{file = "cffi-1.17.1-cp312-cp312-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1257bdabf294dceb59f5e70c64a3e2f462c30c7ad68092d01bbbfb1c16b1ba36"},
{file = "cffi-1.17.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:da95af8214998d77a98cc14e3a3bd00aa191526343078b530ceb0bd710fb48a5"},
{file = "cffi-1.17.1-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d63afe322132c194cf832bfec0dc69a99fb9bb6bbd550f161a49e9e855cc78ff"},
{file = "cffi-1.17.1-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f79fc4fc25f1c8698ff97788206bb3c2598949bfe0fef03d299eb1b5356ada99"},
{file = "cffi-1.17.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b62ce867176a75d03a665bad002af8e6d54644fad99a3c70905c543130e39d93"},
{file = "cffi-1.17.1-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:386c8bf53c502fff58903061338ce4f4950cbdcb23e2902d86c0f722b786bbe3"},
{file = "cffi-1.17.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:4ceb10419a9adf4460ea14cfd6bc43d08701f0835e979bf821052f1805850fe8"},
{file = "cffi-1.17.1-cp312-cp312-win32.whl", hash = "sha256:a08d7e755f8ed21095a310a693525137cfe756ce62d066e53f502a83dc550f65"},
{file = "cffi-1.17.1-cp312-cp312-win_amd64.whl", hash = "sha256:51392eae71afec0d0c8fb1a53b204dbb3bcabcb3c9b807eedf3e1e6ccf2de903"},
{file = "cffi-1.17.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:f3a2b4222ce6b60e2e8b337bb9596923045681d71e5a082783484d845390938e"},
{file = "cffi-1.17.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:0984a4925a435b1da406122d4d7968dd861c1385afe3b45ba82b750f229811e2"},
{file = "cffi-1.17.1-cp313-cp313-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d01b12eeeb4427d3110de311e1774046ad344f5b1a7403101878976ecd7a10f3"},
{file = "cffi-1.17.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:706510fe141c86a69c8ddc029c7910003a17353970cff3b904ff0686a5927683"},
{file = "cffi-1.17.1-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:de55b766c7aa2e2a3092c51e0483d700341182f08e67c63630d5b6f200bb28e5"},
{file = "cffi-1.17.1-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c59d6e989d07460165cc5ad3c61f9fd8f1b4796eacbd81cee78957842b834af4"},
{file = "cffi-1.17.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dd398dbc6773384a17fe0d3e7eeb8d1a21c2200473ee6806bb5e6a8e62bb73dd"},
{file = "cffi-1.17.1-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:3edc8d958eb099c634dace3c7e16560ae474aa3803a5df240542b305d14e14ed"},
{file = "cffi-1.17.1-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:72e72408cad3d5419375fc87d289076ee319835bdfa2caad331e377589aebba9"},
{file = "cffi-1.17.1-cp313-cp313-win32.whl", hash = "sha256:e03eab0a8677fa80d646b5ddece1cbeaf556c313dcfac435ba11f107ba117b5d"},
{file = "cffi-1.17.1-cp313-cp313-win_amd64.whl", hash = "sha256:f6a16c31041f09ead72d69f583767292f750d24913dadacf5756b966aacb3f1a"},
{file = "cffi-1.17.1-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:636062ea65bd0195bc012fea9321aca499c0504409f413dc88af450b57ffd03b"},
{file = "cffi-1.17.1-cp38-cp38-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c7eac2ef9b63c79431bc4b25f1cd649d7f061a28808cbc6c47b534bd789ef964"},
{file = "cffi-1.17.1-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e221cf152cff04059d011ee126477f0d9588303eb57e88923578ace7baad17f9"},
{file = "cffi-1.17.1-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:31000ec67d4221a71bd3f67df918b1f88f676f1c3b535a7eb473255fdc0b83fc"},
{file = "cffi-1.17.1-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:6f17be4345073b0a7b8ea599688f692ac3ef23ce28e5df79c04de519dbc4912c"},
{file = "cffi-1.17.1-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0e2b1fac190ae3ebfe37b979cc1ce69c81f4e4fe5746bb401dca63a9062cdaf1"},
{file = "cffi-1.17.1-cp38-cp38-win32.whl", hash = "sha256:7596d6620d3fa590f677e9ee430df2958d2d6d6de2feeae5b20e82c00b76fbf8"},
{file = "cffi-1.17.1-cp38-cp38-win_amd64.whl", hash = "sha256:78122be759c3f8a014ce010908ae03364d00a1f81ab5c7f4a7a5120607ea56e1"},
{file = "cffi-1.17.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:b2ab587605f4ba0bf81dc0cb08a41bd1c0a5906bd59243d56bad7668a6fc6c16"},
{file = "cffi-1.17.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:28b16024becceed8c6dfbc75629e27788d8a3f9030691a1dbf9821a128b22c36"},
{file = "cffi-1.17.1-cp39-cp39-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1d599671f396c4723d016dbddb72fe8e0397082b0a77a4fab8028923bec050e8"},
{file = "cffi-1.17.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ca74b8dbe6e8e8263c0ffd60277de77dcee6c837a3d0881d8c1ead7268c9e576"},
{file = "cffi-1.17.1-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f7f5baafcc48261359e14bcd6d9bff6d4b28d9103847c9e136694cb0501aef87"},
{file = "cffi-1.17.1-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:98e3969bcff97cae1b2def8ba499ea3d6f31ddfdb7635374834cf89a1a08ecf0"},
{file = "cffi-1.17.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:cdf5ce3acdfd1661132f2a9c19cac174758dc2352bfe37d98aa7512c6b7178b3"},
{file = "cffi-1.17.1-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:9755e4345d1ec879e3849e62222a18c7174d65a6a92d5b346b1863912168b595"},
{file = "cffi-1.17.1-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:f1e22e8c4419538cb197e4dd60acc919d7696e5ef98ee4da4e01d3f8cfa4cc5a"},
{file = "cffi-1.17.1-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:c03e868a0b3bc35839ba98e74211ed2b05d2119be4e8a0f224fba9384f1fe02e"},
{file = "cffi-1.17.1-cp39-cp39-win32.whl", hash = "sha256:e31ae45bc2e29f6b2abd0de1cc3b9d5205aa847cafaecb8af1476a609a2f6eb7"},
{file = "cffi-1.17.1-cp39-cp39-win_amd64.whl", hash = "sha256:d016c76bdd850f3c626af19b0542c9677ba156e4ee4fccfdd7848803533ef662"},
{file = "cffi-1.17.1.tar.gz", hash = "sha256:1c39c6016c32bc48dd54561950ebd6836e1670f2ae46128f67cf49e789c52824"},
]
markers = {main = "platform_python_implementation != \"PyPy\""}
[package.dependencies]
pycparser = "*"
[[package]]
name = "charset-normalizer"
version = "3.4.1"
description = "The Real First Universal Charset Detector. Open, modern and actively maintained alternative to Chardet."
optional = false
python-versions = ">=3.7"
groups = ["docs"]
files = [
{file = "charset_normalizer-3.4.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:91b36a978b5ae0ee86c394f5a54d6ef44db1de0815eb43de826d41d21e4af3de"},
{file = "charset_normalizer-3.4.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7461baadb4dc00fd9e0acbe254e3d7d2112e7f92ced2adc96e54ef6501c5f176"},
{file = "charset_normalizer-3.4.1-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e218488cd232553829be0664c2292d3af2eeeb94b32bea483cf79ac6a694e037"},
{file = "charset_normalizer-3.4.1-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:80ed5e856eb7f30115aaf94e4a08114ccc8813e6ed1b5efa74f9f82e8509858f"},
{file = "charset_normalizer-3.4.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b010a7a4fd316c3c484d482922d13044979e78d1861f0e0650423144c616a46a"},
{file = "charset_normalizer-3.4.1-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:4532bff1b8421fd0a320463030c7520f56a79c9024a4e88f01c537316019005a"},
{file = "charset_normalizer-3.4.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:d973f03c0cb71c5ed99037b870f2be986c3c05e63622c017ea9816881d2dd247"},
{file = "charset_normalizer-3.4.1-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:3a3bd0dcd373514dcec91c411ddb9632c0d7d92aed7093b8c3bbb6d69ca74408"},
{file = "charset_normalizer-3.4.1-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:d9c3cdf5390dcd29aa8056d13e8e99526cda0305acc038b96b30352aff5ff2bb"},
{file = "charset_normalizer-3.4.1-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:2bdfe3ac2e1bbe5b59a1a63721eb3b95fc9b6817ae4a46debbb4e11f6232428d"},
{file = "charset_normalizer-3.4.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:eab677309cdb30d047996b36d34caeda1dc91149e4fdca0b1a039b3f79d9a807"},
{file = "charset_normalizer-3.4.1-cp310-cp310-win32.whl", hash = "sha256:c0429126cf75e16c4f0ad00ee0eae4242dc652290f940152ca8c75c3a4b6ee8f"},
{file = "charset_normalizer-3.4.1-cp310-cp310-win_amd64.whl", hash = "sha256:9f0b8b1c6d84c8034a44893aba5e767bf9c7a211e313a9605d9c617d7083829f"},
{file = "charset_normalizer-3.4.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:8bfa33f4f2672964266e940dd22a195989ba31669bd84629f05fab3ef4e2d125"},
{file = "charset_normalizer-3.4.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:28bf57629c75e810b6ae989f03c0828d64d6b26a5e205535585f96093e405ed1"},
{file = "charset_normalizer-3.4.1-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f08ff5e948271dc7e18a35641d2f11a4cd8dfd5634f55228b691e62b37125eb3"},
{file = "charset_normalizer-3.4.1-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:234ac59ea147c59ee4da87a0c0f098e9c8d169f4dc2a159ef720f1a61bbe27cd"},
{file = "charset_normalizer-3.4.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fd4ec41f914fa74ad1b8304bbc634b3de73d2a0889bd32076342a573e0779e00"},
{file = "charset_normalizer-3.4.1-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:eea6ee1db730b3483adf394ea72f808b6e18cf3cb6454b4d86e04fa8c4327a12"},
{file = "charset_normalizer-3.4.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:c96836c97b1238e9c9e3fe90844c947d5afbf4f4c92762679acfe19927d81d77"},
{file = "charset_normalizer-3.4.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:4d86f7aff21ee58f26dcf5ae81a9addbd914115cdebcbb2217e4f0ed8982e146"},
{file = "charset_normalizer-3.4.1-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:09b5e6733cbd160dcc09589227187e242a30a49ca5cefa5a7edd3f9d19ed53fd"},
{file = "charset_normalizer-3.4.1-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:5777ee0881f9499ed0f71cc82cf873d9a0ca8af166dfa0af8ec4e675b7df48e6"},
{file = "charset_normalizer-3.4.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:237bdbe6159cff53b4f24f397d43c6336c6b0b42affbe857970cefbb620911c8"},
{file = "charset_normalizer-3.4.1-cp311-cp311-win32.whl", hash = "sha256:8417cb1f36cc0bc7eaba8ccb0e04d55f0ee52df06df3ad55259b9a323555fc8b"},
{file = "charset_normalizer-3.4.1-cp311-cp311-win_amd64.whl", hash = "sha256:d7f50a1f8c450f3925cb367d011448c39239bb3eb4117c36a6d354794de4ce76"},
{file = "charset_normalizer-3.4.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:73d94b58ec7fecbc7366247d3b0b10a21681004153238750bb67bd9012414545"},
{file = "charset_normalizer-3.4.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:dad3e487649f498dd991eeb901125411559b22e8d7ab25d3aeb1af367df5efd7"},
{file = "charset_normalizer-3.4.1-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:c30197aa96e8eed02200a83fba2657b4c3acd0f0aa4bdc9f6c1af8e8962e0757"},
{file = "charset_normalizer-3.4.1-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2369eea1ee4a7610a860d88f268eb39b95cb588acd7235e02fd5a5601773d4fa"},
{file = "charset_normalizer-3.4.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bc2722592d8998c870fa4e290c2eec2c1569b87fe58618e67d38b4665dfa680d"},
{file = "charset_normalizer-3.4.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ffc9202a29ab3920fa812879e95a9e78b2465fd10be7fcbd042899695d75e616"},
{file = "charset_normalizer-3.4.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:804a4d582ba6e5b747c625bf1255e6b1507465494a40a2130978bda7b932c90b"},
{file = "charset_normalizer-3.4.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:0f55e69f030f7163dffe9fd0752b32f070566451afe180f99dbeeb81f511ad8d"},
{file = "charset_normalizer-3.4.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:c4c3e6da02df6fa1410a7680bd3f63d4f710232d3139089536310d027950696a"},
{file = "charset_normalizer-3.4.1-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:5df196eb874dae23dcfb968c83d4f8fdccb333330fe1fc278ac5ceeb101003a9"},
{file = "charset_normalizer-3.4.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:e358e64305fe12299a08e08978f51fc21fac060dcfcddd95453eabe5b93ed0e1"},
{file = "charset_normalizer-3.4.1-cp312-cp312-win32.whl", hash = "sha256:9b23ca7ef998bc739bf6ffc077c2116917eabcc901f88da1b9856b210ef63f35"},
{file = "charset_normalizer-3.4.1-cp312-cp312-win_amd64.whl", hash = "sha256:6ff8a4a60c227ad87030d76e99cd1698345d4491638dfa6673027c48b3cd395f"},
{file = "charset_normalizer-3.4.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:aabfa34badd18f1da5ec1bc2715cadc8dca465868a4e73a0173466b688f29dda"},
{file = "charset_normalizer-3.4.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:22e14b5d70560b8dd51ec22863f370d1e595ac3d024cb8ad7d308b4cd95f8313"},
{file = "charset_normalizer-3.4.1-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8436c508b408b82d87dc5f62496973a1805cd46727c34440b0d29d8a2f50a6c9"},
{file = "charset_normalizer-3.4.1-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2d074908e1aecee37a7635990b2c6d504cd4766c7bc9fc86d63f9c09af3fa11b"},
{file = "charset_normalizer-3.4.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:955f8851919303c92343d2f66165294848d57e9bba6cf6e3625485a70a038d11"},
{file = "charset_normalizer-3.4.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:44ecbf16649486d4aebafeaa7ec4c9fed8b88101f4dd612dcaf65d5e815f837f"},
{file = "charset_normalizer-3.4.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:0924e81d3d5e70f8126529951dac65c1010cdf117bb75eb02dd12339b57749dd"},
{file = "charset_normalizer-3.4.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:2967f74ad52c3b98de4c3b32e1a44e32975e008a9cd2a8cc8966d6a5218c5cb2"},
{file = "charset_normalizer-3.4.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:c75cb2a3e389853835e84a2d8fb2b81a10645b503eca9bcb98df6b5a43eb8886"},
{file = "charset_normalizer-3.4.1-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:09b26ae6b1abf0d27570633b2b078a2a20419c99d66fb2823173d73f188ce601"},
{file = "charset_normalizer-3.4.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:fa88b843d6e211393a37219e6a1c1df99d35e8fd90446f1118f4216e307e48cd"},
{file = "charset_normalizer-3.4.1-cp313-cp313-win32.whl", hash = "sha256:eb8178fe3dba6450a3e024e95ac49ed3400e506fd4e9e5c32d30adda88cbd407"},
{file = "charset_normalizer-3.4.1-cp313-cp313-win_amd64.whl", hash = "sha256:b1ac5992a838106edb89654e0aebfc24f5848ae2547d22c2c3f66454daa11971"},
{file = "charset_normalizer-3.4.1-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f30bf9fd9be89ecb2360c7d94a711f00c09b976258846efe40db3d05828e8089"},
{file = "charset_normalizer-3.4.1-cp37-cp37m-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:97f68b8d6831127e4787ad15e6757232e14e12060bec17091b85eb1486b91d8d"},
{file = "charset_normalizer-3.4.1-cp37-cp37m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:7974a0b5ecd505609e3b19742b60cee7aa2aa2fb3151bc917e6e2646d7667dcf"},
{file = "charset_normalizer-3.4.1-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fc54db6c8593ef7d4b2a331b58653356cf04f67c960f584edb7c3d8c97e8f39e"},
{file = "charset_normalizer-3.4.1-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:311f30128d7d333eebd7896965bfcfbd0065f1716ec92bd5638d7748eb6f936a"},
{file = "charset_normalizer-3.4.1-cp37-cp37m-musllinux_1_2_aarch64.whl", hash = "sha256:7d053096f67cd1241601111b698f5cad775f97ab25d81567d3f59219b5f1adbd"},
{file = "charset_normalizer-3.4.1-cp37-cp37m-musllinux_1_2_i686.whl", hash = "sha256:807f52c1f798eef6cf26beb819eeb8819b1622ddfeef9d0977a8502d4db6d534"},
{file = "charset_normalizer-3.4.1-cp37-cp37m-musllinux_1_2_ppc64le.whl", hash = "sha256:dccbe65bd2f7f7ec22c4ff99ed56faa1e9f785482b9bbd7c717e26fd723a1d1e"},
{file = "charset_normalizer-3.4.1-cp37-cp37m-musllinux_1_2_s390x.whl", hash = "sha256:2fb9bd477fdea8684f78791a6de97a953c51831ee2981f8e4f583ff3b9d9687e"},
{file = "charset_normalizer-3.4.1-cp37-cp37m-musllinux_1_2_x86_64.whl", hash = "sha256:01732659ba9b5b873fc117534143e4feefecf3b2078b0a6a2e925271bb6f4cfa"},
{file = "charset_normalizer-3.4.1-cp37-cp37m-win32.whl", hash = "sha256:7a4f97a081603d2050bfaffdefa5b02a9ec823f8348a572e39032caa8404a487"},
{file = "charset_normalizer-3.4.1-cp37-cp37m-win_amd64.whl", hash = "sha256:7b1bef6280950ee6c177b326508f86cad7ad4dff12454483b51d8b7d673a2c5d"},
{file = "charset_normalizer-3.4.1-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:ecddf25bee22fe4fe3737a399d0d177d72bc22be6913acfab364b40bce1ba83c"},
{file = "charset_normalizer-3.4.1-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8c60ca7339acd497a55b0ea5d506b2a2612afb2826560416f6894e8b5770d4a9"},
{file = "charset_normalizer-3.4.1-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b7b2d86dd06bfc2ade3312a83a5c364c7ec2e3498f8734282c6c3d4b07b346b8"},
{file = "charset_normalizer-3.4.1-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:dd78cfcda14a1ef52584dbb008f7ac81c1328c0f58184bf9a84c49c605002da6"},
{file = "charset_normalizer-3.4.1-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6e27f48bcd0957c6d4cb9d6fa6b61d192d0b13d5ef563e5f2ae35feafc0d179c"},
{file = "charset_normalizer-3.4.1-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:01ad647cdd609225c5350561d084b42ddf732f4eeefe6e678765636791e78b9a"},
{file = "charset_normalizer-3.4.1-cp38-cp38-musllinux_1_2_aarch64.whl", hash = "sha256:619a609aa74ae43d90ed2e89bdd784765de0a25ca761b93e196d938b8fd1dbbd"},
{file = "charset_normalizer-3.4.1-cp38-cp38-musllinux_1_2_i686.whl", hash = "sha256:89149166622f4db9b4b6a449256291dc87a99ee53151c74cbd82a53c8c2f6ccd"},
{file = "charset_normalizer-3.4.1-cp38-cp38-musllinux_1_2_ppc64le.whl", hash = "sha256:7709f51f5f7c853f0fb938bcd3bc59cdfdc5203635ffd18bf354f6967ea0f824"},
{file = "charset_normalizer-3.4.1-cp38-cp38-musllinux_1_2_s390x.whl", hash = "sha256:345b0426edd4e18138d6528aed636de7a9ed169b4aaf9d61a8c19e39d26838ca"},
{file = "charset_normalizer-3.4.1-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:0907f11d019260cdc3f94fbdb23ff9125f6b5d1039b76003b5b0ac9d6a6c9d5b"},
{file = "charset_normalizer-3.4.1-cp38-cp38-win32.whl", hash = "sha256:ea0d8d539afa5eb2728aa1932a988a9a7af94f18582ffae4bc10b3fbdad0626e"},
{file = "charset_normalizer-3.4.1-cp38-cp38-win_amd64.whl", hash = "sha256:329ce159e82018d646c7ac45b01a430369d526569ec08516081727a20e9e4af4"},
{file = "charset_normalizer-3.4.1-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:b97e690a2118911e39b4042088092771b4ae3fc3aa86518f84b8cf6888dbdb41"},
{file = "charset_normalizer-3.4.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:78baa6d91634dfb69ec52a463534bc0df05dbd546209b79a3880a34487f4b84f"},
{file = "charset_normalizer-3.4.1-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1a2bc9f351a75ef49d664206d51f8e5ede9da246602dc2d2726837620ea034b2"},
{file = "charset_normalizer-3.4.1-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:75832c08354f595c760a804588b9357d34ec00ba1c940c15e31e96d902093770"},
{file = "charset_normalizer-3.4.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0af291f4fe114be0280cdd29d533696a77b5b49cfde5467176ecab32353395c4"},
{file = "charset_normalizer-3.4.1-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0167ddc8ab6508fe81860a57dd472b2ef4060e8d378f0cc555707126830f2537"},
{file = "charset_normalizer-3.4.1-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:2a75d49014d118e4198bcee5ee0a6f25856b29b12dbf7cd012791f8a6cc5c496"},
{file = "charset_normalizer-3.4.1-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:363e2f92b0f0174b2f8238240a1a30142e3db7b957a5dd5689b0e75fb717cc78"},
{file = "charset_normalizer-3.4.1-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:ab36c8eb7e454e34e60eb55ca5d241a5d18b2c6244f6827a30e451c42410b5f7"},
{file = "charset_normalizer-3.4.1-cp39-cp39-musllinux_1_2_s390x.whl", hash = "sha256:4c0907b1928a36d5a998d72d64d8eaa7244989f7aaaf947500d3a800c83a3fd6"},
{file = "charset_normalizer-3.4.1-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:04432ad9479fa40ec0f387795ddad4437a2b50417c69fa275e212933519ff294"},
{file = "charset_normalizer-3.4.1-cp39-cp39-win32.whl", hash = "sha256:3bed14e9c89dcb10e8f3a29f9ccac4955aebe93c71ae803af79265c9ca5644c5"},
{file = "charset_normalizer-3.4.1-cp39-cp39-win_amd64.whl", hash = "sha256:49402233c892a461407c512a19435d1ce275543138294f7ef013f0b63d5d3765"},
{file = "charset_normalizer-3.4.1-py3-none-any.whl", hash = "sha256:d98b1668f06378c6dbefec3b92299716b931cd4e6061f3c875a71ced1780ab85"},
{file = "charset_normalizer-3.4.1.tar.gz", hash = "sha256:44251f18cd68a75b56585dd00dae26183e102cd5e0f9f1466e6df5da2ed64ea3"},
]
[[package]]
name = "click"
version = "8.1.8"
description = "Composable command line interface toolkit"
optional = false
python-versions = ">=3.7"
groups = ["docs"]
files = [
{file = "click-8.1.8-py3-none-any.whl", hash = "sha256:63c132bbbed01578a06712a2d1f497bb62d9c1c0d329b7903a866228027263b2"},
{file = "click-8.1.8.tar.gz", hash = "sha256:ed53c9d8990d83c2a27deae68e4ee337473f6330c040a31d4225c9574d16096a"},
]
[package.dependencies]
colorama = {version = "*", markers = "platform_system == \"Windows\""}
[[package]]
name = "colorama"
version = "0.4.6"
description = "Cross-platform colored terminal text."
optional = false
python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7"
groups = ["dev", "docs"]
files = [
{file = "colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6"},
{file = "colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44"},
]
markers = {dev = "sys_platform == \"win32\""}
[[package]]
name = "coverage"
version = "7.8.0"
description = "Code coverage measurement for Python"
optional = false
python-versions = ">=3.9"
groups = ["dev"]
files = [
{file = "coverage-7.8.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:2931f66991175369859b5fd58529cd4b73582461877ecfd859b6549869287ffe"},
{file = "coverage-7.8.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:52a523153c568d2c0ef8826f6cc23031dc86cffb8c6aeab92c4ff776e7951b28"},
{file = "coverage-7.8.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5c8a5c139aae4c35cbd7cadca1df02ea8cf28a911534fc1b0456acb0b14234f3"},
{file = "coverage-7.8.0-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:5a26c0c795c3e0b63ec7da6efded5f0bc856d7c0b24b2ac84b4d1d7bc578d676"},
{file = "coverage-7.8.0-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:821f7bcbaa84318287115d54becb1915eece6918136c6f91045bb84e2f88739d"},
{file = "coverage-7.8.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:a321c61477ff8ee705b8a5fed370b5710c56b3a52d17b983d9215861e37b642a"},
{file = "coverage-7.8.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:ed2144b8a78f9d94d9515963ed273d620e07846acd5d4b0a642d4849e8d91a0c"},
{file = "coverage-7.8.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:042e7841a26498fff7a37d6fda770d17519982f5b7d8bf5278d140b67b61095f"},
{file = "coverage-7.8.0-cp310-cp310-win32.whl", hash = "sha256:f9983d01d7705b2d1f7a95e10bbe4091fabc03a46881a256c2787637b087003f"},
{file = "coverage-7.8.0-cp310-cp310-win_amd64.whl", hash = "sha256:5a570cd9bd20b85d1a0d7b009aaf6c110b52b5755c17be6962f8ccd65d1dbd23"},
{file = "coverage-7.8.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:e7ac22a0bb2c7c49f441f7a6d46c9c80d96e56f5a8bc6972529ed43c8b694e27"},
{file = "coverage-7.8.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:bf13d564d310c156d1c8e53877baf2993fb3073b2fc9f69790ca6a732eb4bfea"},
{file = "coverage-7.8.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a5761c70c017c1b0d21b0815a920ffb94a670c8d5d409d9b38857874c21f70d7"},
{file = "coverage-7.8.0-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e5ff52d790c7e1628241ffbcaeb33e07d14b007b6eb00a19320c7b8a7024c040"},
{file = "coverage-7.8.0-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d39fc4817fd67b3915256af5dda75fd4ee10621a3d484524487e33416c6f3543"},
{file = "coverage-7.8.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:b44674870709017e4b4036e3d0d6c17f06a0e6d4436422e0ad29b882c40697d2"},
{file = "coverage-7.8.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:8f99eb72bf27cbb167b636eb1726f590c00e1ad375002230607a844d9e9a2318"},
{file = "coverage-7.8.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:b571bf5341ba8c6bc02e0baeaf3b061ab993bf372d982ae509807e7f112554e9"},
{file = "coverage-7.8.0-cp311-cp311-win32.whl", hash = "sha256:e75a2ad7b647fd8046d58c3132d7eaf31b12d8a53c0e4b21fa9c4d23d6ee6d3c"},
{file = "coverage-7.8.0-cp311-cp311-win_amd64.whl", hash = "sha256:3043ba1c88b2139126fc72cb48574b90e2e0546d4c78b5299317f61b7f718b78"},
{file = "coverage-7.8.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:bbb5cc845a0292e0c520656d19d7ce40e18d0e19b22cb3e0409135a575bf79fc"},
{file = "coverage-7.8.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:4dfd9a93db9e78666d178d4f08a5408aa3f2474ad4d0e0378ed5f2ef71640cb6"},
{file = "coverage-7.8.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f017a61399f13aa6d1039f75cd467be388d157cd81f1a119b9d9a68ba6f2830d"},
{file = "coverage-7.8.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0915742f4c82208ebf47a2b154a5334155ed9ef9fe6190674b8a46c2fb89cb05"},
{file = "coverage-7.8.0-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8a40fcf208e021eb14b0fac6bdb045c0e0cab53105f93ba0d03fd934c956143a"},
{file = "coverage-7.8.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:a1f406a8e0995d654b2ad87c62caf6befa767885301f3b8f6f73e6f3c31ec3a6"},
{file = "coverage-7.8.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:77af0f6447a582fdc7de5e06fa3757a3ef87769fbb0fdbdeba78c23049140a47"},
{file = "coverage-7.8.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:f2d32f95922927186c6dbc8bc60df0d186b6edb828d299ab10898ef3f40052fe"},
{file = "coverage-7.8.0-cp312-cp312-win32.whl", hash = "sha256:769773614e676f9d8e8a0980dd7740f09a6ea386d0f383db6821df07d0f08545"},
{file = "coverage-7.8.0-cp312-cp312-win_amd64.whl", hash = "sha256:e5d2b9be5b0693cf21eb4ce0ec8d211efb43966f6657807f6859aab3814f946b"},
{file = "coverage-7.8.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:5ac46d0c2dd5820ce93943a501ac5f6548ea81594777ca585bf002aa8854cacd"},
{file = "coverage-7.8.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:771eb7587a0563ca5bb6f622b9ed7f9d07bd08900f7589b4febff05f469bea00"},
{file = "coverage-7.8.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:42421e04069fb2cbcbca5a696c4050b84a43b05392679d4068acbe65449b5c64"},
{file = "coverage-7.8.0-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:554fec1199d93ab30adaa751db68acec2b41c5602ac944bb19187cb9a41a8067"},
{file = "coverage-7.8.0-cp313-cp313-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5aaeb00761f985007b38cf463b1d160a14a22c34eb3f6a39d9ad6fc27cb73008"},
{file = "coverage-7.8.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:581a40c7b94921fffd6457ffe532259813fc68eb2bdda60fa8cc343414ce3733"},
{file = "coverage-7.8.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:f319bae0321bc838e205bf9e5bc28f0a3165f30c203b610f17ab5552cff90323"},
{file = "coverage-7.8.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:04bfec25a8ef1c5f41f5e7e5c842f6b615599ca8ba8391ec33a9290d9d2db3a3"},
{file = "coverage-7.8.0-cp313-cp313-win32.whl", hash = "sha256:dd19608788b50eed889e13a5d71d832edc34fc9dfce606f66e8f9f917eef910d"},
{file = "coverage-7.8.0-cp313-cp313-win_amd64.whl", hash = "sha256:a9abbccd778d98e9c7e85038e35e91e67f5b520776781d9a1e2ee9d400869487"},
{file = "coverage-7.8.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:18c5ae6d061ad5b3e7eef4363fb27a0576012a7447af48be6c75b88494c6cf25"},
{file = "coverage-7.8.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:95aa6ae391a22bbbce1b77ddac846c98c5473de0372ba5c463480043a07bff42"},
{file = "coverage-7.8.0-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e013b07ba1c748dacc2a80e69a46286ff145935f260eb8c72df7185bf048f502"},
{file = "coverage-7.8.0-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d766a4f0e5aa1ba056ec3496243150698dc0481902e2b8559314368717be82b1"},
{file = "coverage-7.8.0-cp313-cp313t-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ad80e6b4a0c3cb6f10f29ae4c60e991f424e6b14219d46f1e7d442b938ee68a4"},
{file = "coverage-7.8.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:b87eb6fc9e1bb8f98892a2458781348fa37e6925f35bb6ceb9d4afd54ba36c73"},
{file = "coverage-7.8.0-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:d1ba00ae33be84066cfbe7361d4e04dec78445b2b88bdb734d0d1cbab916025a"},
{file = "coverage-7.8.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:f3c38e4e5ccbdc9198aecc766cedbb134b2d89bf64533973678dfcf07effd883"},
{file = "coverage-7.8.0-cp313-cp313t-win32.whl", hash = "sha256:379fe315e206b14e21db5240f89dc0774bdd3e25c3c58c2c733c99eca96f1ada"},
{file = "coverage-7.8.0-cp313-cp313t-win_amd64.whl", hash = "sha256:2e4b6b87bb0c846a9315e3ab4be2d52fac905100565f4b92f02c445c8799e257"},
{file = "coverage-7.8.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:fa260de59dfb143af06dcf30c2be0b200bed2a73737a8a59248fcb9fa601ef0f"},
{file = "coverage-7.8.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:96121edfa4c2dfdda409877ea8608dd01de816a4dc4a0523356067b305e4e17a"},
{file = "coverage-7.8.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6b8af63b9afa1031c0ef05b217faa598f3069148eeee6bb24b79da9012423b82"},
{file = "coverage-7.8.0-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:89b1f4af0d4afe495cd4787a68e00f30f1d15939f550e869de90a86efa7e0814"},
{file = "coverage-7.8.0-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:94ec0be97723ae72d63d3aa41961a0b9a6f5a53ff599813c324548d18e3b9e8c"},
{file = "coverage-7.8.0-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:8a1d96e780bdb2d0cbb297325711701f7c0b6f89199a57f2049e90064c29f6bd"},
{file = "coverage-7.8.0-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:f1d8a2a57b47142b10374902777e798784abf400a004b14f1b0b9eaf1e528ba4"},
{file = "coverage-7.8.0-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:cf60dd2696b457b710dd40bf17ad269d5f5457b96442f7f85722bdb16fa6c899"},
{file = "coverage-7.8.0-cp39-cp39-win32.whl", hash = "sha256:be945402e03de47ba1872cd5236395e0f4ad635526185a930735f66710e1bd3f"},
{file = "coverage-7.8.0-cp39-cp39-win_amd64.whl", hash = "sha256:90e7fbc6216ecaffa5a880cdc9c77b7418c1dcb166166b78dbc630d07f278cc3"},
{file = "coverage-7.8.0-pp39.pp310.pp311-none-any.whl", hash = "sha256:b8194fb8e50d556d5849753de991d390c5a1edeeba50f68e3a9253fbd8bf8ccd"},
{file = "coverage-7.8.0-py3-none-any.whl", hash = "sha256:dbf364b4c5e7bae9250528167dfe40219b62e2d573c854d74be213e1e52069f7"},
{file = "coverage-7.8.0.tar.gz", hash = "sha256:7a3d62b3b03b4b6fd41a085f3574874cf946cb4604d2b4d3e8dca8cd570ca501"},
]
[package.extras]
toml = ["tomli ; python_full_version <= \"3.11.0a6\""]
[[package]]
name = "cryptography"
version = "44.0.2"
description = "cryptography is a package which provides cryptographic recipes and primitives to Python developers."
optional = false
python-versions = "!=3.9.0,!=3.9.1,>=3.7"
groups = ["main"]
files = [
{file = "cryptography-44.0.2-cp37-abi3-macosx_10_9_universal2.whl", hash = "sha256:efcfe97d1b3c79e486554efddeb8f6f53a4cdd4cf6086642784fa31fc384e1d7"},
{file = "cryptography-44.0.2-cp37-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:29ecec49f3ba3f3849362854b7253a9f59799e3763b0c9d0826259a88efa02f1"},
{file = "cryptography-44.0.2-cp37-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bc821e161ae88bfe8088d11bb39caf2916562e0a2dc7b6d56714a48b784ef0bb"},
{file = "cryptography-44.0.2-cp37-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:3c00b6b757b32ce0f62c574b78b939afab9eecaf597c4d624caca4f9e71e7843"},
{file = "cryptography-44.0.2-cp37-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:7bdcd82189759aba3816d1f729ce42ffded1ac304c151d0a8e89b9996ab863d5"},
{file = "cryptography-44.0.2-cp37-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:4973da6ca3db4405c54cd0b26d328be54c7747e89e284fcff166132eb7bccc9c"},
{file = "cryptography-44.0.2-cp37-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:4e389622b6927d8133f314949a9812972711a111d577a5d1f4bee5e58736b80a"},
{file = "cryptography-44.0.2-cp37-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:f514ef4cd14bb6fb484b4a60203e912cfcb64f2ab139e88c2274511514bf7308"},
{file = "cryptography-44.0.2-cp37-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:1bc312dfb7a6e5d66082c87c34c8a62176e684b6fe3d90fcfe1568de675e6688"},
{file = "cryptography-44.0.2-cp37-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:3b721b8b4d948b218c88cb8c45a01793483821e709afe5f622861fc6182b20a7"},
{file = "cryptography-44.0.2-cp37-abi3-win32.whl", hash = "sha256:51e4de3af4ec3899d6d178a8c005226491c27c4ba84101bfb59c901e10ca9f79"},
{file = "cryptography-44.0.2-cp37-abi3-win_amd64.whl", hash = "sha256:c505d61b6176aaf982c5717ce04e87da5abc9a36a5b39ac03905c4aafe8de7aa"},
{file = "cryptography-44.0.2-cp39-abi3-macosx_10_9_universal2.whl", hash = "sha256:8e0ddd63e6bf1161800592c71ac794d3fb8001f2caebe0966e77c5234fa9efc3"},
{file = "cryptography-44.0.2-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:81276f0ea79a208d961c433a947029e1a15948966658cf6710bbabb60fcc2639"},
{file = "cryptography-44.0.2-cp39-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9a1e657c0f4ea2a23304ee3f964db058c9e9e635cc7019c4aa21c330755ef6fd"},
{file = "cryptography-44.0.2-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:6210c05941994290f3f7f175a4a57dbbb2afd9273657614c506d5976db061181"},
{file = "cryptography-44.0.2-cp39-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:d1c3572526997b36f245a96a2b1713bf79ce99b271bbcf084beb6b9b075f29ea"},
{file = "cryptography-44.0.2-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:b042d2a275c8cee83a4b7ae30c45a15e6a4baa65a179a0ec2d78ebb90e4f6699"},
{file = "cryptography-44.0.2-cp39-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:d03806036b4f89e3b13b6218fefea8d5312e450935b1a2d55f0524e2ed7c59d9"},
{file = "cryptography-44.0.2-cp39-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:c7362add18b416b69d58c910caa217f980c5ef39b23a38a0880dfd87bdf8cd23"},
{file = "cryptography-44.0.2-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:8cadc6e3b5a1f144a039ea08a0bdb03a2a92e19c46be3285123d32029f40a922"},
{file = "cryptography-44.0.2-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:6f101b1f780f7fc613d040ca4bdf835c6ef3b00e9bd7125a4255ec574c7916e4"},
{file = "cryptography-44.0.2-cp39-abi3-win32.whl", hash = "sha256:3dc62975e31617badc19a906481deacdeb80b4bb454394b4098e3f2525a488c5"},
{file = "cryptography-44.0.2-cp39-abi3-win_amd64.whl", hash = "sha256:5f6f90b72d8ccadb9c6e311c775c8305381db88374c65fa1a68250aa8a9cb3a6"},
{file = "cryptography-44.0.2-pp310-pypy310_pp73-macosx_10_9_x86_64.whl", hash = "sha256:af4ff3e388f2fa7bff9f7f2b31b87d5651c45731d3e8cfa0944be43dff5cfbdb"},
{file = "cryptography-44.0.2-pp310-pypy310_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:0529b1d5a0105dd3731fa65680b45ce49da4d8115ea76e9da77a875396727b41"},
{file = "cryptography-44.0.2-pp310-pypy310_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:7ca25849404be2f8e4b3c59483d9d3c51298a22c1c61a0e84415104dacaf5562"},
{file = "cryptography-44.0.2-pp310-pypy310_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:268e4e9b177c76d569e8a145a6939eca9a5fec658c932348598818acf31ae9a5"},
{file = "cryptography-44.0.2-pp310-pypy310_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:9eb9d22b0a5d8fd9925a7764a054dca914000607dff201a24c791ff5c799e1fa"},
{file = "cryptography-44.0.2-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:2bf7bf75f7df9715f810d1b038870309342bff3069c5bd8c6b96128cb158668d"},
{file = "cryptography-44.0.2-pp311-pypy311_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:909c97ab43a9c0c0b0ada7a1281430e4e5ec0458e6d9244c0e821bbf152f061d"},
{file = "cryptography-44.0.2-pp311-pypy311_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:96e7a5e9d6e71f9f4fca8eebfd603f8e86c5225bb18eb621b2c1e50b290a9471"},
{file = "cryptography-44.0.2-pp311-pypy311_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:d1b3031093a366ac767b3feb8bcddb596671b3aaff82d4050f984da0c248b615"},
{file = "cryptography-44.0.2-pp311-pypy311_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:04abd71114848aa25edb28e225ab5f268096f44cf0127f3d36975bdf1bdf3390"},
{file = "cryptography-44.0.2.tar.gz", hash = "sha256:c63454aa261a0cf0c5b4718349629793e9e634993538db841165b3df74f37ec0"},
]
[package.dependencies]
cffi = {version = ">=1.12", markers = "platform_python_implementation != \"PyPy\""}
[package.extras]
docs = ["sphinx (>=5.3.0)", "sphinx-rtd-theme (>=3.0.0) ; python_version >= \"3.8\""]
docstest = ["pyenchant (>=3)", "readme-renderer (>=30.0)", "sphinxcontrib-spelling (>=7.3.1)"]
nox = ["nox (>=2024.4.15)", "nox[uv] (>=2024.3.2) ; python_version >= \"3.8\""]
pep8test = ["check-sdist ; python_version >= \"3.8\"", "click (>=8.0.1)", "mypy (>=1.4)", "ruff (>=0.3.6)"]
sdist = ["build (>=1.0.0)"]
ssh = ["bcrypt (>=3.1.5)"]
test = ["certifi (>=2024)", "cryptography-vectors (==44.0.2)", "pretend (>=0.7)", "pytest (>=7.4.0)", "pytest-benchmark (>=4.0)", "pytest-cov (>=2.10.1)", "pytest-xdist (>=3.5.0)"]
test-randomorder = ["pytest-randomly"]
[[package]]
name = "dbus-fast"
version = "2.44.1"
description = "A faster version of dbus-next"
optional = false
python-versions = ">=3.9"
groups = ["main", "dev"]
files = [
{file = "dbus_fast-2.44.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:c78a004ba43aeaf203a19169d2b4be238375905645999da30cb0da730df80cf2"},
{file = "dbus_fast-2.44.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:65a634286651398f3f1326e8200fc54289d52c2c00249d29cacfc691660a5da1"},
{file = "dbus_fast-2.44.1-cp310-cp310-manylinux_2_17_i686.manylinux_2_5_i686.manylinux1_i686.manylinux2014_i686.whl", hash = "sha256:0c4a128f8b29941307fc5722f37a1bb87ddcf733188d917ab374d9da0c6e1ce7"},
{file = "dbus_fast-2.44.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:adaf459fbce22a63d3578f3ec782c6978edf975eb06d71fb5b7a690496cf6bbe"},
{file = "dbus_fast-2.44.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:de871cf722c436bdcceb96b2a3af7084e1fa468f7916ae278ec8ec49a6fa7eef"},
{file = "dbus_fast-2.44.1-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:b40863de172031bcc02f54c6f05cccb0b882dc2e1b09e11314a8ccf38c558760"},
{file = "dbus_fast-2.44.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:8b7ae16555df6b56d3befcc51e036779ef47c0e954fdb9fb0821ac25212aefe9"},
{file = "dbus_fast-2.44.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:a220a28e88062a2548f0c6da9eb15fb7e3af70eae56729fc3795ce3e3fba057d"},
{file = "dbus_fast-2.44.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4ec5db912bd4cfeadf7134163d6dde684271cd44cf26e3b4720107f3de406623"},
{file = "dbus_fast-2.44.1-cp311-cp311-manylinux_2_17_i686.manylinux_2_5_i686.manylinux1_i686.manylinux2014_i686.whl", hash = "sha256:6ad99f626837753b39a39e09facd2091ee4851ee1eb6ebec5fa9a9a231734254"},
{file = "dbus_fast-2.44.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f7aa157f689a114bfb5367c55884d35e25d57cf25202a6590ce05010f929e7df"},
{file = "dbus_fast-2.44.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:f961d8bcad80359f24c0156b3094f58a87d583d56139ee50922fe5894b6797cf"},
{file = "dbus_fast-2.44.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:1f38fb5c31846c3ada8fc2b693d8d19953d376a9ea21079e3686e93faa1f8a0f"},
{file = "dbus_fast-2.44.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:35e3cde53cc9180ce95c6c84a1e8d1ded429031e4a0a182606e8d22cf57d3294"},
{file = "dbus_fast-2.44.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f3f30fb09f1ea13658fb4316511e27d6b94f8363b16f2d093efe73e6e289b740"},
{file = "dbus_fast-2.44.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3dd0f8d41f6ab9d4a782c116470bc319d690f9b50c97b6debc6d1fef08e4615a"},
{file = "dbus_fast-2.44.1-cp312-cp312-manylinux_2_17_i686.manylinux_2_5_i686.manylinux1_i686.manylinux2014_i686.whl", hash = "sha256:9d6e386658343db380b9e4e81b3bf4e3c17135dbb5889173b1f2582b675b9a8c"},
{file = "dbus_fast-2.44.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3bd27563c11219b6fde7a5458141d860d8445c2defb036bab360d1f9bf1dfae0"},
{file = "dbus_fast-2.44.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:0272784aceac821dd63c8187a8860179061a850269617ff5c5bd25ca37bf9307"},
{file = "dbus_fast-2.44.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:eed613a909a45f0e0a415c88b373024f007a9be56b1316812ed616d69a3b9161"},
{file = "dbus_fast-2.44.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:0d4288f2cba4f8309dcfd9f4392e0f4f2b5be6c796dfdb0c5e03228b1ab649b1"},
{file = "dbus_fast-2.44.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:50a9a4c6921f4b7446717fb4869750f54b561ce486b25b36550cb2a910c988d9"},
{file = "dbus_fast-2.44.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:89dc5db158bf9838979f732acc39e0e1ecd7e3295a09fa8adb93b09c097615a4"},
{file = "dbus_fast-2.44.1-cp313-cp313-manylinux_2_17_i686.manylinux_2_5_i686.manylinux1_i686.manylinux2014_i686.whl", hash = "sha256:f11878c0c089d278861e48c02db8002496c2233b0f605b5630ef61f0b7fb0ea3"},
{file = "dbus_fast-2.44.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:afd81f483b3ffb71e88478cfabccc1fab8d7154fccb1c661bfafcff9b0cfd996"},
{file = "dbus_fast-2.44.1-cp313-cp313-manylinux_2_36_x86_64.whl", hash = "sha256:ad499de96a991287232749c98a59f2436ed260f6fd9ad4cb3b04a4b1bbbef148"},
{file = "dbus_fast-2.44.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:36c44286b11e83977cd29f9551b66b446bb6890dff04585852d975aa3a038ca2"},
{file = "dbus_fast-2.44.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:89f2f6eccbb0e464b90e5a8741deb9d6a91873eeb41a8c7b963962b39eb1e0cd"},
{file = "dbus_fast-2.44.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:bb74a227b071e1a7c517bf3a3e4a5a0a2660620084162e74f15010075534c9d5"},
{file = "dbus_fast-2.44.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:5e3719399e687359b0ef66af1b720661dd4f12059db1c4f506e678569a2256b4"},
{file = "dbus_fast-2.44.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:806450623ef3f8df846524da7e448edc8174261a01cfd5dfda92e3df89c0de10"},
{file = "dbus_fast-2.44.1-cp39-cp39-manylinux_2_17_i686.manylinux_2_5_i686.manylinux1_i686.manylinux2014_i686.whl", hash = "sha256:55ad499b7ef08cb76fce9c9fdcdd6589d2ebfc7e53b3d261d8f40c6d97a8d901"},
{file = "dbus_fast-2.44.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:55d717865219ec2ae9977b6d067c05261cdc3ef6205c687c8bb92b3437886e58"},
{file = "dbus_fast-2.44.1-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:39d4cc61e491e11912f76d70cc1c47387ab4f2e5b71f34bfa13eb11aa6026268"},
{file = "dbus_fast-2.44.1-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:9b3b10151f1140f7b6dd47a89fc37edd05d6213be0a1748eadba82fc144c05c2"},
{file = "dbus_fast-2.44.1-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:33772c223f5cef1bacc298e83dc04b27b3a47065b245fde766fcc126e761dca7"},
{file = "dbus_fast-2.44.1-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:80e3f42f982af45bcfa0ff23e808f3aa54a45fe4bf43aadd3beb5ace816fba76"},
{file = "dbus_fast-2.44.1-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f29a81d86c9ce3020a5df8c1e5557edaa00e1e00c9804ec874d46c99d967a686"},
{file = "dbus_fast-2.44.1-pp310-pypy310_pp73-manylinux_2_17_i686.manylinux_2_5_i686.manylinux1_i686.manylinux2014_i686.whl", hash = "sha256:5dec134715457601c0fa8df3040a56d319de1a152464ae4d4bfc53bbb5c02e04"},
{file = "dbus_fast-2.44.1-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux_2_5_x86_64.manylinux1_x86_64.manylinux2014_x86_64.whl", hash = "sha256:893509b516f2f24b4e3f09a6b1f3a30f856cf237cd773cdc505ea7ab4fa3c863"},
{file = "dbus_fast-2.44.1-pp39-pypy39_pp73-macosx_11_0_arm64.whl", hash = "sha256:db81275d708774f6a17c89f2e063398c0deb358c4d22b663a3dd99861f6683a4"},
{file = "dbus_fast-2.44.1-pp39-pypy39_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:161a3e6fc8783c30c9feb072e09604d96ec0c465b06bd35b6acc1a0316bd2a27"},
{file = "dbus_fast-2.44.1-pp39-pypy39_pp73-manylinux_2_17_i686.manylinux_2_5_i686.manylinux1_i686.manylinux2014_i686.whl", hash = "sha256:67febe6454e714d85a532bd84969001ed948bbaf1699a7e1e4c6abb5508c9522"},
{file = "dbus_fast-2.44.1-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux_2_5_x86_64.manylinux1_x86_64.manylinux2014_x86_64.whl", hash = "sha256:890f0fc046d5db66524ddedeca8c14b65739fbbf32d6488175c07428362bf250"},
{file = "dbus_fast-2.44.1.tar.gz", hash = "sha256:b027e96c39ed5622bb54d811dcdbbe9d9d6edec3454808a85a1ceb1867d9e25c"},
]
[[package]]
name = "docutils"
version = "0.21.2"
description = "Docutils -- Python Documentation Utilities"
optional = false
python-versions = ">=3.9"
groups = ["docs"]
files = [
{file = "docutils-0.21.2-py3-none-any.whl", hash = "sha256:dafca5b9e384f0e419294eb4d2ff9fa826435bf15f15b7bd45723e8ad76811b2"},
{file = "docutils-0.21.2.tar.gz", hash = "sha256:3a6b18732edf182daa3cd12775bbb338cf5691468f91eeeb109deff6ebfa986f"},
]
[[package]]
name = "freezegun"
version = "1.5.1"
description = "Let your Python tests travel through time"
optional = false
python-versions = ">=3.7"
groups = ["dev"]
files = [
{file = "freezegun-1.5.1-py3-none-any.whl", hash = "sha256:bf111d7138a8abe55ab48a71755673dbaa4ab87f4cff5634a4442dfec34c15f1"},
{file = "freezegun-1.5.1.tar.gz", hash = "sha256:b29dedfcda6d5e8e083ce71b2b542753ad48cfec44037b3fc79702e2980a89e9"},
]
[package.dependencies]
python-dateutil = ">=2.7"
[[package]]
name = "furo"
version = "2024.8.6"
description = "A clean customisable Sphinx documentation theme."
optional = false
python-versions = ">=3.8"
groups = ["docs"]
files = [
{file = "furo-2024.8.6-py3-none-any.whl", hash = "sha256:6cd97c58b47813d3619e63e9081169880fbe331f0ca883c871ff1f3f11814f5c"},
{file = "furo-2024.8.6.tar.gz", hash = "sha256:b63e4cee8abfc3136d3bc03a3d45a76a850bada4d6374d24c1716b0e01394a01"},
]
[package.dependencies]
beautifulsoup4 = "*"
pygments = ">=2.7"
sphinx = ">=6.0,<9.0"
sphinx-basic-ng = ">=1.0.0.beta2"
[[package]]
name = "h11"
version = "0.16.0"
description = "A pure-Python, bring-your-own-I/O implementation of HTTP/1.1"
optional = false
python-versions = ">=3.8"
groups = ["docs"]
files = [
{file = "h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86"},
{file = "h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1"},
]
[[package]]
name = "idna"
version = "3.10"
description = "Internationalized Domain Names in Applications (IDNA)"
optional = false
python-versions = ">=3.6"
groups = ["docs"]
files = [
{file = "idna-3.10-py3-none-any.whl", hash = "sha256:946d195a0d259cbba61165e88e65941f16e9b36ea6ddb97f00452bae8b1287d3"},
{file = "idna-3.10.tar.gz", hash = "sha256:12f65c9b470abda6dc35cf8e63cc574b1c52b11df2c86030af0ac09b01b13ea9"},
]
[package.extras]
all = ["flake8 (>=7.1.1)", "mypy (>=1.11.2)", "pytest (>=8.3.2)", "ruff (>=0.6.2)"]
[[package]]
name = "imagesize"
version = "1.4.1"
description = "Getting image size from png/jpeg/jpeg2000/gif file"
optional = false
python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*"
groups = ["docs"]
files = [
{file = "imagesize-1.4.1-py2.py3-none-any.whl", hash = "sha256:0d8d18d08f840c19d0ee7ca1fd82490fdc3729b7ac93f49870406ddde8ef8d8b"},
{file = "imagesize-1.4.1.tar.gz", hash = "sha256:69150444affb9cb0d5cc5a92b3676f0b2fb7cd9ae39e947a5e11a36b4497cd4a"},
]
[[package]]
name = "iniconfig"
version = "2.1.0"
description = "brain-dead simple config-ini parsing"
optional = false
python-versions = ">=3.8"
groups = ["dev"]
files = [
{file = "iniconfig-2.1.0-py3-none-any.whl", hash = "sha256:9deba5723312380e77435581c6bf4935c94cbfab9b1ed33ef8d238ea168eb760"},
{file = "iniconfig-2.1.0.tar.gz", hash = "sha256:3abbd2e30b36733fee78f9c7f7308f2d0050e88f0087fd25c2645f63c773e1c7"},
]
[[package]]
name = "jinja2"
version = "3.1.6"
description = "A very fast and expressive template engine."
optional = false
python-versions = ">=3.7"
groups = ["docs"]
files = [
{file = "jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67"},
{file = "jinja2-3.1.6.tar.gz", hash = "sha256:0137fb05990d35f1275a587e9aee6d56da821fc83491a0fb838183be43f66d6d"},
]
[package.dependencies]
MarkupSafe = ">=2.0"
[package.extras]
i18n = ["Babel (>=2.7)"]
[[package]]
name = "markdown-it-py"
version = "3.0.0"
description = "Python port of markdown-it. Markdown parsing, done right!"
optional = false
python-versions = ">=3.8"
groups = ["dev", "docs"]
files = [
{file = "markdown-it-py-3.0.0.tar.gz", hash = "sha256:e3f60a94fa066dc52ec76661e37c851cb232d92f9886b15cb560aaada2df8feb"},
{file = "markdown_it_py-3.0.0-py3-none-any.whl", hash = "sha256:355216845c60bd96232cd8d8c40e8f9765cc86f46880e43a8fd22dc1a1a8cab1"},
]
[package.dependencies]
mdurl = ">=0.1,<1.0"
[package.extras]
benchmarking = ["psutil", "pytest", "pytest-benchmark"]
code-style = ["pre-commit (>=3.0,<4.0)"]
compare = ["commonmark (>=0.9,<1.0)", "markdown (>=3.4,<4.0)", "mistletoe (>=1.0,<2.0)", "mistune (>=2.0,<3.0)", "panflute (>=2.3,<3.0)"]
linkify = ["linkify-it-py (>=1,<3)"]
plugins = ["mdit-py-plugins"]
profiling = ["gprof2dot"]
rtd = ["jupyter_sphinx", "mdit-py-plugins", "myst-parser", "pyyaml", "sphinx", "sphinx-copybutton", "sphinx-design", "sphinx_book_theme"]
testing = ["coverage", "pytest", "pytest-cov", "pytest-regressions"]
[[package]]
name = "markupsafe"
version = "3.0.2"
description = "Safely add untrusted strings to HTML/XML markup."
optional = false
python-versions = ">=3.9"
groups = ["docs"]
files = [
{file = "MarkupSafe-3.0.2-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:7e94c425039cde14257288fd61dcfb01963e658efbc0ff54f5306b06054700f8"},
{file = "MarkupSafe-3.0.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:9e2d922824181480953426608b81967de705c3cef4d1af983af849d7bd619158"},
{file = "MarkupSafe-3.0.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:38a9ef736c01fccdd6600705b09dc574584b89bea478200c5fbf112a6b0d5579"},
{file = "MarkupSafe-3.0.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bbcb445fa71794da8f178f0f6d66789a28d7319071af7a496d4d507ed566270d"},
{file = "MarkupSafe-3.0.2-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:57cb5a3cf367aeb1d316576250f65edec5bb3be939e9247ae594b4bcbc317dfb"},
{file = "MarkupSafe-3.0.2-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:3809ede931876f5b2ec92eef964286840ed3540dadf803dd570c3b7e13141a3b"},
{file = "MarkupSafe-3.0.2-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:e07c3764494e3776c602c1e78e298937c3315ccc9043ead7e685b7f2b8d47b3c"},
{file = "MarkupSafe-3.0.2-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:b424c77b206d63d500bcb69fa55ed8d0e6a3774056bdc4839fc9298a7edca171"},
{file = "MarkupSafe-3.0.2-cp310-cp310-win32.whl", hash = "sha256:fcabf5ff6eea076f859677f5f0b6b5c1a51e70a376b0579e0eadef8db48c6b50"},
{file = "MarkupSafe-3.0.2-cp310-cp310-win_amd64.whl", hash = "sha256:6af100e168aa82a50e186c82875a5893c5597a0c1ccdb0d8b40240b1f28b969a"},
{file = "MarkupSafe-3.0.2-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:9025b4018f3a1314059769c7bf15441064b2207cb3f065e6ea1e7359cb46db9d"},
{file = "MarkupSafe-3.0.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:93335ca3812df2f366e80509ae119189886b0f3c2b81325d39efdb84a1e2ae93"},
{file = "MarkupSafe-3.0.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2cb8438c3cbb25e220c2ab33bb226559e7afb3baec11c4f218ffa7308603c832"},
{file = "MarkupSafe-3.0.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a123e330ef0853c6e822384873bef7507557d8e4a082961e1defa947aa59ba84"},
{file = "MarkupSafe-3.0.2-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1e084f686b92e5b83186b07e8a17fc09e38fff551f3602b249881fec658d3eca"},
{file = "MarkupSafe-3.0.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:d8213e09c917a951de9d09ecee036d5c7d36cb6cb7dbaece4c71a60d79fb9798"},
{file = "MarkupSafe-3.0.2-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:5b02fb34468b6aaa40dfc198d813a641e3a63b98c2b05a16b9f80b7ec314185e"},
{file = "MarkupSafe-3.0.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:0bff5e0ae4ef2e1ae4fdf2dfd5b76c75e5c2fa4132d05fc1b0dabcd20c7e28c4"},
{file = "MarkupSafe-3.0.2-cp311-cp311-win32.whl", hash = "sha256:6c89876f41da747c8d3677a2b540fb32ef5715f97b66eeb0c6b66f5e3ef6f59d"},
{file = "MarkupSafe-3.0.2-cp311-cp311-win_amd64.whl", hash = "sha256:70a87b411535ccad5ef2f1df5136506a10775d267e197e4cf531ced10537bd6b"},
{file = "MarkupSafe-3.0.2-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:9778bd8ab0a994ebf6f84c2b949e65736d5575320a17ae8984a77fab08db94cf"},
{file = "MarkupSafe-3.0.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:846ade7b71e3536c4e56b386c2a47adf5741d2d8b94ec9dc3e92e5e1ee1e2225"},
{file = "MarkupSafe-3.0.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1c99d261bd2d5f6b59325c92c73df481e05e57f19837bdca8413b9eac4bd8028"},
{file = "MarkupSafe-3.0.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e17c96c14e19278594aa4841ec148115f9c7615a47382ecb6b82bd8fea3ab0c8"},
{file = "MarkupSafe-3.0.2-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:88416bd1e65dcea10bc7569faacb2c20ce071dd1f87539ca2ab364bf6231393c"},
{file = "MarkupSafe-3.0.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:2181e67807fc2fa785d0592dc2d6206c019b9502410671cc905d132a92866557"},
{file = "MarkupSafe-3.0.2-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:52305740fe773d09cffb16f8ed0427942901f00adedac82ec8b67752f58a1b22"},
{file = "MarkupSafe-3.0.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:ad10d3ded218f1039f11a75f8091880239651b52e9bb592ca27de44eed242a48"},
{file = "MarkupSafe-3.0.2-cp312-cp312-win32.whl", hash = "sha256:0f4ca02bea9a23221c0182836703cbf8930c5e9454bacce27e767509fa286a30"},
{file = "MarkupSafe-3.0.2-cp312-cp312-win_amd64.whl", hash = "sha256:8e06879fc22a25ca47312fbe7c8264eb0b662f6db27cb2d3bbbc74b1df4b9b87"},
{file = "MarkupSafe-3.0.2-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:ba9527cdd4c926ed0760bc301f6728ef34d841f405abf9d4f959c478421e4efd"},
{file = "MarkupSafe-3.0.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f8b3d067f2e40fe93e1ccdd6b2e1d16c43140e76f02fb1319a05cf2b79d99430"},
{file = "MarkupSafe-3.0.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:569511d3b58c8791ab4c2e1285575265991e6d8f8700c7be0e88f86cb0672094"},
{file = "MarkupSafe-3.0.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:15ab75ef81add55874e7ab7055e9c397312385bd9ced94920f2802310c930396"},
{file = "MarkupSafe-3.0.2-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f3818cb119498c0678015754eba762e0d61e5b52d34c8b13d770f0719f7b1d79"},
{file = "MarkupSafe-3.0.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:cdb82a876c47801bb54a690c5ae105a46b392ac6099881cdfb9f6e95e4014c6a"},
{file = "MarkupSafe-3.0.2-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:cabc348d87e913db6ab4aa100f01b08f481097838bdddf7c7a84b7575b7309ca"},
{file = "MarkupSafe-3.0.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:444dcda765c8a838eaae23112db52f1efaf750daddb2d9ca300bcae1039adc5c"},
{file = "MarkupSafe-3.0.2-cp313-cp313-win32.whl", hash = "sha256:bcf3e58998965654fdaff38e58584d8937aa3096ab5354d493c77d1fdd66d7a1"},
{file = "MarkupSafe-3.0.2-cp313-cp313-win_amd64.whl", hash = "sha256:e6a2a455bd412959b57a172ce6328d2dd1f01cb2135efda2e4576e8a23fa3b0f"},
{file = "MarkupSafe-3.0.2-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:b5a6b3ada725cea8a5e634536b1b01c30bcdcd7f9c6fff4151548d5bf6b3a36c"},
{file = "MarkupSafe-3.0.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:a904af0a6162c73e3edcb969eeeb53a63ceeb5d8cf642fade7d39e7963a22ddb"},
{file = "MarkupSafe-3.0.2-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4aa4e5faecf353ed117801a068ebab7b7e09ffb6e1d5e412dc852e0da018126c"},
{file = "MarkupSafe-3.0.2-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c0ef13eaeee5b615fb07c9a7dadb38eac06a0608b41570d8ade51c56539e509d"},
{file = "MarkupSafe-3.0.2-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d16a81a06776313e817c951135cf7340a3e91e8c1ff2fac444cfd75fffa04afe"},
{file = "MarkupSafe-3.0.2-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:6381026f158fdb7c72a168278597a5e3a5222e83ea18f543112b2662a9b699c5"},
{file = "MarkupSafe-3.0.2-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:3d79d162e7be8f996986c064d1c7c817f6df3a77fe3d6859f6f9e7be4b8c213a"},
{file = "MarkupSafe-3.0.2-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:131a3c7689c85f5ad20f9f6fb1b866f402c445b220c19fe4308c0b147ccd2ad9"},
{file = "MarkupSafe-3.0.2-cp313-cp313t-win32.whl", hash = "sha256:ba8062ed2cf21c07a9e295d5b8a2a5ce678b913b45fdf68c32d95d6c1291e0b6"},
{file = "MarkupSafe-3.0.2-cp313-cp313t-win_amd64.whl", hash = "sha256:e444a31f8db13eb18ada366ab3cf45fd4b31e4db1236a4448f68778c1d1a5a2f"},
{file = "MarkupSafe-3.0.2-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:eaa0a10b7f72326f1372a713e73c3f739b524b3af41feb43e4921cb529f5929a"},
{file = "MarkupSafe-3.0.2-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:48032821bbdf20f5799ff537c7ac3d1fba0ba032cfc06194faffa8cda8b560ff"},
{file = "MarkupSafe-3.0.2-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1a9d3f5f0901fdec14d8d2f66ef7d035f2157240a433441719ac9a3fba440b13"},
{file = "MarkupSafe-3.0.2-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:88b49a3b9ff31e19998750c38e030fc7bb937398b1f78cfa599aaef92d693144"},
{file = "MarkupSafe-3.0.2-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:cfad01eed2c2e0c01fd0ecd2ef42c492f7f93902e39a42fc9ee1692961443a29"},
{file = "MarkupSafe-3.0.2-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:1225beacc926f536dc82e45f8a4d68502949dc67eea90eab715dea3a21c1b5f0"},
{file = "MarkupSafe-3.0.2-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:3169b1eefae027567d1ce6ee7cae382c57fe26e82775f460f0b2778beaad66c0"},
{file = "MarkupSafe-3.0.2-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:eb7972a85c54febfb25b5c4b4f3af4dcc731994c7da0d8a0b4a6eb0640e1d178"},
{file = "MarkupSafe-3.0.2-cp39-cp39-win32.whl", hash = "sha256:8c4e8c3ce11e1f92f6536ff07154f9d49677ebaaafc32db9db4620bc11ed480f"},
{file = "MarkupSafe-3.0.2-cp39-cp39-win_amd64.whl", hash = "sha256:6e296a513ca3d94054c2c881cc913116e90fd030ad1c656b3869762b754f5f8a"},
{file = "markupsafe-3.0.2.tar.gz", hash = "sha256:ee55d3edf80167e48ea11a923c7386f4669df67d7994554387f84e7d8b0a2bf0"},
]
[[package]]
name = "mdit-py-plugins"
version = "0.4.2"
description = "Collection of plugins for markdown-it-py"
optional = false
python-versions = ">=3.8"
groups = ["docs"]
files = [
{file = "mdit_py_plugins-0.4.2-py3-none-any.whl", hash = "sha256:0c673c3f889399a33b95e88d2f0d111b4447bdfea7f237dab2d488f459835636"},
{file = "mdit_py_plugins-0.4.2.tar.gz", hash = "sha256:5f2cd1fdb606ddf152d37ec30e46101a60512bc0e5fa1a7002c36647b09e26b5"},
]
[package.dependencies]
markdown-it-py = ">=1.0.0,<4.0.0"
[package.extras]
code-style = ["pre-commit"]
rtd = ["myst-parser", "sphinx-book-theme"]
testing = ["coverage", "pytest", "pytest-cov", "pytest-regressions"]
[[package]]
name = "mdurl"
version = "0.1.2"
description = "Markdown URL utilities"
optional = false
python-versions = ">=3.7"
groups = ["dev", "docs"]
files = [
{file = "mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8"},
{file = "mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba"},
]
[[package]]
name = "myst-parser"
version = "4.0.1"
description = "An extended [CommonMark](https://spec.commonmark.org/) compliant parser,"
optional = false
python-versions = ">=3.10"
groups = ["docs"]
files = [
{file = "myst_parser-4.0.1-py3-none-any.whl", hash = "sha256:9134e88959ec3b5780aedf8a99680ea242869d012e8821db3126d427edc9c95d"},
{file = "myst_parser-4.0.1.tar.gz", hash = "sha256:5cfea715e4f3574138aecbf7d54132296bfd72bb614d31168f48c477a830a7c4"},
]
[package.dependencies]
docutils = ">=0.19,<0.22"
jinja2 = "*"
markdown-it-py = ">=3.0,<4.0"
mdit-py-plugins = ">=0.4.1,<1.0"
pyyaml = "*"
sphinx = ">=7,<9"
[package.extras]
code-style = ["pre-commit (>=4.0,<5.0)"]
linkify = ["linkify-it-py (>=2.0,<3.0)"]
rtd = ["ipython", "sphinx (>=7)", "sphinx-autodoc2 (>=0.5.0,<0.6.0)", "sphinx-book-theme (>=1.1,<2.0)", "sphinx-copybutton", "sphinx-design", "sphinx-pyscript", "sphinx-tippy (>=0.4.3)", "sphinx-togglebutton", "sphinxext-opengraph (>=0.9.0,<0.10.0)", "sphinxext-rediraffe (>=0.2.7,<0.3.0)"]
testing = ["beautifulsoup4", "coverage[toml]", "defusedxml", "pygments (<2.19)", "pytest (>=8,<9)", "pytest-cov", "pytest-param-files (>=0.6.0,<0.7.0)", "pytest-regressions", "sphinx-pytest"]
testing-docutils = ["pygments", "pytest (>=8,<9)", "pytest-param-files (>=0.6.0,<0.7.0)"]
[[package]]
name = "packaging"
version = "24.2"
description = "Core utilities for Python packages"
optional = false
python-versions = ">=3.8"
groups = ["dev", "docs"]
files = [
{file = "packaging-24.2-py3-none-any.whl", hash = "sha256:09abb1bccd265c01f4a3aa3f7a7db064b36514d2cba19a2f694fe6150451a759"},
{file = "packaging-24.2.tar.gz", hash = "sha256:c228a6dc5e932d346bc5739379109d49e8853dd8223571c7c5b55260edc0b97f"},
]
[[package]]
name = "pluggy"
version = "1.5.0"
description = "plugin and hook calling mechanisms for python"
optional = false
python-versions = ">=3.8"
groups = ["dev"]
files = [
{file = "pluggy-1.5.0-py3-none-any.whl", hash = "sha256:44e1ad92c8ca002de6377e165f3e0f1be63266ab4d554740532335b9d75ea669"},
{file = "pluggy-1.5.0.tar.gz", hash = "sha256:2cffa88e94fdc978c4c574f15f9e59b7f4201d439195c3715ca9e2486f1d0cf1"},
]
[package.extras]
dev = ["pre-commit", "tox"]
testing = ["pytest", "pytest-benchmark"]
[[package]]
name = "pycparser"
version = "2.22"
description = "C parser in Python"
optional = false
python-versions = ">=3.8"
groups = ["main", "dev"]
files = [
{file = "pycparser-2.22-py3-none-any.whl", hash = "sha256:c3702b6d3dd8c7abc1afa565d7e63d53a1d0bd86cdc24edd75470f4de499cfcc"},
{file = "pycparser-2.22.tar.gz", hash = "sha256:491c8be9c040f5390f5bf44a5b07752bd07f56edf992381b05c701439eec10f6"},
]
markers = {main = "platform_python_implementation != \"PyPy\""}
[[package]]
name = "pygments"
version = "2.19.1"
description = "Pygments is a syntax highlighting package written in Python."
optional = false
python-versions = ">=3.8"
groups = ["dev", "docs"]
files = [
{file = "pygments-2.19.1-py3-none-any.whl", hash = "sha256:9ea1544ad55cecf4b8242fab6dd35a93bbce657034b0611ee383099054ab6d8c"},
{file = "pygments-2.19.1.tar.gz", hash = "sha256:61c16d2a8576dc0649d9f39e089b5f02bcd27fba10d8fb4dcc28173f7a45151f"},
]
[package.extras]
windows-terminal = ["colorama (>=0.4.6)"]
[[package]]
name = "pyobjc-core"
version = "10.3.2"
description = "Python<->ObjC Interoperability Module"
optional = false
python-versions = ">=3.8"
groups = ["main"]
markers = "platform_system == \"Darwin\""
files = [
{file = "pyobjc_core-10.3.2-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:acb40672d682851a5c7fd84e5041c4d069b62076168d72591abb5fcc871bb039"},
{file = "pyobjc_core-10.3.2-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:cea5e77659619ad93c782ca07644b6efe7d7ec6f59e46128843a0a87c1af511a"},
{file = "pyobjc_core-10.3.2-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:16644a92fb9661de841ba6115e5354db06a1d193a5e239046e840013c7b3874d"},
{file = "pyobjc_core-10.3.2-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:76b8b911d94501dac89821df349b1860bb770dce102a1a293f524b5b09dd9462"},
{file = "pyobjc_core-10.3.2-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:8c6288fdb210b64115760a4504efbc4daffdc390d309e9318eb0e3e3b78d2828"},
{file = "pyobjc_core-10.3.2-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:87901e9f7032f33eb4fa884e407bf2744d5a0791b379bfca783982a02be3f7fb"},
{file = "pyobjc_core-10.3.2-cp38-cp38-macosx_11_0_universal2.whl", hash = "sha256:636971ab48a4198ca129e149fe58ccf85a7b4a9b93d27f5ae920d88eb2655431"},
{file = "pyobjc_core-10.3.2-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:48e9ac3af42b2340dae709a8b894f5ef7e5132d8546adcd1797cffcc449dabdc"},
{file = "pyobjc_core-10.3.2.tar.gz", hash = "sha256:dbf1475d864ce594288ce03e94e3a98dc7f0e4639971eb1e312bdf6661c21e0e"},
]
[[package]]
name = "pyobjc-framework-cocoa"
version = "10.3.2"
description = "Wrappers for the Cocoa frameworks on macOS"
optional = false
python-versions = ">=3.8"
groups = ["main"]
markers = "platform_system == \"Darwin\""
files = [
{file = "pyobjc_framework_Cocoa-10.3.2-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:61f44c2adab28fdf3aa3d593c9497a2d9ceb9583ed9814adb48828c385d83ff4"},
{file = "pyobjc_framework_Cocoa-10.3.2-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:7caaf8b260e81b27b7b787332846f644b9423bfc1536f6ec24edbde59ab77a87"},
{file = "pyobjc_framework_Cocoa-10.3.2-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:c49e99fc4b9e613fb308651b99d52a8a9ae9916c8ef27aa2f5d585b6678a59bf"},
{file = "pyobjc_framework_Cocoa-10.3.2-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:f1161b5713f9b9934c12649d73a6749617172e240f9431eff9e22175262fdfda"},
{file = "pyobjc_framework_Cocoa-10.3.2-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:08e48b9ee4eb393447b2b781d16663b954bd10a26927df74f92e924c05568d89"},
{file = "pyobjc_framework_Cocoa-10.3.2-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:7faa448d2038ae0e0287a326d390002e744bb6470e45995e2dbd16c892e4495a"},
{file = "pyobjc_framework_Cocoa-10.3.2-cp38-cp38-macosx_11_0_universal2.whl", hash = "sha256:fcd53fee2be9708576617994b107aedc2c40824b648cd51e780e8399c0a447b6"},
{file = "pyobjc_framework_Cocoa-10.3.2-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:838fcf0d10674bde9ff64a3f20c0e188f2dc5e804476d80509b81c4ac1dabc59"},
{file = "pyobjc_framework_cocoa-10.3.2.tar.gz", hash = "sha256:673968e5435845bef969bfe374f31a1a6dc660c98608d2b84d5cae6eafa5c39d"},
]
[package.dependencies]
pyobjc-core = ">=10.3.2"
[[package]]
name = "pyobjc-framework-corebluetooth"
version = "10.3.2"
description = "Wrappers for the framework CoreBluetooth on macOS"
optional = false
python-versions = ">=3.8"
groups = ["main"]
markers = "platform_system == \"Darwin\""
files = [
{file = "pyobjc_framework_CoreBluetooth-10.3.2-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:af3e2f935a6a7e5b009b4cf63c64899592a7b46c3ddcbc8f2e28848842ef65f4"},
{file = "pyobjc_framework_CoreBluetooth-10.3.2-cp36-abi3-macosx_10_13_universal2.whl", hash = "sha256:973b78f47c7e2209a475e60bcc7d1b4a87be6645d39b4e8290ee82640e1cc364"},
{file = "pyobjc_framework_CoreBluetooth-10.3.2-cp36-abi3-macosx_10_9_universal2.whl", hash = "sha256:4bafdf1be15eae48a4878dbbf1bf19877ce28cbbba5baa0267a9564719ee736e"},
{file = "pyobjc_framework_CoreBluetooth-10.3.2-cp36-abi3-macosx_10_9_x86_64.whl", hash = "sha256:4d7dc7494de66c850bda7b173579df7481dc97046fa229d480fe9bf90b2b9651"},
{file = "pyobjc_framework_CoreBluetooth-10.3.2-cp36-abi3-macosx_11_0_universal2.whl", hash = "sha256:62e09e730f4d98384f1b6d44718812195602b3c82d5c78e09f60e8a934e7b266"},
{file = "pyobjc_framework_corebluetooth-10.3.2.tar.gz", hash = "sha256:c0a077bc3a2466271efa382c1e024630bc43cc6f9ab8f3f97431ad08b1ad52bb"},
]
[package.dependencies]
pyobjc-core = ">=10.3.2"
pyobjc-framework-Cocoa = ">=10.3.2"
[[package]]
name = "pyobjc-framework-libdispatch"
version = "10.3.2"
description = "Wrappers for libdispatch on macOS"
optional = false
python-versions = ">=3.8"
groups = ["main"]
markers = "platform_system == \"Darwin\""
files = [
{file = "pyobjc_framework_libdispatch-10.3.2-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:35233a8b1135567c7696087f924e398799467c7f129200b559e8e4fa777af860"},
{file = "pyobjc_framework_libdispatch-10.3.2-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:061f6aa0f88d11d993e6546ec734303cb8979f40ae0f5cd23541236a6b426abd"},
{file = "pyobjc_framework_libdispatch-10.3.2-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:6bb528f34538f35e1b79d839dbfc398dd426990e190d9301fe2d811fddc3da62"},
{file = "pyobjc_framework_libdispatch-10.3.2-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:1357729d5fded08fbf746834ebeef27bee07d6acb991f3b8366e8f4319d882c4"},
{file = "pyobjc_framework_libdispatch-10.3.2-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:210398f9e1815ceeff49b578bf51c2d6a4a30d4c33f573da322f3d7da1add121"},
{file = "pyobjc_framework_libdispatch-10.3.2-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:e7ae5988ac0b369ad40ce5497af71864fac45c289fa52671009b427f03d6871f"},
{file = "pyobjc_framework_libdispatch-10.3.2-cp38-cp38-macosx_11_0_universal2.whl", hash = "sha256:f9d51d52dff453a4b19c096171a6cd31dd5e665371c00c1d72d480e1c22cd3d4"},
{file = "pyobjc_framework_libdispatch-10.3.2-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:ef755bcabff2ea8db45603a8294818e0eeae85bf0b7b9d59e42f5947a26e33b9"},
{file = "pyobjc_framework_libdispatch-10.3.2.tar.gz", hash = "sha256:e9f4311fbf8df602852557a98d2a64f37a9d363acf4d75634120251bbc7b7304"},
]
[package.dependencies]
pyobjc-core = ">=10.3.2"
pyobjc-framework-Cocoa = ">=10.3.2"
[[package]]
name = "pyric"
version = "0.1.6.3"
description = "Python Wireless Library"
optional = false
python-versions = "*"
groups = ["main"]
files = [
{file = "PyRIC-0.1.6.3.tar.gz", hash = "sha256:b539b01cafebd2406c00097f94525ea0f8ecd1dd92f7731f43eac0ef16c2ccc9"},
]
[[package]]
name = "pytest"
version = "8.3.5"
description = "pytest: simple powerful testing with Python"
optional = false
python-versions = ">=3.8"
groups = ["dev"]
files = [
{file = "pytest-8.3.5-py3-none-any.whl", hash = "sha256:c69214aa47deac29fad6c2a4f590b9c4a9fdb16a403176fe154b79c0b4d4d820"},
{file = "pytest-8.3.5.tar.gz", hash = "sha256:f4efe70cc14e511565ac476b57c279e12a855b11f48f212af1080ef2263d3845"},
]
[package.dependencies]
colorama = {version = "*", markers = "sys_platform == \"win32\""}
iniconfig = "*"
packaging = "*"
pluggy = ">=1.5,<2"
[package.extras]
dev = ["argcomplete", "attrs (>=19.2)", "hypothesis (>=3.56)", "mock", "pygments (>=2.7.2)", "requests", "setuptools", "xmlschema"]
[[package]]
name = "pytest-asyncio"
version = "0.26.0"
description = "Pytest support for asyncio"
optional = false
python-versions = ">=3.9"
groups = ["dev"]
files = [
{file = "pytest_asyncio-0.26.0-py3-none-any.whl", hash = "sha256:7b51ed894f4fbea1340262bdae5135797ebbe21d8638978e35d31c6d19f72fb0"},
{file = "pytest_asyncio-0.26.0.tar.gz", hash = "sha256:c4df2a697648241ff39e7f0e4a73050b03f123f760673956cf0d72a4990e312f"},
]
[package.dependencies]
pytest = ">=8.2,<9"
[package.extras]
docs = ["sphinx (>=5.3)", "sphinx-rtd-theme (>=1)"]
testing = ["coverage (>=6.2)", "hypothesis (>=5.7.1)"]
[[package]]
name = "pytest-codspeed"
version = "3.2.0"
description = "Pytest plugin to create CodSpeed benchmarks"
optional = false
python-versions = ">=3.9"
groups = ["dev"]
files = [
{file = "pytest_codspeed-3.2.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c5165774424c7ab8db7e7acdb539763a0e5657996effefdf0664d7fd95158d34"},
{file = "pytest_codspeed-3.2.0-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9bd55f92d772592c04a55209950c50880413ae46876e66bd349ef157075ca26c"},
{file = "pytest_codspeed-3.2.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4cf6f56067538f4892baa8d7ab5ef4e45bb59033be1ef18759a2c7fc55b32035"},
{file = "pytest_codspeed-3.2.0-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:39a687b05c3d145642061b45ea78e47e12f13ce510104d1a2cda00eee0e36f58"},
{file = "pytest_codspeed-3.2.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:46a1afaaa1ac4c2ca5b0700d31ac46d80a27612961d031067d73c6ccbd8d3c2b"},
{file = "pytest_codspeed-3.2.0-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c48ce3af3dfa78413ed3d69d1924043aa1519048dbff46edccf8f35a25dab3c2"},
{file = "pytest_codspeed-3.2.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:66692506d33453df48b36a84703448cb8b22953eea51f03fbb2eb758dc2bdc4f"},
{file = "pytest_codspeed-3.2.0-cp313-cp313-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:479774f80d0bdfafa16112700df4dbd31bf2a6757fac74795fd79c0a7b3c389b"},
{file = "pytest_codspeed-3.2.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:109f9f4dd1088019c3b3f887d003b7d65f98a7736ca1d457884f5aa293e8e81c"},
{file = "pytest_codspeed-3.2.0-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e2f69a03b52c9bb041aec1b8ee54b7b6c37a6d0a948786effa4c71157765b6da"},
{file = "pytest_codspeed-3.2.0-py3-none-any.whl", hash = "sha256:54b5c2e986d6a28e7b0af11d610ea57bd5531cec8326abe486f1b55b09d91c39"},
{file = "pytest_codspeed-3.2.0.tar.gz", hash = "sha256:f9d1b1a3b2c69cdc0490a1e8b1ced44bffbd0e8e21d81a7160cfdd923f6e8155"},
]
[package.dependencies]
cffi = ">=1.17.1"
pytest = ">=3.8"
rich = ">=13.8.1"
[package.extras]
compat = ["pytest-benchmark (>=5.0.0,<5.1.0)", "pytest-xdist (>=3.6.1,<3.7.0)"]
lint = ["mypy (>=1.11.2,<1.12.0)", "ruff (>=0.6.5,<0.7.0)"]
test = ["pytest (>=7.0,<8.0)", "pytest-cov (>=4.0.0,<4.1.0)"]
[[package]]
name = "pytest-cov"
version = "6.1.1"
description = "Pytest plugin for measuring coverage."
optional = false
python-versions = ">=3.9"
groups = ["dev"]
files = [
{file = "pytest_cov-6.1.1-py3-none-any.whl", hash = "sha256:bddf29ed2d0ab6f4df17b4c55b0a657287db8684af9c42ea546b21b1041b3dde"},
{file = "pytest_cov-6.1.1.tar.gz", hash = "sha256:46935f7aaefba760e716c2ebfbe1c216240b9592966e7da99ea8292d4d3e2a0a"},
]
[package.dependencies]
coverage = {version = ">=7.5", extras = ["toml"]}
pytest = ">=4.6"
[package.extras]
testing = ["fields", "hunter", "process-tests", "pytest-xdist", "virtualenv"]
[[package]]
name = "python-dateutil"
version = "2.9.0.post0"
description = "Extensions to the standard Python datetime module"
optional = false
python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,>=2.7"
groups = ["dev"]
files = [
{file = "python-dateutil-2.9.0.post0.tar.gz", hash = "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3"},
{file = "python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427"},
]
[package.dependencies]
six = ">=1.5"
[[package]]
name = "pyyaml"
version = "6.0.2"
description = "YAML parser and emitter for Python"
optional = false
python-versions = ">=3.8"
groups = ["docs"]
files = [
{file = "PyYAML-6.0.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:0a9a2848a5b7feac301353437eb7d5957887edbf81d56e903999a75a3d743086"},
{file = "PyYAML-6.0.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:29717114e51c84ddfba879543fb232a6ed60086602313ca38cce623c1d62cfbf"},
{file = "PyYAML-6.0.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8824b5a04a04a047e72eea5cec3bc266db09e35de6bdfe34c9436ac5ee27d237"},
{file = "PyYAML-6.0.2-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:7c36280e6fb8385e520936c3cb3b8042851904eba0e58d277dca80a5cfed590b"},
{file = "PyYAML-6.0.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ec031d5d2feb36d1d1a24380e4db6d43695f3748343d99434e6f5f9156aaa2ed"},
{file = "PyYAML-6.0.2-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:936d68689298c36b53b29f23c6dbb74de12b4ac12ca6cfe0e047bedceea56180"},
{file = "PyYAML-6.0.2-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:23502f431948090f597378482b4812b0caae32c22213aecf3b55325e049a6c68"},
{file = "PyYAML-6.0.2-cp310-cp310-win32.whl", hash = "sha256:2e99c6826ffa974fe6e27cdb5ed0021786b03fc98e5ee3c5bfe1fd5015f42b99"},
{file = "PyYAML-6.0.2-cp310-cp310-win_amd64.whl", hash = "sha256:a4d3091415f010369ae4ed1fc6b79def9416358877534caf6a0fdd2146c87a3e"},
{file = "PyYAML-6.0.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:cc1c1159b3d456576af7a3e4d1ba7e6924cb39de8f67111c735f6fc832082774"},
{file = "PyYAML-6.0.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:1e2120ef853f59c7419231f3bf4e7021f1b936f6ebd222406c3b60212205d2ee"},
{file = "PyYAML-6.0.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5d225db5a45f21e78dd9358e58a98702a0302f2659a3c6cd320564b75b86f47c"},
{file = "PyYAML-6.0.2-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:5ac9328ec4831237bec75defaf839f7d4564be1e6b25ac710bd1a96321cc8317"},
{file = "PyYAML-6.0.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3ad2a3decf9aaba3d29c8f537ac4b243e36bef957511b4766cb0057d32b0be85"},
{file = "PyYAML-6.0.2-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:ff3824dc5261f50c9b0dfb3be22b4567a6f938ccce4587b38952d85fd9e9afe4"},
{file = "PyYAML-6.0.2-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:797b4f722ffa07cc8d62053e4cff1486fa6dc094105d13fea7b1de7d8bf71c9e"},
{file = "PyYAML-6.0.2-cp311-cp311-win32.whl", hash = "sha256:11d8f3dd2b9c1207dcaf2ee0bbbfd5991f571186ec9cc78427ba5bd32afae4b5"},
{file = "PyYAML-6.0.2-cp311-cp311-win_amd64.whl", hash = "sha256:e10ce637b18caea04431ce14fabcf5c64a1c61ec9c56b071a4b7ca131ca52d44"},
{file = "PyYAML-6.0.2-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:c70c95198c015b85feafc136515252a261a84561b7b1d51e3384e0655ddf25ab"},
{file = "PyYAML-6.0.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ce826d6ef20b1bc864f0a68340c8b3287705cae2f8b4b1d932177dcc76721725"},
{file = "PyYAML-6.0.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1f71ea527786de97d1a0cc0eacd1defc0985dcf6b3f17bb77dcfc8c34bec4dc5"},
{file = "PyYAML-6.0.2-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9b22676e8097e9e22e36d6b7bda33190d0d400f345f23d4065d48f4ca7ae0425"},
{file = "PyYAML-6.0.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:80bab7bfc629882493af4aa31a4cfa43a4c57c83813253626916b8c7ada83476"},
{file = "PyYAML-6.0.2-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:0833f8694549e586547b576dcfaba4a6b55b9e96098b36cdc7ebefe667dfed48"},
{file = "PyYAML-6.0.2-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:8b9c7197f7cb2738065c481a0461e50ad02f18c78cd75775628afb4d7137fb3b"},
{file = "PyYAML-6.0.2-cp312-cp312-win32.whl", hash = "sha256:ef6107725bd54b262d6dedcc2af448a266975032bc85ef0172c5f059da6325b4"},
{file = "PyYAML-6.0.2-cp312-cp312-win_amd64.whl", hash = "sha256:7e7401d0de89a9a855c839bc697c079a4af81cf878373abd7dc625847d25cbd8"},
{file = "PyYAML-6.0.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:efdca5630322a10774e8e98e1af481aad470dd62c3170801852d752aa7a783ba"},
{file = "PyYAML-6.0.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:50187695423ffe49e2deacb8cd10510bc361faac997de9efef88badc3bb9e2d1"},
{file = "PyYAML-6.0.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0ffe8360bab4910ef1b9e87fb812d8bc0a308b0d0eef8c8f44e0254ab3b07133"},
{file = "PyYAML-6.0.2-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:17e311b6c678207928d649faa7cb0d7b4c26a0ba73d41e99c4fff6b6c3276484"},
{file = "PyYAML-6.0.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:70b189594dbe54f75ab3a1acec5f1e3faa7e8cf2f1e08d9b561cb41b845f69d5"},
{file = "PyYAML-6.0.2-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:41e4e3953a79407c794916fa277a82531dd93aad34e29c2a514c2c0c5fe971cc"},
{file = "PyYAML-6.0.2-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:68ccc6023a3400877818152ad9a1033e3db8625d899c72eacb5a668902e4d652"},
{file = "PyYAML-6.0.2-cp313-cp313-win32.whl", hash = "sha256:bc2fa7c6b47d6bc618dd7fb02ef6fdedb1090ec036abab80d4681424b84c1183"},
{file = "PyYAML-6.0.2-cp313-cp313-win_amd64.whl", hash = "sha256:8388ee1976c416731879ac16da0aff3f63b286ffdd57cdeb95f3f2e085687563"},
{file = "PyYAML-6.0.2-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:24471b829b3bf607e04e88d79542a9d48bb037c2267d7927a874e6c205ca7e9a"},
{file = "PyYAML-6.0.2-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d7fded462629cfa4b685c5416b949ebad6cec74af5e2d42905d41e257e0869f5"},
{file = "PyYAML-6.0.2-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d84a1718ee396f54f3a086ea0a66d8e552b2ab2017ef8b420e92edbc841c352d"},
{file = "PyYAML-6.0.2-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9056c1ecd25795207ad294bcf39f2db3d845767be0ea6e6a34d856f006006083"},
{file = "PyYAML-6.0.2-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:82d09873e40955485746739bcb8b4586983670466c23382c19cffecbf1fd8706"},
{file = "PyYAML-6.0.2-cp38-cp38-win32.whl", hash = "sha256:43fa96a3ca0d6b1812e01ced1044a003533c47f6ee8aca31724f78e93ccc089a"},
{file = "PyYAML-6.0.2-cp38-cp38-win_amd64.whl", hash = "sha256:01179a4a8559ab5de078078f37e5c1a30d76bb88519906844fd7bdea1b7729ff"},
{file = "PyYAML-6.0.2-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:688ba32a1cffef67fd2e9398a2efebaea461578b0923624778664cc1c914db5d"},
{file = "PyYAML-6.0.2-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:a8786accb172bd8afb8be14490a16625cbc387036876ab6ba70912730faf8e1f"},
{file = "PyYAML-6.0.2-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d8e03406cac8513435335dbab54c0d385e4a49e4945d2909a581c83647ca0290"},
{file = "PyYAML-6.0.2-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f753120cb8181e736c57ef7636e83f31b9c0d1722c516f7e86cf15b7aa57ff12"},
{file = "PyYAML-6.0.2-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3b1fdb9dc17f5a7677423d508ab4f243a726dea51fa5e70992e59a7411c89d19"},
{file = "PyYAML-6.0.2-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:0b69e4ce7a131fe56b7e4d770c67429700908fc0752af059838b1cfb41960e4e"},
{file = "PyYAML-6.0.2-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:a9f8c2e67970f13b16084e04f134610fd1d374bf477b17ec1599185cf611d725"},
{file = "PyYAML-6.0.2-cp39-cp39-win32.whl", hash = "sha256:6395c297d42274772abc367baaa79683958044e5d3835486c16da75d2a694631"},
{file = "PyYAML-6.0.2-cp39-cp39-win_amd64.whl", hash = "sha256:39693e1f8320ae4f43943590b49779ffb98acb81f788220ea932a6b6c51004d8"},
{file = "pyyaml-6.0.2.tar.gz", hash = "sha256:d584d9ec91ad65861cc08d42e834324ef890a082e591037abe114850ff7bbc3e"},
]
[[package]]
name = "requests"
version = "2.32.3"
description = "Python HTTP for Humans."
optional = false
python-versions = ">=3.8"
groups = ["docs"]
files = [
{file = "requests-2.32.3-py3-none-any.whl", hash = "sha256:70761cfe03c773ceb22aa2f671b4757976145175cdfca038c02654d061d6dcc6"},
{file = "requests-2.32.3.tar.gz", hash = "sha256:55365417734eb18255590a9ff9eb97e9e1da868d4ccd6402399eaf68af20a760"},
]
[package.dependencies]
certifi = ">=2017.4.17"
charset-normalizer = ">=2,<4"
idna = ">=2.5,<4"
urllib3 = ">=1.21.1,<3"
[package.extras]
socks = ["PySocks (>=1.5.6,!=1.5.7)"]
use-chardet-on-py3 = ["chardet (>=3.0.2,<6)"]
[[package]]
name = "rich"
version = "14.0.0"
description = "Render rich text, tables, progress bars, syntax highlighting, markdown and more to the terminal"
optional = false
python-versions = ">=3.8.0"
groups = ["dev"]
files = [
{file = "rich-14.0.0-py3-none-any.whl", hash = "sha256:1c9491e1951aac09caffd42f448ee3d04e58923ffe14993f6e83068dc395d7e0"},
{file = "rich-14.0.0.tar.gz", hash = "sha256:82f1bc23a6a21ebca4ae0c45af9bdbc492ed20231dcb63f297d6d1021a9d5725"},
]
[package.dependencies]
markdown-it-py = ">=2.2.0"
pygments = ">=2.13.0,<3.0.0"
[package.extras]
jupyter = ["ipywidgets (>=7.5.1,<9)"]
[[package]]
name = "roman-numerals-py"
version = "3.1.0"
description = "Manipulate well-formed Roman numerals"
optional = false
python-versions = ">=3.9"
groups = ["docs"]
files = [
{file = "roman_numerals_py-3.1.0-py3-none-any.whl", hash = "sha256:9da2ad2fb670bcf24e81070ceb3be72f6c11c440d73bd579fbeca1e9f330954c"},
{file = "roman_numerals_py-3.1.0.tar.gz", hash = "sha256:be4bf804f083a4ce001b5eb7e3c0862479d10f94c936f6c4e5f250aa5ff5bd2d"},
]
[package.extras]
lint = ["mypy (==1.15.0)", "pyright (==1.1.394)", "ruff (==0.9.7)"]
test = ["pytest (>=8)"]
[[package]]
name = "six"
version = "1.17.0"
description = "Python 2 and 3 compatibility utilities"
optional = false
python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,>=2.7"
groups = ["dev"]
files = [
{file = "six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274"},
{file = "six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81"},
]
[[package]]
name = "sniffio"
version = "1.3.1"
description = "Sniff out which async library your code is running under"
optional = false
python-versions = ">=3.7"
groups = ["docs"]
files = [
{file = "sniffio-1.3.1-py3-none-any.whl", hash = "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2"},
{file = "sniffio-1.3.1.tar.gz", hash = "sha256:f4324edc670a0f49750a81b895f35c3adb843cca46f0530f79fc1babb23789dc"},
]
[[package]]
name = "snowballstemmer"
version = "2.2.0"
description = "This package provides 29 stemmers for 28 languages generated from Snowball algorithms."
optional = false
python-versions = "*"
groups = ["docs"]
files = [
{file = "snowballstemmer-2.2.0-py2.py3-none-any.whl", hash = "sha256:c8e1716e83cc398ae16824e5572ae04e0d9fc2c6b985fb0f900f5f0c96ecba1a"},
{file = "snowballstemmer-2.2.0.tar.gz", hash = "sha256:09b16deb8547d3412ad7b590689584cd0fe25ec8db3be37788be3810cbf19cb1"},
]
[[package]]
name = "soupsieve"
version = "2.6"
description = "A modern CSS selector implementation for Beautiful Soup."
optional = false
python-versions = ">=3.8"
groups = ["docs"]
files = [
{file = "soupsieve-2.6-py3-none-any.whl", hash = "sha256:e72c4ff06e4fb6e4b5a9f0f55fe6e81514581fca1515028625d0f299c602ccc9"},
{file = "soupsieve-2.6.tar.gz", hash = "sha256:e2e68417777af359ec65daac1057404a3c8a5455bb8abc36f1a9866ab1a51abb"},
]
[[package]]
name = "sphinx"
version = "8.2.3"
description = "Python documentation generator"
optional = false
python-versions = ">=3.11"
groups = ["docs"]
files = [
{file = "sphinx-8.2.3-py3-none-any.whl", hash = "sha256:4405915165f13521d875a8c29c8970800a0141c14cc5416a38feca4ea5d9b9c3"},
{file = "sphinx-8.2.3.tar.gz", hash = "sha256:398ad29dee7f63a75888314e9424d40f52ce5a6a87ae88e7071e80af296ec348"},
]
[package.dependencies]
alabaster = ">=0.7.14"
babel = ">=2.13"
colorama = {version = ">=0.4.6", markers = "sys_platform == \"win32\""}
docutils = ">=0.20,<0.22"
imagesize = ">=1.3"
Jinja2 = ">=3.1"
packaging = ">=23.0"
Pygments = ">=2.17"
requests = ">=2.30.0"
roman-numerals-py = ">=1.0.0"
snowballstemmer = ">=2.2"
sphinxcontrib-applehelp = ">=1.0.7"
sphinxcontrib-devhelp = ">=1.0.6"
sphinxcontrib-htmlhelp = ">=2.0.6"
sphinxcontrib-jsmath = ">=1.0.1"
sphinxcontrib-qthelp = ">=1.0.6"
sphinxcontrib-serializinghtml = ">=1.1.9"
[package.extras]
docs = ["sphinxcontrib-websupport"]
lint = ["betterproto (==2.0.0b6)", "mypy (==1.15.0)", "pypi-attestations (==0.0.21)", "pyright (==1.1.395)", "pytest (>=8.0)", "ruff (==0.9.9)", "sphinx-lint (>=0.9)", "types-Pillow (==10.2.0.20240822)", "types-Pygments (==2.19.0.20250219)", "types-colorama (==0.4.15.20240311)", "types-defusedxml (==0.7.0.20240218)", "types-docutils (==0.21.0.20241128)", "types-requests (==2.32.0.20241016)", "types-urllib3 (==1.26.25.14)"]
test = ["cython (>=3.0)", "defusedxml (>=0.7.1)", "pytest (>=8.0)", "pytest-xdist[psutil] (>=3.4)", "setuptools (>=70.0)", "typing_extensions (>=4.9)"]
[[package]]
name = "sphinx-autobuild"
version = "2024.10.3"
description = "Rebuild Sphinx documentation on changes, with hot reloading in the browser."
optional = false
python-versions = ">=3.9"
groups = ["docs"]
files = [
{file = "sphinx_autobuild-2024.10.3-py3-none-any.whl", hash = "sha256:158e16c36f9d633e613c9aaf81c19b0fc458ca78b112533b20dafcda430d60fa"},
{file = "sphinx_autobuild-2024.10.3.tar.gz", hash = "sha256:248150f8f333e825107b6d4b86113ab28fa51750e5f9ae63b59dc339be951fb1"},
]
[package.dependencies]
colorama = ">=0.4.6"
sphinx = "*"
starlette = ">=0.35"
uvicorn = ">=0.25"
watchfiles = ">=0.20"
websockets = ">=11"
[package.extras]
test = ["httpx", "pytest (>=6)"]
[[package]]
name = "sphinx-basic-ng"
version = "1.0.0b2"
description = "A modern skeleton for Sphinx themes."
optional = false
python-versions = ">=3.7"
groups = ["docs"]
files = [
{file = "sphinx_basic_ng-1.0.0b2-py3-none-any.whl", hash = "sha256:eb09aedbabfb650607e9b4b68c9d240b90b1e1be221d6ad71d61c52e29f7932b"},
{file = "sphinx_basic_ng-1.0.0b2.tar.gz", hash = "sha256:9ec55a47c90c8c002b5960c57492ec3021f5193cb26cebc2dc4ea226848651c9"},
]
[package.dependencies]
sphinx = ">=4.0"
[package.extras]
docs = ["furo", "ipython", "myst-parser", "sphinx-copybutton", "sphinx-inline-tabs"]
[[package]]
name = "sphinxcontrib-applehelp"
version = "2.0.0"
description = "sphinxcontrib-applehelp is a Sphinx extension which outputs Apple help books"
optional = false
python-versions = ">=3.9"
groups = ["docs"]
files = [
{file = "sphinxcontrib_applehelp-2.0.0-py3-none-any.whl", hash = "sha256:4cd3f0ec4ac5dd9c17ec65e9ab272c9b867ea77425228e68ecf08d6b28ddbdb5"},
{file = "sphinxcontrib_applehelp-2.0.0.tar.gz", hash = "sha256:2f29ef331735ce958efa4734873f084941970894c6090408b079c61b2e1c06d1"},
]
[package.extras]
lint = ["mypy", "ruff (==0.5.5)", "types-docutils"]
standalone = ["Sphinx (>=5)"]
test = ["pytest"]
[[package]]
name = "sphinxcontrib-devhelp"
version = "2.0.0"
description = "sphinxcontrib-devhelp is a sphinx extension which outputs Devhelp documents"
optional = false
python-versions = ">=3.9"
groups = ["docs"]
files = [
{file = "sphinxcontrib_devhelp-2.0.0-py3-none-any.whl", hash = "sha256:aefb8b83854e4b0998877524d1029fd3e6879210422ee3780459e28a1f03a8a2"},
{file = "sphinxcontrib_devhelp-2.0.0.tar.gz", hash = "sha256:411f5d96d445d1d73bb5d52133377b4248ec79db5c793ce7dbe59e074b4dd1ad"},
]
[package.extras]
lint = ["mypy", "ruff (==0.5.5)", "types-docutils"]
standalone = ["Sphinx (>=5)"]
test = ["pytest"]
[[package]]
name = "sphinxcontrib-htmlhelp"
version = "2.1.0"
description = "sphinxcontrib-htmlhelp is a sphinx extension which renders HTML help files"
optional = false
python-versions = ">=3.9"
groups = ["docs"]
files = [
{file = "sphinxcontrib_htmlhelp-2.1.0-py3-none-any.whl", hash = "sha256:166759820b47002d22914d64a075ce08f4c46818e17cfc9470a9786b759b19f8"},
{file = "sphinxcontrib_htmlhelp-2.1.0.tar.gz", hash = "sha256:c9e2916ace8aad64cc13a0d233ee22317f2b9025b9cf3295249fa985cc7082e9"},
]
[package.extras]
lint = ["mypy", "ruff (==0.5.5)", "types-docutils"]
standalone = ["Sphinx (>=5)"]
test = ["html5lib", "pytest"]
[[package]]
name = "sphinxcontrib-jsmath"
version = "1.0.1"
description = "A sphinx extension which renders display math in HTML via JavaScript"
optional = false
python-versions = ">=3.5"
groups = ["docs"]
files = [
{file = "sphinxcontrib-jsmath-1.0.1.tar.gz", hash = "sha256:a9925e4a4587247ed2191a22df5f6970656cb8ca2bd6284309578f2153e0c4b8"},
{file = "sphinxcontrib_jsmath-1.0.1-py2.py3-none-any.whl", hash = "sha256:2ec2eaebfb78f3f2078e73666b1415417a116cc848b72e5172e596c871103178"},
]
[package.extras]
test = ["flake8", "mypy", "pytest"]
[[package]]
name = "sphinxcontrib-qthelp"
version = "2.0.0"
description = "sphinxcontrib-qthelp is a sphinx extension which outputs QtHelp documents"
optional = false
python-versions = ">=3.9"
groups = ["docs"]
files = [
{file = "sphinxcontrib_qthelp-2.0.0-py3-none-any.whl", hash = "sha256:b18a828cdba941ccd6ee8445dbe72ffa3ef8cbe7505d8cd1fa0d42d3f2d5f3eb"},
{file = "sphinxcontrib_qthelp-2.0.0.tar.gz", hash = "sha256:4fe7d0ac8fc171045be623aba3e2a8f613f8682731f9153bb2e40ece16b9bbab"},
]
[package.extras]
lint = ["mypy", "ruff (==0.5.5)", "types-docutils"]
standalone = ["Sphinx (>=5)"]
test = ["defusedxml (>=0.7.1)", "pytest"]
[[package]]
name = "sphinxcontrib-serializinghtml"
version = "2.0.0"
description = "sphinxcontrib-serializinghtml is a sphinx extension which outputs \"serialized\" HTML files (json and pickle)"
optional = false
python-versions = ">=3.9"
groups = ["docs"]
files = [
{file = "sphinxcontrib_serializinghtml-2.0.0-py3-none-any.whl", hash = "sha256:6e2cb0eef194e10c27ec0023bfeb25badbbb5868244cf5bc5bdc04e4464bf331"},
{file = "sphinxcontrib_serializinghtml-2.0.0.tar.gz", hash = "sha256:e9d912827f872c029017a53f0ef2180b327c3f7fd23c87229f7a8e8b70031d4d"},
]
[package.extras]
lint = ["mypy", "ruff (==0.5.5)", "types-docutils"]
standalone = ["Sphinx (>=5)"]
test = ["pytest"]
[[package]]
name = "starlette"
version = "0.46.2"
description = "The little ASGI library that shines."
optional = false
python-versions = ">=3.9"
groups = ["docs"]
files = [
{file = "starlette-0.46.2-py3-none-any.whl", hash = "sha256:595633ce89f8ffa71a015caed34a5b2dc1c0cdb3f0f1fbd1e69339cf2abeec35"},
{file = "starlette-0.46.2.tar.gz", hash = "sha256:7f7361f34eed179294600af672f565727419830b54b7b084efe44bb82d2fccd5"},
]
[package.dependencies]
anyio = ">=3.6.2,<5"
[package.extras]
full = ["httpx (>=0.27.0,<0.29.0)", "itsdangerous", "jinja2", "python-multipart (>=0.0.18)", "pyyaml"]
[[package]]
name = "typing-extensions"
version = "4.13.2"
description = "Backported and Experimental Type Hints for Python 3.8+"
optional = false
python-versions = ">=3.8"
groups = ["main", "docs"]
files = [
{file = "typing_extensions-4.13.2-py3-none-any.whl", hash = "sha256:a439e7c04b49fec3e5d3e2beaa21755cadbbdc391694e28ccdd36ca4a1408f8c"},
{file = "typing_extensions-4.13.2.tar.gz", hash = "sha256:e6c81219bd689f51865d9e372991c540bda33a0379d5573cddb9a3a23f7caaef"},
]
markers = {main = "python_version < \"3.12\""}
[[package]]
name = "uart-devices"
version = "0.1.1"
description = "UART Devices for Linux"
optional = false
python-versions = "<4.0,>=3.9"
groups = ["main"]
files = [
{file = "uart_devices-0.1.1-py3-none-any.whl", hash = "sha256:55bc8cce66465e90b298f0910e5c496bc7be021341c5455954cf61c6253dc123"},
{file = "uart_devices-0.1.1.tar.gz", hash = "sha256:3a52c4ae0f5f7400ebe1ae5f6e2a2d40cc0b7f18a50e895236535c4e53c6ed34"},
]
[[package]]
name = "urllib3"
version = "2.4.0"
description = "HTTP library with thread-safe connection pooling, file post, and more."
optional = false
python-versions = ">=3.9"
groups = ["docs"]
files = [
{file = "urllib3-2.4.0-py3-none-any.whl", hash = "sha256:4e16665048960a0900c702d4a66415956a584919c03361cac9f1df5c5dd7e813"},
{file = "urllib3-2.4.0.tar.gz", hash = "sha256:414bc6535b787febd7567804cc015fee39daab8ad86268f1310a9250697de466"},
]
[package.extras]
brotli = ["brotli (>=1.0.9) ; platform_python_implementation == \"CPython\"", "brotlicffi (>=0.8.0) ; platform_python_implementation != \"CPython\""]
h2 = ["h2 (>=4,<5)"]
socks = ["pysocks (>=1.5.6,!=1.5.7,<2.0)"]
zstd = ["zstandard (>=0.18.0)"]
[[package]]
name = "usb-devices"
version = "0.4.5"
description = "Tools for mapping, describing, and resetting USB devices"
optional = false
python-versions = ">=3.9,<4.0"
groups = ["main"]
files = [
{file = "usb_devices-0.4.5-py3-none-any.whl", hash = "sha256:8a415219ef1395e25aa0bddcad484c88edf9673acdeae8a07223ca7222a01dcf"},
{file = "usb_devices-0.4.5.tar.gz", hash = "sha256:9b5c7606df2bc791c6c45b7f76244a0cbed83cb6fa4c68791a143c03345e195d"},
]
[[package]]
name = "uvicorn"
version = "0.34.1"
description = "The lightning-fast ASGI server."
optional = false
python-versions = ">=3.9"
groups = ["docs"]
files = [
{file = "uvicorn-0.34.1-py3-none-any.whl", hash = "sha256:984c3a8c7ca18ebaad15995ee7401179212c59521e67bfc390c07fa2b8d2e065"},
{file = "uvicorn-0.34.1.tar.gz", hash = "sha256:af981725fc4b7ffc5cb3b0e9eda6258a90c4b52cb2a83ce567ae0a7ae1757afc"},
]
[package.dependencies]
click = ">=7.0"
h11 = ">=0.8"
[package.extras]
standard = ["colorama (>=0.4) ; sys_platform == \"win32\"", "httptools (>=0.6.3)", "python-dotenv (>=0.13)", "pyyaml (>=5.1)", "uvloop (>=0.14.0,!=0.15.0,!=0.15.1) ; sys_platform != \"win32\" and sys_platform != \"cygwin\" and platform_python_implementation != \"PyPy\"", "watchfiles (>=0.13)", "websockets (>=10.4)"]
[[package]]
name = "watchfiles"
version = "1.0.5"
description = "Simple, modern and high performance file watching and code reload in python."
optional = false
python-versions = ">=3.9"
groups = ["docs"]
files = [
{file = "watchfiles-1.0.5-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:5c40fe7dd9e5f81e0847b1ea64e1f5dd79dd61afbedb57759df06767ac719b40"},
{file = "watchfiles-1.0.5-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:8c0db396e6003d99bb2d7232c957b5f0b5634bbd1b24e381a5afcc880f7373fb"},
{file = "watchfiles-1.0.5-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b551d4fb482fc57d852b4541f911ba28957d051c8776e79c3b4a51eb5e2a1b11"},
{file = "watchfiles-1.0.5-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:830aa432ba5c491d52a15b51526c29e4a4b92bf4f92253787f9726fe01519487"},
{file = "watchfiles-1.0.5-cp310-cp310-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:a16512051a822a416b0d477d5f8c0e67b67c1a20d9acecb0aafa3aa4d6e7d256"},
{file = "watchfiles-1.0.5-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:bfe0cbc787770e52a96c6fda6726ace75be7f840cb327e1b08d7d54eadc3bc85"},
{file = "watchfiles-1.0.5-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d363152c5e16b29d66cbde8fa614f9e313e6f94a8204eaab268db52231fe5358"},
{file = "watchfiles-1.0.5-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7ee32c9a9bee4d0b7bd7cbeb53cb185cf0b622ac761efaa2eba84006c3b3a614"},
{file = "watchfiles-1.0.5-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:29c7fd632ccaf5517c16a5188e36f6612d6472ccf55382db6c7fe3fcccb7f59f"},
{file = "watchfiles-1.0.5-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:8e637810586e6fe380c8bc1b3910accd7f1d3a9a7262c8a78d4c8fb3ba6a2b3d"},
{file = "watchfiles-1.0.5-cp310-cp310-win32.whl", hash = "sha256:cd47d063fbeabd4c6cae1d4bcaa38f0902f8dc5ed168072874ea11d0c7afc1ff"},
{file = "watchfiles-1.0.5-cp310-cp310-win_amd64.whl", hash = "sha256:86c0df05b47a79d80351cd179893f2f9c1b1cae49d96e8b3290c7f4bd0ca0a92"},
{file = "watchfiles-1.0.5-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:237f9be419e977a0f8f6b2e7b0475ababe78ff1ab06822df95d914a945eac827"},
{file = "watchfiles-1.0.5-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:e0da39ff917af8b27a4bdc5a97ac577552a38aac0d260a859c1517ea3dc1a7c4"},
{file = "watchfiles-1.0.5-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2cfcb3952350e95603f232a7a15f6c5f86c5375e46f0bd4ae70d43e3e063c13d"},
{file = "watchfiles-1.0.5-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:68b2dddba7a4e6151384e252a5632efcaa9bc5d1c4b567f3cb621306b2ca9f63"},
{file = "watchfiles-1.0.5-cp311-cp311-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:95cf944fcfc394c5f9de794ce581914900f82ff1f855326f25ebcf24d5397418"},
{file = "watchfiles-1.0.5-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ecf6cd9f83d7c023b1aba15d13f705ca7b7d38675c121f3cc4a6e25bd0857ee9"},
{file = "watchfiles-1.0.5-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:852de68acd6212cd6d33edf21e6f9e56e5d98c6add46f48244bd479d97c967c6"},
{file = "watchfiles-1.0.5-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d5730f3aa35e646103b53389d5bc77edfbf578ab6dab2e005142b5b80a35ef25"},
{file = "watchfiles-1.0.5-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:18b3bd29954bc4abeeb4e9d9cf0b30227f0f206c86657674f544cb032296acd5"},
{file = "watchfiles-1.0.5-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:ba5552a1b07c8edbf197055bc9d518b8f0d98a1c6a73a293bc0726dce068ed01"},
{file = "watchfiles-1.0.5-cp311-cp311-win32.whl", hash = "sha256:2f1fefb2e90e89959447bc0420fddd1e76f625784340d64a2f7d5983ef9ad246"},
{file = "watchfiles-1.0.5-cp311-cp311-win_amd64.whl", hash = "sha256:b6e76ceb1dd18c8e29c73f47d41866972e891fc4cc7ba014f487def72c1cf096"},
{file = "watchfiles-1.0.5-cp311-cp311-win_arm64.whl", hash = "sha256:266710eb6fddc1f5e51843c70e3bebfb0f5e77cf4f27129278c70554104d19ed"},
{file = "watchfiles-1.0.5-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:b5eb568c2aa6018e26da9e6c86f3ec3fd958cee7f0311b35c2630fa4217d17f2"},
{file = "watchfiles-1.0.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:0a04059f4923ce4e856b4b4e5e783a70f49d9663d22a4c3b3298165996d1377f"},
{file = "watchfiles-1.0.5-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3e380c89983ce6e6fe2dd1e1921b9952fb4e6da882931abd1824c092ed495dec"},
{file = "watchfiles-1.0.5-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:fe43139b2c0fdc4a14d4f8d5b5d967f7a2777fd3d38ecf5b1ec669b0d7e43c21"},
{file = "watchfiles-1.0.5-cp312-cp312-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ee0822ce1b8a14fe5a066f93edd20aada932acfe348bede8aa2149f1a4489512"},
{file = "watchfiles-1.0.5-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a0dbcb1c2d8f2ab6e0a81c6699b236932bd264d4cef1ac475858d16c403de74d"},
{file = "watchfiles-1.0.5-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a2014a2b18ad3ca53b1f6c23f8cd94a18ce930c1837bd891262c182640eb40a6"},
{file = "watchfiles-1.0.5-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:10f6ae86d5cb647bf58f9f655fcf577f713915a5d69057a0371bc257e2553234"},
{file = "watchfiles-1.0.5-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:1a7bac2bde1d661fb31f4d4e8e539e178774b76db3c2c17c4bb3e960a5de07a2"},
{file = "watchfiles-1.0.5-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:4ab626da2fc1ac277bbf752446470b367f84b50295264d2d313e28dc4405d663"},
{file = "watchfiles-1.0.5-cp312-cp312-win32.whl", hash = "sha256:9f4571a783914feda92018ef3901dab8caf5b029325b5fe4558c074582815249"},
{file = "watchfiles-1.0.5-cp312-cp312-win_amd64.whl", hash = "sha256:360a398c3a19672cf93527f7e8d8b60d8275119c5d900f2e184d32483117a705"},
{file = "watchfiles-1.0.5-cp312-cp312-win_arm64.whl", hash = "sha256:1a2902ede862969077b97523987c38db28abbe09fb19866e711485d9fbf0d417"},
{file = "watchfiles-1.0.5-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:0b289572c33a0deae62daa57e44a25b99b783e5f7aed81b314232b3d3c81a11d"},
{file = "watchfiles-1.0.5-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:a056c2f692d65bf1e99c41045e3bdcaea3cb9e6b5a53dcaf60a5f3bd95fc9763"},
{file = "watchfiles-1.0.5-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b9dca99744991fc9850d18015c4f0438865414e50069670f5f7eee08340d8b40"},
{file = "watchfiles-1.0.5-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:894342d61d355446d02cd3988a7326af344143eb33a2fd5d38482a92072d9563"},
{file = "watchfiles-1.0.5-cp313-cp313-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ab44e1580924d1ffd7b3938e02716d5ad190441965138b4aa1d1f31ea0877f04"},
{file = "watchfiles-1.0.5-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d6f9367b132078b2ceb8d066ff6c93a970a18c3029cea37bfd7b2d3dd2e5db8f"},
{file = "watchfiles-1.0.5-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f2e55a9b162e06e3f862fb61e399fe9f05d908d019d87bf5b496a04ef18a970a"},
{file = "watchfiles-1.0.5-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0125f91f70e0732a9f8ee01e49515c35d38ba48db507a50c5bdcad9503af5827"},
{file = "watchfiles-1.0.5-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:13bb21f8ba3248386337c9fa51c528868e6c34a707f729ab041c846d52a0c69a"},
{file = "watchfiles-1.0.5-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:839ebd0df4a18c5b3c1b890145b5a3f5f64063c2a0d02b13c76d78fe5de34936"},
{file = "watchfiles-1.0.5-cp313-cp313-win32.whl", hash = "sha256:4a8ec1e4e16e2d5bafc9ba82f7aaecfeec990ca7cd27e84fb6f191804ed2fcfc"},
{file = "watchfiles-1.0.5-cp313-cp313-win_amd64.whl", hash = "sha256:f436601594f15bf406518af922a89dcaab416568edb6f65c4e5bbbad1ea45c11"},
{file = "watchfiles-1.0.5-cp39-cp39-macosx_10_12_x86_64.whl", hash = "sha256:2cfb371be97d4db374cba381b9f911dd35bb5f4c58faa7b8b7106c8853e5d225"},
{file = "watchfiles-1.0.5-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:a3904d88955fda461ea2531fcf6ef73584ca921415d5cfa44457a225f4a42bc1"},
{file = "watchfiles-1.0.5-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2b7a21715fb12274a71d335cff6c71fe7f676b293d322722fe708a9ec81d91f5"},
{file = "watchfiles-1.0.5-cp39-cp39-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:dfd6ae1c385ab481766b3c61c44aca2b3cd775f6f7c0fa93d979ddec853d29d5"},
{file = "watchfiles-1.0.5-cp39-cp39-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b659576b950865fdad31fa491d31d37cf78b27113a7671d39f919828587b429b"},
{file = "watchfiles-1.0.5-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1909e0a9cd95251b15bff4261de5dd7550885bd172e3536824bf1cf6b121e200"},
{file = "watchfiles-1.0.5-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:832ccc221927c860e7286c55c9b6ebcc0265d5e072f49c7f6456c7798d2b39aa"},
{file = "watchfiles-1.0.5-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:85fbb6102b3296926d0c62cfc9347f6237fb9400aecd0ba6bbda94cae15f2b3b"},
{file = "watchfiles-1.0.5-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:15ac96dd567ad6c71c71f7b2c658cb22b7734901546cd50a475128ab557593ca"},
{file = "watchfiles-1.0.5-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:4b6227351e11c57ae997d222e13f5b6f1f0700d84b8c52304e8675d33a808382"},
{file = "watchfiles-1.0.5-cp39-cp39-win32.whl", hash = "sha256:974866e0db748ebf1eccab17862bc0f0303807ed9cda465d1324625b81293a18"},
{file = "watchfiles-1.0.5-cp39-cp39-win_amd64.whl", hash = "sha256:9848b21ae152fe79c10dd0197304ada8f7b586d3ebc3f27f43c506e5a52a863c"},
{file = "watchfiles-1.0.5-pp310-pypy310_pp73-macosx_10_12_x86_64.whl", hash = "sha256:f59b870db1f1ae5a9ac28245707d955c8721dd6565e7f411024fa374b5362d1d"},
{file = "watchfiles-1.0.5-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:9475b0093767e1475095f2aeb1d219fb9664081d403d1dff81342df8cd707034"},
{file = "watchfiles-1.0.5-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:fc533aa50664ebd6c628b2f30591956519462f5d27f951ed03d6c82b2dfd9965"},
{file = "watchfiles-1.0.5-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fed1cd825158dcaae36acce7b2db33dcbfd12b30c34317a88b8ed80f0541cc57"},
{file = "watchfiles-1.0.5-pp39-pypy39_pp73-macosx_10_12_x86_64.whl", hash = "sha256:554389562c29c2c182e3908b149095051f81d28c2fec79ad6c8997d7d63e0009"},
{file = "watchfiles-1.0.5-pp39-pypy39_pp73-macosx_11_0_arm64.whl", hash = "sha256:a74add8d7727e6404d5dc4dcd7fac65d4d82f95928bbee0cf5414c900e86773e"},
{file = "watchfiles-1.0.5-pp39-pypy39_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:cb1489f25b051a89fae574505cc26360c8e95e227a9500182a7fe0afcc500ce0"},
{file = "watchfiles-1.0.5-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c0901429650652d3f0da90bad42bdafc1f9143ff3605633c455c999a2d786cac"},
{file = "watchfiles-1.0.5.tar.gz", hash = "sha256:b7529b5dcc114679d43827d8c35a07c493ad6f083633d573d81c660abc5979e9"},
]
[package.dependencies]
anyio = ">=3.0.0"
[[package]]
name = "websockets"
version = "15.0.1"
description = "An implementation of the WebSocket Protocol (RFC 6455 & 7692)"
optional = false
python-versions = ">=3.9"
groups = ["docs"]
files = [
{file = "websockets-15.0.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:d63efaa0cd96cf0c5fe4d581521d9fa87744540d4bc999ae6e08595a1014b45b"},
{file = "websockets-15.0.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:ac60e3b188ec7574cb761b08d50fcedf9d77f1530352db4eef1707fe9dee7205"},
{file = "websockets-15.0.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:5756779642579d902eed757b21b0164cd6fe338506a8083eb58af5c372e39d9a"},
{file = "websockets-15.0.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0fdfe3e2a29e4db3659dbd5bbf04560cea53dd9610273917799f1cde46aa725e"},
{file = "websockets-15.0.1-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:4c2529b320eb9e35af0fa3016c187dffb84a3ecc572bcee7c3ce302bfeba52bf"},
{file = "websockets-15.0.1-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ac1e5c9054fe23226fb11e05a6e630837f074174c4c2f0fe442996112a6de4fb"},
{file = "websockets-15.0.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:5df592cd503496351d6dc14f7cdad49f268d8e618f80dce0cd5a36b93c3fc08d"},
{file = "websockets-15.0.1-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:0a34631031a8f05657e8e90903e656959234f3a04552259458aac0b0f9ae6fd9"},
{file = "websockets-15.0.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:3d00075aa65772e7ce9e990cab3ff1de702aa09be3940d1dc88d5abf1ab8a09c"},
{file = "websockets-15.0.1-cp310-cp310-win32.whl", hash = "sha256:1234d4ef35db82f5446dca8e35a7da7964d02c127b095e172e54397fb6a6c256"},
{file = "websockets-15.0.1-cp310-cp310-win_amd64.whl", hash = "sha256:39c1fec2c11dc8d89bba6b2bf1556af381611a173ac2b511cf7231622058af41"},
{file = "websockets-15.0.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:823c248b690b2fd9303ba00c4f66cd5e2d8c3ba4aa968b2779be9532a4dad431"},
{file = "websockets-15.0.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:678999709e68425ae2593acf2e3ebcbcf2e69885a5ee78f9eb80e6e371f1bf57"},
{file = "websockets-15.0.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:d50fd1ee42388dcfb2b3676132c78116490976f1300da28eb629272d5d93e905"},
{file = "websockets-15.0.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d99e5546bf73dbad5bf3547174cd6cb8ba7273062a23808ffea025ecb1cf8562"},
{file = "websockets-15.0.1-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:66dd88c918e3287efc22409d426c8f729688d89a0c587c88971a0faa2c2f3792"},
{file = "websockets-15.0.1-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8dd8327c795b3e3f219760fa603dcae1dcc148172290a8ab15158cf85a953413"},
{file = "websockets-15.0.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:8fdc51055e6ff4adeb88d58a11042ec9a5eae317a0a53d12c062c8a8865909e8"},
{file = "websockets-15.0.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:693f0192126df6c2327cce3baa7c06f2a117575e32ab2308f7f8216c29d9e2e3"},
{file = "websockets-15.0.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:54479983bd5fb469c38f2f5c7e3a24f9a4e70594cd68cd1fa6b9340dadaff7cf"},
{file = "websockets-15.0.1-cp311-cp311-win32.whl", hash = "sha256:16b6c1b3e57799b9d38427dda63edcbe4926352c47cf88588c0be4ace18dac85"},
{file = "websockets-15.0.1-cp311-cp311-win_amd64.whl", hash = "sha256:27ccee0071a0e75d22cb35849b1db43f2ecd3e161041ac1ee9d2352ddf72f065"},
{file = "websockets-15.0.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:3e90baa811a5d73f3ca0bcbf32064d663ed81318ab225ee4f427ad4e26e5aff3"},
{file = "websockets-15.0.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:592f1a9fe869c778694f0aa806ba0374e97648ab57936f092fd9d87f8bc03665"},
{file = "websockets-15.0.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:0701bc3cfcb9164d04a14b149fd74be7347a530ad3bbf15ab2c678a2cd3dd9a2"},
{file = "websockets-15.0.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e8b56bdcdb4505c8078cb6c7157d9811a85790f2f2b3632c7d1462ab5783d215"},
{file = "websockets-15.0.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0af68c55afbd5f07986df82831c7bff04846928ea8d1fd7f30052638788bc9b5"},
{file = "websockets-15.0.1-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:64dee438fed052b52e4f98f76c5790513235efaa1ef7f3f2192c392cd7c91b65"},
{file = "websockets-15.0.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:d5f6b181bb38171a8ad1d6aa58a67a6aa9d4b38d0f8c5f496b9e42561dfc62fe"},
{file = "websockets-15.0.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:5d54b09eba2bada6011aea5375542a157637b91029687eb4fdb2dab11059c1b4"},
{file = "websockets-15.0.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:3be571a8b5afed347da347bfcf27ba12b069d9d7f42cb8c7028b5e98bbb12597"},
{file = "websockets-15.0.1-cp312-cp312-win32.whl", hash = "sha256:c338ffa0520bdb12fbc527265235639fb76e7bc7faafbb93f6ba80d9c06578a9"},
{file = "websockets-15.0.1-cp312-cp312-win_amd64.whl", hash = "sha256:fcd5cf9e305d7b8338754470cf69cf81f420459dbae8a3b40cee57417f4614a7"},
{file = "websockets-15.0.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:ee443ef070bb3b6ed74514f5efaa37a252af57c90eb33b956d35c8e9c10a1931"},
{file = "websockets-15.0.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:5a939de6b7b4e18ca683218320fc67ea886038265fd1ed30173f5ce3f8e85675"},
{file = "websockets-15.0.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:746ee8dba912cd6fc889a8147168991d50ed70447bf18bcda7039f7d2e3d9151"},
{file = "websockets-15.0.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:595b6c3969023ecf9041b2936ac3827e4623bfa3ccf007575f04c5a6aa318c22"},
{file = "websockets-15.0.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3c714d2fc58b5ca3e285461a4cc0c9a66bd0e24c5da9911e30158286c9b5be7f"},
{file = "websockets-15.0.1-cp313-cp313-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0f3c1e2ab208db911594ae5b4f79addeb3501604a165019dd221c0bdcabe4db8"},
{file = "websockets-15.0.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:229cf1d3ca6c1804400b0a9790dc66528e08a6a1feec0d5040e8b9eb14422375"},
{file = "websockets-15.0.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:756c56e867a90fb00177d530dca4b097dd753cde348448a1012ed6c5131f8b7d"},
{file = "websockets-15.0.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:558d023b3df0bffe50a04e710bc87742de35060580a293c2a984299ed83bc4e4"},
{file = "websockets-15.0.1-cp313-cp313-win32.whl", hash = "sha256:ba9e56e8ceeeedb2e080147ba85ffcd5cd0711b89576b83784d8605a7df455fa"},
{file = "websockets-15.0.1-cp313-cp313-win_amd64.whl", hash = "sha256:e09473f095a819042ecb2ab9465aee615bd9c2028e4ef7d933600a8401c79561"},
{file = "websockets-15.0.1-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:5f4c04ead5aed67c8a1a20491d54cdfba5884507a48dd798ecaf13c74c4489f5"},
{file = "websockets-15.0.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:abdc0c6c8c648b4805c5eacd131910d2a7f6455dfd3becab248ef108e89ab16a"},
{file = "websockets-15.0.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:a625e06551975f4b7ea7102bc43895b90742746797e2e14b70ed61c43a90f09b"},
{file = "websockets-15.0.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d591f8de75824cbb7acad4e05d2d710484f15f29d4a915092675ad3456f11770"},
{file = "websockets-15.0.1-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:47819cea040f31d670cc8d324bb6435c6f133b8c7a19ec3d61634e62f8d8f9eb"},
{file = "websockets-15.0.1-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ac017dd64572e5c3bd01939121e4d16cf30e5d7e110a119399cf3133b63ad054"},
{file = "websockets-15.0.1-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:4a9fac8e469d04ce6c25bb2610dc535235bd4aa14996b4e6dbebf5e007eba5ee"},
{file = "websockets-15.0.1-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:363c6f671b761efcb30608d24925a382497c12c506b51661883c3e22337265ed"},
{file = "websockets-15.0.1-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:2034693ad3097d5355bfdacfffcbd3ef5694f9718ab7f29c29689a9eae841880"},
{file = "websockets-15.0.1-cp39-cp39-win32.whl", hash = "sha256:3b1ac0d3e594bf121308112697cf4b32be538fb1444468fb0a6ae4feebc83411"},
{file = "websockets-15.0.1-cp39-cp39-win_amd64.whl", hash = "sha256:b7643a03db5c95c799b89b31c036d5f27eeb4d259c798e878d6937d71832b1e4"},
{file = "websockets-15.0.1-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:0c9e74d766f2818bb95f84c25be4dea09841ac0f734d1966f415e4edfc4ef1c3"},
{file = "websockets-15.0.1-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:1009ee0c7739c08a0cd59de430d6de452a55e42d6b522de7aa15e6f67db0b8e1"},
{file = "websockets-15.0.1-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:76d1f20b1c7a2fa82367e04982e708723ba0e7b8d43aa643d3dcd404d74f1475"},
{file = "websockets-15.0.1-pp310-pypy310_pp73-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f29d80eb9a9263b8d109135351caf568cc3f80b9928bccde535c235de55c22d9"},
{file = "websockets-15.0.1-pp310-pypy310_pp73-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b359ed09954d7c18bbc1680f380c7301f92c60bf924171629c5db97febb12f04"},
{file = "websockets-15.0.1-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:cad21560da69f4ce7658ca2cb83138fb4cf695a2ba3e475e0559e05991aa8122"},
{file = "websockets-15.0.1-pp39-pypy39_pp73-macosx_10_15_x86_64.whl", hash = "sha256:7f493881579c90fc262d9cdbaa05a6b54b3811c2f300766748db79f098db9940"},
{file = "websockets-15.0.1-pp39-pypy39_pp73-macosx_11_0_arm64.whl", hash = "sha256:47b099e1f4fbc95b701b6e85768e1fcdaf1630f3cbe4765fa216596f12310e2e"},
{file = "websockets-15.0.1-pp39-pypy39_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:67f2b6de947f8c757db2db9c71527933ad0019737ec374a8a6be9a956786aaf9"},
{file = "websockets-15.0.1-pp39-pypy39_pp73-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d08eb4c2b7d6c41da6ca0600c077e93f5adcfd979cd777d747e9ee624556da4b"},
{file = "websockets-15.0.1-pp39-pypy39_pp73-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4b826973a4a2ae47ba357e4e82fa44a463b8f168e1ca775ac64521442b19e87f"},
{file = "websockets-15.0.1-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:21c1fa28a6a7e3cbdc171c694398b6df4744613ce9b36b1a498e816787e28123"},
{file = "websockets-15.0.1-py3-none-any.whl", hash = "sha256:f7a866fbc1e97b5c617ee4116daaa09b722101d4a3c170c787450ba409f9736f"},
{file = "websockets-15.0.1.tar.gz", hash = "sha256:82544de02076bafba038ce055ee6412d68da13ab47f0c60cab827346de828dee"},
]
[[package]]
name = "winrt-runtime"
version = "2.3.0"
description = "Python projection of Windows Runtime (WinRT) APIs"
optional = false
python-versions = "<3.14,>=3.9"
groups = ["main"]
markers = "platform_system == \"Windows\" and python_version >= \"3.12\""
files = [
{file = "winrt_runtime-2.3.0-cp310-cp310-win32.whl", hash = "sha256:5c22ed339b420a6026134e28281b25078a9e6755eceb494dce5d42ee5814e3fd"},
{file = "winrt_runtime-2.3.0-cp310-cp310-win_amd64.whl", hash = "sha256:f3ef0d6b281a8d4155ea14a0f917faf82a004d4996d07beb2b3d2af191503fb1"},
{file = "winrt_runtime-2.3.0-cp310-cp310-win_arm64.whl", hash = "sha256:93ce23df52396ed89dfe659ee0e1a968928e526b9c577942d4a54ad55b333644"},
{file = "winrt_runtime-2.3.0-cp311-cp311-win32.whl", hash = "sha256:352d70864846fd7ec89703845b82a35cef73f42d178a02a4635a38df5a61c0f8"},
{file = "winrt_runtime-2.3.0-cp311-cp311-win_amd64.whl", hash = "sha256:286e6036af4903dd830398103c3edd110a46432347e8a52ba416d937c0e1f5f9"},
{file = "winrt_runtime-2.3.0-cp311-cp311-win_arm64.whl", hash = "sha256:44d0f0f48f2f10c02b885989e8bbac41d7bf9c03550b20ddf562100356fca7a9"},
{file = "winrt_runtime-2.3.0-cp312-cp312-win32.whl", hash = "sha256:03d3e4aedc65832e57c0dbf210ec2a9d7fb2819c74d420ba889b323e9fa5cf28"},
{file = "winrt_runtime-2.3.0-cp312-cp312-win_amd64.whl", hash = "sha256:0dc636aec2f4ee6c3849fa59dae10c128f4a908f0ce452e91af65d812ea66dcb"},
{file = "winrt_runtime-2.3.0-cp312-cp312-win_arm64.whl", hash = "sha256:d9f140c71e4f3bf7bf7d6853b246eab2e1632c72f218ff163aa41a74b576736f"},
{file = "winrt_runtime-2.3.0-cp313-cp313-win32.whl", hash = "sha256:77f06df6b7a6cb536913ae455e30c1733d31d88dafe2c3cd8c3d0e2bcf7e2a20"},
{file = "winrt_runtime-2.3.0-cp313-cp313-win_amd64.whl", hash = "sha256:7388774b74ea2f4510ab3a98c95af296665ebe69d9d7e2fd7ee2c3fc5856099e"},
{file = "winrt_runtime-2.3.0-cp313-cp313-win_arm64.whl", hash = "sha256:0d3a4ac7661cad492d51653054e63328b940a6083c1ee1dd977f90069cb8afaa"},
{file = "winrt_runtime-2.3.0-cp39-cp39-win32.whl", hash = "sha256:cd7bce2c7703054e7f64d11be665e9728e15d9dae0d952a51228fe830e0c4b55"},
{file = "winrt_runtime-2.3.0-cp39-cp39-win_amd64.whl", hash = "sha256:2da01af378ab9374a3a933da97543f471a676a3b844318316869bffeff811e8a"},
{file = "winrt_runtime-2.3.0-cp39-cp39-win_arm64.whl", hash = "sha256:1c6bbfcc7cbe1c8159ed5d776b30b7f1cbc2c6990803292823b0788c22d75636"},
{file = "winrt_runtime-2.3.0.tar.gz", hash = "sha256:bb895a2b8c74b375781302215e2661914369c625aa1f8df84f8d37691b22db77"},
]
[[package]]
name = "winrt-windows-devices-bluetooth"
version = "2.3.0"
description = "Python projection of Windows Runtime (WinRT) APIs"
optional = false
python-versions = "<3.14,>=3.9"
groups = ["main"]
markers = "platform_system == \"Windows\" and python_version >= \"3.12\""
files = [
{file = "winrt_Windows.Devices.Bluetooth-2.3.0-cp310-cp310-win32.whl", hash = "sha256:554aa6d0ca4bebc22a45f19fa60db1183a2b5643468f3c95cf0ebc33fbc1b0d0"},
{file = "winrt_Windows.Devices.Bluetooth-2.3.0-cp310-cp310-win_amd64.whl", hash = "sha256:cec2682e10431f027c1823647772671fb09bebc1e8a00021a3651120b846d36f"},
{file = "winrt_Windows.Devices.Bluetooth-2.3.0-cp310-cp310-win_arm64.whl", hash = "sha256:b4d42faef99845de2aded4c75c906f03cc3ba3df51fb4435e4cc88a19168cf99"},
{file = "winrt_Windows.Devices.Bluetooth-2.3.0-cp311-cp311-win32.whl", hash = "sha256:64e0992175d4d5a1160179a8c586c2202a0edbd47a5b6da4efdbc8bb601f2f99"},
{file = "winrt_Windows.Devices.Bluetooth-2.3.0-cp311-cp311-win_amd64.whl", hash = "sha256:0830111c077508b599062fbe2d817203e4efa3605bd209cf4a3e03388ec39dda"},
{file = "winrt_Windows.Devices.Bluetooth-2.3.0-cp311-cp311-win_arm64.whl", hash = "sha256:3943d538cb7b6bde3fd8741591eb6e23487ee9ee6284f05428b205e7d10b6d92"},
{file = "winrt_Windows.Devices.Bluetooth-2.3.0-cp312-cp312-win32.whl", hash = "sha256:544ed169039e6d5e250323cc18c87967cfeb4d3d09ce354fd7c5fd2283f3bb98"},
{file = "winrt_Windows.Devices.Bluetooth-2.3.0-cp312-cp312-win_amd64.whl", hash = "sha256:f7becf095bf9bc999629fcb6401a88b879c3531b3c55c820e63259c955ddc06c"},
{file = "winrt_Windows.Devices.Bluetooth-2.3.0-cp312-cp312-win_arm64.whl", hash = "sha256:a6a2980409c855b4e5dab0be9bde9f30236292ac1fc994df959fa5a518cd6690"},
{file = "winrt_Windows.Devices.Bluetooth-2.3.0-cp313-cp313-win32.whl", hash = "sha256:82f443be43379d4762e72633047c82843c873b6f26428a18855ca7b53e1958d7"},
{file = "winrt_Windows.Devices.Bluetooth-2.3.0-cp313-cp313-win_amd64.whl", hash = "sha256:8b407da87ab52315c2d562a75d824dcafcae6e1628031cdb971072a47eb78ff0"},
{file = "winrt_Windows.Devices.Bluetooth-2.3.0-cp313-cp313-win_arm64.whl", hash = "sha256:e36d0b487bc5b64662b8470085edf8bfa5a220d7afc4f2e8d7faa3e3ac2bae80"},
{file = "winrt_Windows.Devices.Bluetooth-2.3.0-cp39-cp39-win32.whl", hash = "sha256:6553023433edf5a75767e8962bf492d0623036975c7d8373d5bbccc633a77bbc"},
{file = "winrt_Windows.Devices.Bluetooth-2.3.0-cp39-cp39-win_amd64.whl", hash = "sha256:77bdeadb043190c40ebbad462cd06e38b6461bc976bc67daf587e9395c387aae"},
{file = "winrt_Windows.Devices.Bluetooth-2.3.0-cp39-cp39-win_arm64.whl", hash = "sha256:c588ab79b534fedecce48f7082b419315e8d797d0120556166492e603e90d932"},
{file = "winrt_windows_devices_bluetooth-2.3.0.tar.gz", hash = "sha256:a1204b71c369a0399ec15d9a7b7c67990dd74504e486b839bf81825bd381a837"},
]
[package.dependencies]
winrt-runtime = "2.3.0"
[package.extras]
all = ["winrt-Windows.Devices.Bluetooth.GenericAttributeProfile[all] (==2.3.0)", "winrt-Windows.Devices.Bluetooth.Rfcomm[all] (==2.3.0)", "winrt-Windows.Devices.Enumeration[all] (==2.3.0)", "winrt-Windows.Devices.Radios[all] (==2.3.0)", "winrt-Windows.Foundation.Collections[all] (==2.3.0)", "winrt-Windows.Foundation[all] (==2.3.0)", "winrt-Windows.Networking[all] (==2.3.0)", "winrt-Windows.Storage.Streams[all] (==2.3.0)"]
[[package]]
name = "winrt-windows-devices-bluetooth-advertisement"
version = "2.3.0"
description = "Python projection of Windows Runtime (WinRT) APIs"
optional = false
python-versions = "<3.14,>=3.9"
groups = ["main"]
markers = "platform_system == \"Windows\" and python_version >= \"3.12\""
files = [
{file = "winrt_Windows.Devices.Bluetooth.Advertisement-2.3.0-cp310-cp310-win32.whl", hash = "sha256:4386498e7794ed383542ea868f0aa2dd8fb5f09f12bdffde024d12bd9f5a3756"},
{file = "winrt_Windows.Devices.Bluetooth.Advertisement-2.3.0-cp310-cp310-win_amd64.whl", hash = "sha256:d6fa25b2541d2898ae17982e86e0977a639b04f75119612cb46e1719474513fd"},
{file = "winrt_Windows.Devices.Bluetooth.Advertisement-2.3.0-cp310-cp310-win_arm64.whl", hash = "sha256:b200ff5acd181353f61f5b6446176faf78a61867d8c1d21e77a15e239d2cdf6b"},
{file = "winrt_Windows.Devices.Bluetooth.Advertisement-2.3.0-cp311-cp311-win32.whl", hash = "sha256:e56ad277813b48e35a3074f286c55a7a25884676e23ef9c3fc12349a42cb8fa4"},
{file = "winrt_Windows.Devices.Bluetooth.Advertisement-2.3.0-cp311-cp311-win_amd64.whl", hash = "sha256:d6533fef6a5914dc8d519b83b1841becf6fd2f37163d6e07df318a6a6118f194"},
{file = "winrt_Windows.Devices.Bluetooth.Advertisement-2.3.0-cp311-cp311-win_arm64.whl", hash = "sha256:8f4369cb0108f8ee0cace559f9870b00a4dde3fc1abd52f84adba08bc733825c"},
{file = "winrt_Windows.Devices.Bluetooth.Advertisement-2.3.0-cp312-cp312-win32.whl", hash = "sha256:d729d989acd7c1d703e2088299b6e219089a415db4a7b80cd52fdc507ec3ce95"},
{file = "winrt_Windows.Devices.Bluetooth.Advertisement-2.3.0-cp312-cp312-win_amd64.whl", hash = "sha256:1d3d258d4388a2b46f2e46f2fbdede1bf327eaa9c2dd4605f8a7fe454077c49e"},
{file = "winrt_Windows.Devices.Bluetooth.Advertisement-2.3.0-cp312-cp312-win_arm64.whl", hash = "sha256:d8c12457b00a79f8f1058d7a51bd8e7f177fb66e31389469e75b1104f6358921"},
{file = "winrt_Windows.Devices.Bluetooth.Advertisement-2.3.0-cp313-cp313-win32.whl", hash = "sha256:ac1e55a350881f82cb51e162cb7a4b5d9359e9e5fbde922de802404a951d64ec"},
{file = "winrt_Windows.Devices.Bluetooth.Advertisement-2.3.0-cp313-cp313-win_amd64.whl", hash = "sha256:0fc339340fb8be21c1c829816a49dc31b986c6d602d113d4a49ee8ffaf0e2396"},
{file = "winrt_Windows.Devices.Bluetooth.Advertisement-2.3.0-cp313-cp313-win_arm64.whl", hash = "sha256:da63d9c56edcb3b2d5135e65cc8c9c4658344dd480a8a2daf45beb2106f17874"},
{file = "winrt_Windows.Devices.Bluetooth.Advertisement-2.3.0-cp39-cp39-win32.whl", hash = "sha256:e98c6ae4b0afd3e4f3ab4fa06e84d6017ff9242146a64e3bad73f7f34183a076"},
{file = "winrt_Windows.Devices.Bluetooth.Advertisement-2.3.0-cp39-cp39-win_amd64.whl", hash = "sha256:cdc485f4143fbbb3ae0c9c9ad03b1021a5cb233c6df65bf56ac14f8e22c918c3"},
{file = "winrt_Windows.Devices.Bluetooth.Advertisement-2.3.0-cp39-cp39-win_arm64.whl", hash = "sha256:7af519cc895be84d6974e9f70d102545a5e8db05e065903b0fd84521218e60a9"},
{file = "winrt_windows_devices_bluetooth_advertisement-2.3.0.tar.gz", hash = "sha256:c8adbec690b765ca70337c35efec9910b0937a40a0a242184ea295367137f81c"},
]
[package.dependencies]
winrt-runtime = "2.3.0"
[package.extras]
all = ["winrt-Windows.Devices.Bluetooth[all] (==2.3.0)", "winrt-Windows.Foundation.Collections[all] (==2.3.0)", "winrt-Windows.Foundation[all] (==2.3.0)", "winrt-Windows.Storage.Streams[all] (==2.3.0)"]
[[package]]
name = "winrt-windows-devices-bluetooth-genericattributeprofile"
version = "2.3.0"
description = "Python projection of Windows Runtime (WinRT) APIs"
optional = false
python-versions = "<3.14,>=3.9"
groups = ["main"]
markers = "platform_system == \"Windows\" and python_version >= \"3.12\""
files = [
{file = "winrt_Windows.Devices.Bluetooth.GenericAttributeProfile-2.3.0-cp310-cp310-win32.whl", hash = "sha256:1ec75b107370827874d8435a47852d0459cb66d5694e02a833e0a75c4748e847"},
{file = "winrt_Windows.Devices.Bluetooth.GenericAttributeProfile-2.3.0-cp310-cp310-win_amd64.whl", hash = "sha256:0a178aa936abbc56ae1cc54a222dee4a34ce6c09506a5b592d4f7d04dbe76b95"},
{file = "winrt_Windows.Devices.Bluetooth.GenericAttributeProfile-2.3.0-cp310-cp310-win_arm64.whl", hash = "sha256:b7067b8578e19ad17b28694090d5b000fee57db5b219462155961b685d71fba5"},
{file = "winrt_Windows.Devices.Bluetooth.GenericAttributeProfile-2.3.0-cp311-cp311-win32.whl", hash = "sha256:e0aeba201e20b6c4bc18a4336b5b07d653d4ab4c9c17a301613db680a346cd5e"},
{file = "winrt_Windows.Devices.Bluetooth.GenericAttributeProfile-2.3.0-cp311-cp311-win_amd64.whl", hash = "sha256:f87b3995de18b98075ec2b02afc7252873fa75e7c840eb770d7bfafb4fda5c12"},
{file = "winrt_Windows.Devices.Bluetooth.GenericAttributeProfile-2.3.0-cp311-cp311-win_arm64.whl", hash = "sha256:7dccce04ec076666001efca8e2484d0ec444b2302ae150ef184aa253b8cfba09"},
{file = "winrt_Windows.Devices.Bluetooth.GenericAttributeProfile-2.3.0-cp312-cp312-win32.whl", hash = "sha256:1b97ef2ab9c9f5bae984989a47565d0d19c84969d74982a2664a4a3485cb8274"},
{file = "winrt_Windows.Devices.Bluetooth.GenericAttributeProfile-2.3.0-cp312-cp312-win_amd64.whl", hash = "sha256:5fac2c7b301fa70e105785d7504176c76e4d824fc3823afed4d1ab6a7682272c"},
{file = "winrt_Windows.Devices.Bluetooth.GenericAttributeProfile-2.3.0-cp312-cp312-win_arm64.whl", hash = "sha256:353fdccf2398b2a12e0835834cff8143a7efd9ba877fb5820fdcce531732b500"},
{file = "winrt_Windows.Devices.Bluetooth.GenericAttributeProfile-2.3.0-cp313-cp313-win32.whl", hash = "sha256:f414f793767ccc56d055b1c74830efb51fa4cbdc9163847b1a38b1ee35778f49"},
{file = "winrt_Windows.Devices.Bluetooth.GenericAttributeProfile-2.3.0-cp313-cp313-win_amd64.whl", hash = "sha256:8ef35d9cda5bbdcc55aa7eaf143ab873227d6ee467aaf28edbd2428f229e7c94"},
{file = "winrt_Windows.Devices.Bluetooth.GenericAttributeProfile-2.3.0-cp313-cp313-win_arm64.whl", hash = "sha256:6a9e7308ba264175c2a9ee31f6cf1d647cb35ee9a1da7350793d8fe033a6b9b8"},
{file = "winrt_Windows.Devices.Bluetooth.GenericAttributeProfile-2.3.0-cp39-cp39-win32.whl", hash = "sha256:aea58f7e484cf3480ab9472a3e99b61c157b8a47baae8694bc7400ea5335f5dc"},
{file = "winrt_Windows.Devices.Bluetooth.GenericAttributeProfile-2.3.0-cp39-cp39-win_amd64.whl", hash = "sha256:992b792a9e7f5771ccdc18eec4e526a11f23b75d9be5de3ec552ff719333897a"},
{file = "winrt_Windows.Devices.Bluetooth.GenericAttributeProfile-2.3.0-cp39-cp39-win_arm64.whl", hash = "sha256:66b030a9cc6099dafe4253239e8e625cc063bb9bb115bebed6260d92dd86f6b1"},
{file = "winrt_windows_devices_bluetooth_genericattributeprofile-2.3.0.tar.gz", hash = "sha256:f40f94bf2f7243848dc10e39cfde76c9044727a05e7e5dfb8cb7f062f3fd3dda"},
]
[package.dependencies]
winrt-runtime = "2.3.0"
[package.extras]
all = ["winrt-Windows.Devices.Bluetooth[all] (==2.3.0)", "winrt-Windows.Devices.Enumeration[all] (==2.3.0)", "winrt-Windows.Foundation.Collections[all] (==2.3.0)", "winrt-Windows.Foundation[all] (==2.3.0)", "winrt-Windows.Storage.Streams[all] (==2.3.0)"]
[[package]]
name = "winrt-windows-devices-enumeration"
version = "2.3.0"
description = "Python projection of Windows Runtime (WinRT) APIs"
optional = false
python-versions = "<3.14,>=3.9"
groups = ["main"]
markers = "platform_system == \"Windows\" and python_version >= \"3.12\""
files = [
{file = "winrt_Windows.Devices.Enumeration-2.3.0-cp310-cp310-win32.whl", hash = "sha256:461360ab47967f39721e71276fdcfe87ad2f71ba7b09d721f2f88bcdf16a6924"},
{file = "winrt_Windows.Devices.Enumeration-2.3.0-cp310-cp310-win_amd64.whl", hash = "sha256:a7d7b01d43d5dcc1f3846db12f4c552155efae75469f36052623faed7f0f74a8"},
{file = "winrt_Windows.Devices.Enumeration-2.3.0-cp310-cp310-win_arm64.whl", hash = "sha256:6478fbe6f45172a9911c15b061ec9b0f30c9f4845ba3fd1e9e1bb78c1fb691c4"},
{file = "winrt_Windows.Devices.Enumeration-2.3.0-cp311-cp311-win32.whl", hash = "sha256:30be5cba8e9e81ea8dd514ba1300b5bb14ad7cc4e32efe908ddddd14c73e7f61"},
{file = "winrt_Windows.Devices.Enumeration-2.3.0-cp311-cp311-win_amd64.whl", hash = "sha256:86c2a1865e0a0146dd4f51f17e3d773d3e6732742f61838c05061f28738c6dbd"},
{file = "winrt_Windows.Devices.Enumeration-2.3.0-cp311-cp311-win_arm64.whl", hash = "sha256:1b50d9304e49a9f04bc8139831b75be968ff19a1f50529d5eb0081dae2103d92"},
{file = "winrt_Windows.Devices.Enumeration-2.3.0-cp312-cp312-win32.whl", hash = "sha256:42ed0349f0290a1b0a101425a06196c5d5db1240db6f8bd7d2204f23c48d727b"},
{file = "winrt_Windows.Devices.Enumeration-2.3.0-cp312-cp312-win_amd64.whl", hash = "sha256:83e385fbf85b9511699d33c659673611f42b98bd3a554a85b377a34cc3b68b2e"},
{file = "winrt_Windows.Devices.Enumeration-2.3.0-cp312-cp312-win_arm64.whl", hash = "sha256:26f855caee61c12449c6b07e22ea1ad470f8daa24223d8581e1fe622c70b48a8"},
{file = "winrt_Windows.Devices.Enumeration-2.3.0-cp313-cp313-win32.whl", hash = "sha256:a5f2cff6ee584e5627a2246bdbcd1b3a3fd1e7ae0741f62c59f7d5a5650d5791"},
{file = "winrt_Windows.Devices.Enumeration-2.3.0-cp313-cp313-win_amd64.whl", hash = "sha256:7516171521aa383ccdc8f422cc202979a2359d0d1256f22852bfb0b55d9154f0"},
{file = "winrt_Windows.Devices.Enumeration-2.3.0-cp313-cp313-win_arm64.whl", hash = "sha256:80d01dfffe4b548439242f3f7a737189354768b203cca023dc29b267dfe5595a"},
{file = "winrt_Windows.Devices.Enumeration-2.3.0-cp39-cp39-win32.whl", hash = "sha256:990a375cd8edc2d30b939a49dcc1349ede3a4b8e4da78baf0de5e5711d3a4f00"},
{file = "winrt_Windows.Devices.Enumeration-2.3.0-cp39-cp39-win_amd64.whl", hash = "sha256:2e7bedf0eac2066d7d37b1d34071b95bb57024e9e083867be1d24e916e012ac0"},
{file = "winrt_Windows.Devices.Enumeration-2.3.0-cp39-cp39-win_arm64.whl", hash = "sha256:c53b673b80ba794f1c1320a5e0a14d795193c3f64b8132ebafba2f49c7301c2f"},
{file = "winrt_windows_devices_enumeration-2.3.0.tar.gz", hash = "sha256:a14078aac41432781acb0c950fcdcdeb096e2f80f7591a3d46435f30221fc3eb"},
]
[package.dependencies]
winrt-runtime = "2.3.0"
[package.extras]
all = ["winrt-Windows.ApplicationModel.Background[all] (==2.3.0)", "winrt-Windows.Foundation.Collections[all] (==2.3.0)", "winrt-Windows.Foundation[all] (==2.3.0)", "winrt-Windows.Security.Credentials[all] (==2.3.0)", "winrt-Windows.Storage.Streams[all] (==2.3.0)", "winrt-Windows.UI.Popups[all] (==2.3.0)", "winrt-Windows.UI[all] (==2.3.0)"]
[[package]]
name = "winrt-windows-foundation"
version = "2.3.0"
description = "Python projection of Windows Runtime (WinRT) APIs"
optional = false
python-versions = "<3.14,>=3.9"
groups = ["main"]
markers = "platform_system == \"Windows\" and python_version >= \"3.12\""
files = [
{file = "winrt_Windows.Foundation-2.3.0-cp310-cp310-win32.whl", hash = "sha256:ea7b0e82be5c05690fedaf0dac5aa5e5fefd7ebf90b1497e5993197d305d916d"},
{file = "winrt_Windows.Foundation-2.3.0-cp310-cp310-win_amd64.whl", hash = "sha256:6807dd40f8ecd6403679f6eae0db81674fdcf33768d08fdee66e0a17b7a02515"},
{file = "winrt_Windows.Foundation-2.3.0-cp310-cp310-win_arm64.whl", hash = "sha256:0a861815e97ace82583210c03cf800507b0c3a97edd914bfffa5f88de1fbafcc"},
{file = "winrt_Windows.Foundation-2.3.0-cp311-cp311-win32.whl", hash = "sha256:c79b3d9384128b6b28c2483b4600f15c5d32c1f6646f9d77fdb3ee9bbaef6f81"},
{file = "winrt_Windows.Foundation-2.3.0-cp311-cp311-win_amd64.whl", hash = "sha256:fdd9c4914070dc598f5961d9c7571dd7d745f5cc60347603bf39d6ee921bd85c"},
{file = "winrt_Windows.Foundation-2.3.0-cp311-cp311-win_arm64.whl", hash = "sha256:62bbb0ffa273551d33fd533d6e09b6f9f633dc214225d483722af47d2525fb84"},
{file = "winrt_Windows.Foundation-2.3.0-cp312-cp312-win32.whl", hash = "sha256:d36f472ac258e79eee6061e1bb4ce50bfd200f9271392d23479c800ca6aee8d1"},
{file = "winrt_Windows.Foundation-2.3.0-cp312-cp312-win_amd64.whl", hash = "sha256:8de9b5e95a3fdabdb45b1952e05355dd5a678f80bf09a54d9f966dccc805b383"},
{file = "winrt_Windows.Foundation-2.3.0-cp312-cp312-win_arm64.whl", hash = "sha256:37da09c08c9c772baedb1958e5ee116fe63809f33c6820c69750f340b3dda292"},
{file = "winrt_Windows.Foundation-2.3.0-cp313-cp313-win32.whl", hash = "sha256:2b00fad3f2a3859ccae41eee12ab44434813a371c2f3003b4f2419e5eecb4832"},
{file = "winrt_Windows.Foundation-2.3.0-cp313-cp313-win_amd64.whl", hash = "sha256:686619932b2a2c689cbebc7f5196437a45fd2056656ef130bb10240bb111086a"},
{file = "winrt_Windows.Foundation-2.3.0-cp313-cp313-win_arm64.whl", hash = "sha256:b38dcb83fe82a7da9a57d7d5ad5deb09503b5be6d9357a9fd3016ca31673805d"},
{file = "winrt_Windows.Foundation-2.3.0-cp39-cp39-win32.whl", hash = "sha256:2d6922de4dc38061b86d314c7319d7c6bd78a52d64ee0c93eb81474bddb499bc"},
{file = "winrt_Windows.Foundation-2.3.0-cp39-cp39-win_amd64.whl", hash = "sha256:1513e43adff3779d2f611d8bdf9350ac1a7c04389e9e6b1d777c5cd54f46e4fc"},
{file = "winrt_Windows.Foundation-2.3.0-cp39-cp39-win_arm64.whl", hash = "sha256:c811e4a4f79b947fbbb50f74d34ef6840dd2dd26e0199bd61a4185e48c6a84a8"},
{file = "winrt_windows_foundation-2.3.0.tar.gz", hash = "sha256:c5766f011c8debbe89b460af4a97d026ca252144e62d7278c9c79c5581ea0c02"},
]
[package.dependencies]
winrt-runtime = "2.3.0"
[package.extras]
all = ["winrt-Windows.Foundation.Collections[all] (==2.3.0)"]
[[package]]
name = "winrt-windows-foundation-collections"
version = "2.3.0"
description = "Python projection of Windows Runtime (WinRT) APIs"
optional = false
python-versions = "<3.14,>=3.9"
groups = ["main"]
markers = "platform_system == \"Windows\" and python_version >= \"3.12\""
files = [
{file = "winrt_Windows.Foundation.Collections-2.3.0-cp310-cp310-win32.whl", hash = "sha256:d2fca59eef9582a33c2797b1fda1d5757d66827cc34e6fc1d1c94a5875c4c043"},
{file = "winrt_Windows.Foundation.Collections-2.3.0-cp310-cp310-win_amd64.whl", hash = "sha256:d14b47d9137aebad71aa4fde5892673f2fa326f5f4799378cb9f6158b07a9824"},
{file = "winrt_Windows.Foundation.Collections-2.3.0-cp310-cp310-win_arm64.whl", hash = "sha256:cca5398a4522dffd76decf64a28368cda67e81dc01cad35a9f39cc351af69bdd"},
{file = "winrt_Windows.Foundation.Collections-2.3.0-cp311-cp311-win32.whl", hash = "sha256:3808af64c95a9b464e8e97f6bec57a8b22168185f1c893f30de69aaf48c85b17"},
{file = "winrt_Windows.Foundation.Collections-2.3.0-cp311-cp311-win_amd64.whl", hash = "sha256:1e9a3842a39feb965545124abfe79ed726adc5a1fc6a192470a3c5d3ec3f7a74"},
{file = "winrt_Windows.Foundation.Collections-2.3.0-cp311-cp311-win_arm64.whl", hash = "sha256:751c2a68fef080dfe0af892ef4cebf317844e4baa786e979028757fe2740fba4"},
{file = "winrt_Windows.Foundation.Collections-2.3.0-cp312-cp312-win32.whl", hash = "sha256:498c1fc403d3dc7a091aaac92af471615de4f9550d544347cb3b169c197183b5"},
{file = "winrt_Windows.Foundation.Collections-2.3.0-cp312-cp312-win_amd64.whl", hash = "sha256:4d1b1cacc159f38d8e6b662f6e7a5c41879a36aa7434c1580d7f948c9037419e"},
{file = "winrt_Windows.Foundation.Collections-2.3.0-cp312-cp312-win_arm64.whl", hash = "sha256:398d93b76a2cf70d5e75c1f802e1dd856501e63bc9a31f4510ac59f718951b9e"},
{file = "winrt_Windows.Foundation.Collections-2.3.0-cp313-cp313-win32.whl", hash = "sha256:1e5f1637e0919c7bb5b11ba1eebbd43bc0ad9600cf887b59fcece0f8a6c0eac3"},
{file = "winrt_Windows.Foundation.Collections-2.3.0-cp313-cp313-win_amd64.whl", hash = "sha256:c809a70bc0f93d53c7289a0a86d8869740e09fff0c57318a14401f5c17e0b912"},
{file = "winrt_Windows.Foundation.Collections-2.3.0-cp313-cp313-win_arm64.whl", hash = "sha256:269942fe86af06293a2676c8b2dcd5cb1d8ddfe1b5244f11c16e48ae0a5d100f"},
{file = "winrt_Windows.Foundation.Collections-2.3.0-cp39-cp39-win32.whl", hash = "sha256:936b1c5720b564ec699673198addee97f3bdb790622d24c8fd1b346a9767717c"},
{file = "winrt_Windows.Foundation.Collections-2.3.0-cp39-cp39-win_amd64.whl", hash = "sha256:905a6ac9cd6b51659a9bba08cf44cfc925f528ef34cdd9c3a6c2632e97804a96"},
{file = "winrt_Windows.Foundation.Collections-2.3.0-cp39-cp39-win_arm64.whl", hash = "sha256:1d6eac85976bd831e1b8cc479d7f14afa51c27cec5a38e2540077d3400cbd3ef"},
{file = "winrt_windows_foundation_collections-2.3.0.tar.gz", hash = "sha256:15c997fd6b64ef0400a619319ea3c6851c9c24e31d51b6448ba9bac3616d25a0"},
]
[package.dependencies]
winrt-runtime = "2.3.0"
[package.extras]
all = ["winrt-Windows.Foundation[all] (==2.3.0)"]
[[package]]
name = "winrt-windows-storage-streams"
version = "2.3.0"
description = "Python projection of Windows Runtime (WinRT) APIs"
optional = false
python-versions = "<3.14,>=3.9"
groups = ["main"]
markers = "platform_system == \"Windows\" and python_version >= \"3.12\""
files = [
{file = "winrt_Windows.Storage.Streams-2.3.0-cp310-cp310-win32.whl", hash = "sha256:2c0901aee1232e92ed9320644b853d7801a0bdb87790164d56e961cd39910f07"},
{file = "winrt_Windows.Storage.Streams-2.3.0-cp310-cp310-win_amd64.whl", hash = "sha256:ba07dc25decffd29aa8603119629c167bd03fa274099e3bad331a4920c292b78"},
{file = "winrt_Windows.Storage.Streams-2.3.0-cp310-cp310-win_arm64.whl", hash = "sha256:5b60b48460095c50a00a6f7f9b3b780f5bdcb1ec663fc09458201499f93e23ea"},
{file = "winrt_Windows.Storage.Streams-2.3.0-cp311-cp311-win32.whl", hash = "sha256:8388f37759df64ceef1423ae7dd9275c8a6eb3b8245d400173b4916adc94b5ad"},
{file = "winrt_Windows.Storage.Streams-2.3.0-cp311-cp311-win_amd64.whl", hash = "sha256:e5783dbe3694cc3deda594256ebb1088655386959bb834a6bfb7cd763ee87631"},
{file = "winrt_Windows.Storage.Streams-2.3.0-cp311-cp311-win_arm64.whl", hash = "sha256:0a487d19c73b82aafa3d5ef889bb35e6e8e2487ca4f16f5446f2445033d5219c"},
{file = "winrt_Windows.Storage.Streams-2.3.0-cp312-cp312-win32.whl", hash = "sha256:272e87e6c74cb2832261ab33db7966a99e7a2400240cc4f8bf526a80ca054c68"},
{file = "winrt_Windows.Storage.Streams-2.3.0-cp312-cp312-win_amd64.whl", hash = "sha256:997bf1a2d52c5f104b172947e571f27d9916a4409b4da592ec3e7f907848dd1a"},
{file = "winrt_Windows.Storage.Streams-2.3.0-cp312-cp312-win_arm64.whl", hash = "sha256:d56daa00205c24ede6669d41eb70d6017e0202371d99f8ee2b0b31350ab59bd5"},
{file = "winrt_Windows.Storage.Streams-2.3.0-cp313-cp313-win32.whl", hash = "sha256:7ac4e46fc5e21d8badc5d41779273c3f5e7196f1cf2df1959b6b70eca1d5d85f"},
{file = "winrt_Windows.Storage.Streams-2.3.0-cp313-cp313-win_amd64.whl", hash = "sha256:1460027c94c107fcee484997494f3a400f08ee40396f010facb0e72b3b74c457"},
{file = "winrt_Windows.Storage.Streams-2.3.0-cp313-cp313-win_arm64.whl", hash = "sha256:e4553a70f5264a7733596802a2991e2414cdcd5e396b9d11ee87be9abae9329e"},
{file = "winrt_Windows.Storage.Streams-2.3.0-cp39-cp39-win32.whl", hash = "sha256:28e1117e23046e499831af16d11f5e61e6066ed6247ef58b93738702522c29b0"},
{file = "winrt_Windows.Storage.Streams-2.3.0-cp39-cp39-win_amd64.whl", hash = "sha256:5511dc578f92eb303aee4d3345ee4ffc88aa414564e43e0e3d84ff29427068f0"},
{file = "winrt_Windows.Storage.Streams-2.3.0-cp39-cp39-win_arm64.whl", hash = "sha256:6f5b3f8af4df08f5bf9329373949236ffaef22d021070278795e56da5326a876"},
{file = "winrt_windows_storage_streams-2.3.0.tar.gz", hash = "sha256:d2c010beeb1dd7c135ed67ecfaea13440474a7c469e2e9aa2852db27d2063d44"},
]
[package.dependencies]
winrt-runtime = "2.3.0"
[package.extras]
all = ["winrt-Windows.Foundation.Collections[all] (==2.3.0)", "winrt-Windows.Foundation[all] (==2.3.0)", "winrt-Windows.Storage[all] (==2.3.0)", "winrt-Windows.System[all] (==2.3.0)"]
[metadata]
lock-version = "2.1"
python-versions = ">=3.11,<3.14"
content-hash = "71ed56d737b9f7b0f57abc6bca7d345bfa716d1721609ba1bbf51ed4954fa19e"
habluetooth-3.48.2/pyproject.toml 0000664 0000000 0000000 00000007700 15005442573 0017053 0 ustar 00root root 0000000 0000000 [build-system]
requires = ['setuptools>=77.0', 'Cython>=3,<3.1', "poetry-core>=2.0.0"]
build-backend = "poetry.core.masonry.api"
[project]
name = "habluetooth"
version = "3.48.2"
license = "Apache-2.0"
description = "High availability Bluetooth"
authors = [{ name = "J. Nick Koston", email = "bluetooth@koston.org" }]
readme = "README.md"
requires-python = ">=3.11"
[project.urls]
"Repository" = "https://github.com/bluetooth-devices/habluetooth"
"Documentation" = "https://habluetooth.readthedocs.io"
"Bug Tracker" = "https://github.com/bluetooth-devices/habluetooth/issues"
"Changelog" = "https://github.com/bluetooth-devices/habluetooth/blob/main/CHANGELOG.md"
[tool.poetry]
classifiers = [
"Development Status :: 2 - Pre-Alpha",
"Intended Audience :: Developers",
"Natural Language :: English",
"Operating System :: OS Independent",
"Topic :: Software Development :: Libraries",
]
packages = [
{ include = "habluetooth", from = "src" },
]
[tool.poetry.build]
generate-setup-file = true
script = "build_ext.py"
[tool.poetry.dependencies]
python = ">=3.11,<3.14"
bleak = ">=0.21.1"
bleak-retry-connector = ">=3.9.0"
bluetooth-data-tools = ">=1.28.0"
bluetooth-adapters = ">=0.16.1"
bluetooth-auto-recovery = ">=1.5.1"
async-interrupt = ">=1.1.1"
dbus-fast = { version = ">=2.30.2", markers = "platform_system == 'Linux'" }
[tool.poetry.group.dev.dependencies]
pytest = ">=7,<9"
pytest-cov = ">=3,<7"
pytest-asyncio = ">=0.23.6,<0.27.0"
pytest-codspeed = ">=2.2.1,<4.0.0"
freezegun = "^1.5.1"
dbus-fast = ">=2.30.2"
[tool.poetry.group.docs]
optional = true
[tool.poetry.group.docs.dependencies]
myst-parser = ">=0.16"
sphinx = ">=4.0"
furo = ">=2023.5.20"
sphinx-autobuild = ">=2021.3.14"
[tool.semantic_release]
version_toml = ["pyproject.toml:project.version"]
version_variables = [
"src/habluetooth/__init__.py:__version__",
"docs/conf.py:release",
]
build_command = "pip install poetry && poetry build"
[tool.semantic_release.changelog]
exclude_commit_patterns = [
"chore*",
"ci*",
]
[tool.semantic_release.changelog.environment]
keep_trailing_newline = true
[tool.semantic_release.branches.main]
match = "main"
[tool.semantic_release.branches.noop]
match = "(?!main$)"
prerelease = true
[tool.pytest.ini_options]
addopts = "-v -Wdefault --cov=habluetooth --cov-report=term-missing:skip-covered"
pythonpath = ["src"]
log_cli="true"
log_level="NOTSET"
[tool.coverage.run]
branch = true
[tool.coverage.report]
exclude_lines = [
"pragma: no cover",
"@overload",
"if TYPE_CHECKING",
"raise NotImplementedError",
'if __name__ == "__main__":',
]
[tool.ruff]
target-version = "py38"
line-length = 88
ignore = [
"E721", # type checks for cython
"D203", # 1 blank line required before class docstring
"D212", # Multi-line docstring summary should start at the first line
"D100", # Missing docstring in public module
"D104", # Missing docstring in public package
"D107", # Missing docstring in `__init__`
"D401", # First line of docstring should be in imperative mood
]
select = [
"B", # flake8-bugbear
"D", # flake8-docstrings
"C4", # flake8-comprehensions
"S", # flake8-bandit
"F", # pyflake
"E", # pycodestyle
"W", # pycodestyle
"UP", # pyupgrade
"I", # isort
"RUF", # ruff specific
]
[tool.ruff.per-file-ignores]
"tests/**/*" = [
"D100",
"D101",
"D102",
"D103",
"D104",
"S101",
]
"setup.py" = ["D100"]
"conftest.py" = ["D100"]
"docs/conf.py" = ["D100"]
[tool.ruff.isort]
known-first-party = ["habluetooth", "tests"]
[tool.mypy]
check_untyped_defs = true
disallow_any_generics = true
disallow_incomplete_defs = true
disallow_untyped_defs = true
mypy_path = "src/"
no_implicit_optional = true
show_error_codes = true
warn_unreachable = true
warn_unused_ignores = true
exclude = [
'docs/.*',
'setup.py',
]
[[tool.mypy.overrides]]
module = "tests.*"
allow_untyped_defs = true
[[tool.mypy.overrides]]
module = "docs.*"
ignore_errors = true
habluetooth-3.48.2/renovate.json 0000664 0000000 0000000 00000000101 15005442573 0016641 0 ustar 00root root 0000000 0000000 {
"extends": ["github>browniebroke/renovate-configs:python"]
}
habluetooth-3.48.2/src/ 0000775 0000000 0000000 00000000000 15005442573 0014722 5 ustar 00root root 0000000 0000000 habluetooth-3.48.2/src/habluetooth/ 0000775 0000000 0000000 00000000000 15005442573 0017240 5 ustar 00root root 0000000 0000000 habluetooth-3.48.2/src/habluetooth/__init__.py 0000664 0000000 0000000 00000004343 15005442573 0021355 0 ustar 00root root 0000000 0000000 __version__ = "3.48.2"
from .advertisement_tracker import (
TRACKER_BUFFERING_WOBBLE_SECONDS,
AdvertisementTracker,
)
from .base_scanner import BaseHaRemoteScanner, BaseHaScanner
from .central_manager import get_manager, set_manager
from .const import (
CONNECTABLE_FALLBACK_MAXIMUM_STALE_ADVERTISEMENT_SECONDS,
FALLBACK_MAXIMUM_STALE_ADVERTISEMENT_SECONDS,
SCANNER_WATCHDOG_INTERVAL,
SCANNER_WATCHDOG_TIMEOUT,
UNAVAILABLE_TRACK_SECONDS,
)
from .manager import BluetoothManager
from .models import (
BluetoothServiceInfo,
BluetoothServiceInfoBleak,
HaBluetoothConnector,
HaBluetoothSlotAllocations,
HaScannerDetails,
HaScannerRegistration,
HaScannerRegistrationEvent,
)
from .scanner import BluetoothScanningMode, HaScanner, ScannerStartError
from .scanner_device import BluetoothScannerDevice
from .storage import (
DiscoveredDeviceAdvertisementData,
DiscoveredDeviceAdvertisementDataDict,
DiscoveryStorageType,
discovered_device_advertisement_data_from_dict,
discovered_device_advertisement_data_to_dict,
expire_stale_scanner_discovered_device_advertisement_data,
)
from .wrappers import HaBleakClientWrapper, HaBleakScannerWrapper
__all__ = [
"CONNECTABLE_FALLBACK_MAXIMUM_STALE_ADVERTISEMENT_SECONDS",
"FALLBACK_MAXIMUM_STALE_ADVERTISEMENT_SECONDS",
"SCANNER_WATCHDOG_INTERVAL",
"SCANNER_WATCHDOG_TIMEOUT",
"TRACKER_BUFFERING_WOBBLE_SECONDS",
"UNAVAILABLE_TRACK_SECONDS",
"AdvertisementTracker",
"BaseHaRemoteScanner",
"BaseHaScanner",
"BluetoothManager",
"BluetoothScannerDevice",
"BluetoothScanningMode",
"BluetoothServiceInfo",
"BluetoothServiceInfoBleak",
"DiscoveredDeviceAdvertisementData",
"DiscoveredDeviceAdvertisementDataDict",
"DiscoveryStorageType",
"HaBleakClientWrapper",
"HaBleakScannerWrapper",
"HaBluetoothConnector",
"HaBluetoothSlotAllocations",
"HaScanner",
"HaScannerDetails",
"HaScannerRegistration",
"HaScannerRegistrationEvent",
"ScannerStartError",
"discovered_device_advertisement_data_from_dict",
"discovered_device_advertisement_data_to_dict",
"expire_stale_scanner_discovered_device_advertisement_data",
"get_manager",
"set_manager",
]
habluetooth-3.48.2/src/habluetooth/advertisement_tracker.pxd 0000664 0000000 0000000 00000000670 15005442573 0024345 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)
habluetooth-3.48.2/src/habluetooth/advertisement_tracker.py 0000664 0000000 0000000 00000005626 15005442573 0024210 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)
habluetooth-3.48.2/src/habluetooth/base_scanner.pxd 0000664 0000000 0000000 00000007324 15005442573 0022406 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 _connect_failures
cdef public dict _connect_in_progress
cpdef void _clear_connection_history(self) except *
cpdef void _finished_connecting(self, str address, bint connected) except *
cdef void _increase_count(self, dict target, str address) except *
cdef void _add_connect_failure(self, str address) except *
cpdef void _add_connecting(self, str address) except *
cdef void _remove_connecting(self, str address) except *
cdef void _clear_connect_failure(self, str address) except *
@cython.locals(
in_progress=Py_ssize_t,
count=Py_ssize_t
)
cpdef _connections_in_progress(self)
cpdef _connection_failures(self, str address)
@cython.locals(
score=double,
scanner_connections_in_progress=Py_ssize_t,
previous_failures=Py_ssize_t
)
cpdef _score_connection_paths(self, int rssi_diff, object scanner_device)
cpdef tuple get_discovered_device_advertisement_data(self, str address)
cpdef float time_since_last_detection(self)
cdef class BaseHaRemoteScanner(BaseHaScanner):
cdef public dict _details
cdef public double _expire_seconds
cdef public object _cancel_track
cdef public dict _previous_service_info
@cython.locals(parsed=tuple)
cpdef void _async_on_raw_advertisement(
self,
str address,
int rssi,
bytes raw,
dict details,
double advertisement_monotonic_time
)
@cython.locals(
prev_name=str,
prev_discovery=tuple,
has_local_name=bint,
has_manufacturer_data=bint,
has_service_data=bint,
has_service_uuids=bint,
sub_value=bytes,
super_value=bytes,
info=BluetoothServiceInfoBleak,
prev_info=BluetoothServiceInfoBleak
)
cdef void _async_on_advertisement_internal(
self,
str address,
int rssi,
str local_name,
list service_uuids,
dict service_data,
dict manufacturer_data,
object tx_power,
dict details,
double advertisement_monotonic_time,
bytes raw
)
cpdef void _async_on_advertisement(
self,
str address,
int rssi,
str local_name,
list service_uuids,
dict service_data,
dict manufacturer_data,
object tx_power,
dict details,
double advertisement_monotonic_time
)
@cython.locals(now=double, timestamp=double, info=BluetoothServiceInfoBleak)
cpdef void _async_expire_devices(self)
cpdef void _schedule_expire_devices(self)
@cython.locals(info=BluetoothServiceInfoBleak)
cpdef tuple get_discovered_device_advertisement_data(self, str address)
@cython.locals(info=BluetoothServiceInfoBleak)
cdef dict _build_discovered_device_advertisement_datas(self)
@cython.locals(info=BluetoothServiceInfoBleak)
cdef dict _build_discovered_device_timestamps(self)
habluetooth-3.48.2/src/habluetooth/base_scanner.py 0000664 0000000 0000000 00000057001 15005442573 0022240 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
from contextlib import contextmanager
from typing import TYPE_CHECKING, Any, Final, Iterable, final
from bleak.backends.device import BLEDevice
from bleak.backends.scanner import AdvertisementData
from bleak_retry_connector import NO_RSSI_VALUE
from bluetooth_adapters import adapter_human_name
from bluetooth_data_tools import monotonic_time_coarse, parse_advertisement_data_bytes
from .central_manager import get_manager
from .const import (
CALLBACK_TYPE,
CONNECTABLE_FALLBACK_MAXIMUM_STALE_ADVERTISEMENT_SECONDS,
SCANNER_WATCHDOG_INTERVAL,
SCANNER_WATCHDOG_TIMEOUT,
)
from .models import (
BluetoothScanningMode,
BluetoothServiceInfoBleak,
HaBluetoothConnector,
HaScannerDetails,
)
from .scanner_device import BluetoothScannerDevice
from .storage import DiscoveredDeviceAdvertisementData
SCANNER_WATCHDOG_INTERVAL_SECONDS: Final = SCANNER_WATCHDOG_INTERVAL.total_seconds()
_LOGGER = logging.getLogger(__name__)
_bytes = bytes
_float = float
_int = int
_str = str
class BaseHaScanner:
"""Base class for high availability BLE scanners."""
__slots__ = (
"_cancel_watchdog",
"_connect_failures",
"_connect_in_progress",
"_connecting",
"_last_detection",
"_loop",
"_manager",
"_start_time",
"adapter",
"connectable",
"connector",
"current_mode",
"details",
"name",
"requested_mode",
"scanning",
"source",
)
def __init__(
self,
source: str,
adapter: str,
connector: HaBluetoothConnector | None = None,
connectable: bool = False,
requested_mode: BluetoothScanningMode | None = None,
current_mode: BluetoothScanningMode | None = None,
) -> None:
"""Initialize the scanner."""
self.connectable = connectable
self.source = source
self.connector = connector
self._connecting = 0
self.adapter = adapter
self.name = adapter_human_name(adapter, source) if adapter != source else source
self.scanning: bool = True
self.requested_mode = requested_mode
self.current_mode = current_mode
self._last_detection = 0.0
self._start_time = 0.0
self._cancel_watchdog: asyncio.TimerHandle | None = None
self._loop: asyncio.AbstractEventLoop | None = None
self._manager = get_manager()
self.details = HaScannerDetails(
source=self.source,
connectable=self.connectable,
name=self.name,
adapter=self.adapter,
)
self._connect_failures: dict[str, int] = {}
self._connect_in_progress: dict[str, int] = {}
def _clear_connection_history(self) -> None:
"""Clear the connection history for a scanner."""
self._connect_failures.clear()
self._connect_in_progress.clear()
def _finished_connecting(self, address: str, connected: bool) -> None:
"""Finished connecting."""
self._remove_connecting(address)
if connected:
self._clear_connect_failure(address)
else:
self._add_connect_failure(address)
def _increase_count(self, target: dict[str, int], address: str) -> None:
"""Increase the reference count."""
if address in target:
target[address] += 1
else:
target[address] = 1
def _add_connect_failure(self, address: str) -> None:
"""Add a connect failure."""
self._increase_count(self._connect_failures, address)
def _add_connecting(self, address: str) -> None:
"""Add a connecting."""
self._increase_count(self._connect_in_progress, address)
def _remove_connecting(self, address: str) -> None:
"""Remove a connecting."""
if address not in self._connect_in_progress:
_LOGGER.warning(
"Removing a non-existing connecting %s %s", self.name, address
)
return
self._connect_in_progress[address] -= 1
if not self._connect_in_progress[address]:
del self._connect_in_progress[address]
def _clear_connect_failure(self, address: str) -> None:
"""Clear a connect failure."""
self._connect_failures.pop(address, None)
def _score_connection_paths(
self, rssi_diff: _int, scanner_device: BluetoothScannerDevice
) -> float:
"""Score the connection paths."""
address = scanner_device.ble_device.address
score = scanner_device.advertisement.rssi or NO_RSSI_VALUE
scanner_connections_in_progress = len(self._connect_in_progress)
previous_failures = self._connect_failures.get(address, 0)
if scanner_connections_in_progress:
# Very large penalty for multiple connections in progress
# to avoid overloading the adapter
score -= rssi_diff * scanner_connections_in_progress * 1.01
if previous_failures:
score -= rssi_diff * previous_failures * 0.51
return score
def _connections_in_progress(self) -> int:
"""Return if the connection is in progress."""
in_progress = 0
for count in self._connect_in_progress.values():
in_progress += count
return in_progress
def _connection_failures(self, address: str) -> int:
"""Return the number of failures."""
return self._connect_failures.get(address, 0)
def time_since_last_detection(self) -> float:
"""Return the time since the last detection."""
return monotonic_time_coarse() - self._last_detection
def async_setup(self) -> CALLBACK_TYPE:
"""Set up the scanner."""
self._loop = asyncio.get_running_loop()
return self._unsetup
def _async_stop_scanner_watchdog(self) -> None:
"""Stop the scanner watchdog."""
if self._cancel_watchdog:
self._cancel_watchdog.cancel()
self._cancel_watchdog = None
def _async_setup_scanner_watchdog(self) -> None:
"""If something has restarted or updated, we need to restart the scanner."""
self._start_time = self._last_detection = monotonic_time_coarse()
if not self._cancel_watchdog:
self._schedule_watchdog()
def _schedule_watchdog(self) -> None:
"""Schedule the watchdog."""
loop = self._loop
if TYPE_CHECKING:
assert loop is not None
self._cancel_watchdog = loop.call_at(
loop.time() + SCANNER_WATCHDOG_INTERVAL_SECONDS,
self._async_call_scanner_watchdog,
)
@final
def _async_call_scanner_watchdog(self) -> None:
"""Call the scanner watchdog and schedule the next one."""
self._async_scanner_watchdog()
self._schedule_watchdog()
def _async_watchdog_triggered(self) -> bool:
"""Check if the watchdog has been triggered."""
time_since_last_detection = self.time_since_last_detection()
_LOGGER.debug(
"%s: Scanner watchdog time_since_last_detection: %s",
self.name,
time_since_last_detection,
)
return time_since_last_detection > SCANNER_WATCHDOG_TIMEOUT
def _async_scanner_watchdog(self) -> None:
"""
Check if the scanner is running.
Override this method if you need to do something else when the watchdog
is triggered.
"""
if self._async_watchdog_triggered():
_LOGGER.debug(
(
"%s: Bluetooth scanner has gone quiet for %ss, check logs on the"
" scanner device for more information"
),
self.name,
self.time_since_last_detection(),
)
self.scanning = False
return
self.scanning = not self._connecting
def _unsetup(self) -> None:
"""Unset up the scanner."""
@contextmanager
def connecting(self) -> Generator[None, None, None]:
"""Context manager to track connecting state."""
self._connecting += 1
self.scanning = not self._connecting
try:
yield
finally:
self._connecting -= 1
self.scanning = not self._connecting
@property
def discovered_devices(self) -> list[BLEDevice]:
"""Return a list of discovered devices."""
raise NotImplementedError
@property
def discovered_devices_and_advertisement_data(
self,
) -> dict[str, tuple[BLEDevice, AdvertisementData]]:
"""Return a list of discovered devices and their advertisement data."""
raise NotImplementedError
@property
def discovered_addresses(self) -> Iterable[str]:
"""Return an iterable of discovered devices."""
raise NotImplementedError
def get_discovered_device_advertisement_data(
self, address: str
) -> tuple[BLEDevice, AdvertisementData] | None:
"""Return the advertisement data for a discovered device."""
raise NotImplementedError
async def async_diagnostics(self) -> dict[str, Any]:
"""Return diagnostic information about the scanner."""
device_adv_datas = self.discovered_devices_and_advertisement_data.values()
return {
"name": self.name,
"connectable": self.connectable,
"start_time": self._start_time,
"source": self.source,
"scanning": self.scanning,
"requested_mode": self.requested_mode,
"current_mode": self.current_mode,
"type": self.__class__.__name__,
"last_detection": self._last_detection,
"monotonic_time": monotonic_time_coarse(),
"discovered_devices_and_advertisement_data": [
{
"name": device.name,
"address": device.address,
"rssi": advertisement_data.rssi,
"advertisement_data": advertisement_data,
"details": device.details,
}
for device, advertisement_data in device_adv_datas
],
}
class BaseHaRemoteScanner(BaseHaScanner):
"""Base class for a high availability remote BLE scanner."""
__slots__ = (
"_cancel_track",
"_details",
"_expire_seconds",
"_previous_service_info",
)
def __init__(
self,
scanner_id: str,
name: str,
connector: HaBluetoothConnector | None,
connectable: bool,
requested_mode: BluetoothScanningMode | None = None,
current_mode: BluetoothScanningMode | None = None,
) -> None:
"""Initialize the scanner."""
super().__init__(
scanner_id, name, connector, connectable, requested_mode, current_mode
)
self._details: dict[str, str | HaBluetoothConnector] = {"source": scanner_id}
# Scanners only care about connectable devices. The manager
# will handle taking care of availability for non-connectable devices
self._expire_seconds = CONNECTABLE_FALLBACK_MAXIMUM_STALE_ADVERTISEMENT_SECONDS
self._cancel_track: asyncio.TimerHandle | None = None
self._previous_service_info: dict[str, BluetoothServiceInfoBleak] = {}
def restore_discovered_devices(
self, history: DiscoveredDeviceAdvertisementData
) -> None:
"""Restore discovered devices from a previous run."""
discovered_device_timestamps = history.discovered_device_timestamps
self._previous_service_info = {
address: BluetoothServiceInfoBleak(
device.name or address,
address,
adv.rssi,
adv.manufacturer_data,
adv.service_data,
adv.service_uuids,
self.source,
device,
adv,
self.connectable,
discovered_device_timestamps[address],
adv.tx_power,
history.discovered_device_raw.get(address),
)
for address, (
device,
adv,
) in history.discovered_device_advertisement_datas.items()
}
# Expire anything that is too old
self._async_expire_devices()
def serialize_discovered_devices(
self,
) -> DiscoveredDeviceAdvertisementData:
"""Serialize discovered devices to be stored."""
return DiscoveredDeviceAdvertisementData(
self.connectable,
self._expire_seconds,
self._build_discovered_device_advertisement_datas(),
self._build_discovered_device_timestamps(),
self._build_discovered_device_raw(),
)
@property
def _discovered_device_timestamps(self) -> dict[str, float]:
"""Return a dict of discovered device timestamps."""
warnings.warn(
"BaseHaRemoteScanner._discovered_device_timestamps is deprecated "
"and will be removed in a future version of habluetooth, use "
"BaseHaRemoteScanner.discovered_device_timestamps instead",
FutureWarning,
stacklevel=2,
)
return self._build_discovered_device_timestamps()
@property
def discovered_device_timestamps(self) -> dict[str, float]:
"""Return a dict of discovered device timestamps."""
return self._build_discovered_device_timestamps()
def _build_discovered_device_advertisement_datas(
self,
) -> dict[str, tuple[BLEDevice, AdvertisementData]]:
"""Return a list of discovered devices and advertisement data."""
return {
address: (info.device, info._advertisement_internal())
for address, info in self._previous_service_info.items()
}
def _build_discovered_device_timestamps(self) -> dict[str, float]:
"""Return a dict of discovered device timestamps."""
return {
address: info.time for address, info in self._previous_service_info.items()
}
def _build_discovered_device_raw(self) -> dict[str, bytes | None]:
"""Return a dict of discovered device raw advertisement data."""
return {
address: info.raw for address, info in self._previous_service_info.items()
}
def _cancel_expire_devices(self) -> None:
"""Cancel the expiration of old devices."""
if self._cancel_track:
self._cancel_track.cancel()
self._cancel_track = None
def _unsetup(self) -> None:
"""Unset up the scanner."""
self._async_stop_scanner_watchdog()
self._cancel_expire_devices()
def async_setup(self) -> CALLBACK_TYPE:
"""Set up the scanner."""
super().async_setup()
self._schedule_expire_devices()
self._async_setup_scanner_watchdog()
return self._unsetup
def _schedule_expire_devices(self) -> None:
"""Schedule the expiration of old devices."""
loop = self._loop
if TYPE_CHECKING:
assert loop is not None
self._cancel_expire_devices()
self._cancel_track = loop.call_at(
loop.time() + 30, self._async_expire_devices_schedule_next
)
def _async_expire_devices_schedule_next(self) -> None:
"""Expire old devices and schedule the next expiration."""
self._async_expire_devices()
self._schedule_expire_devices()
def _async_expire_devices(self) -> None:
"""Expire old devices."""
now = monotonic_time_coarse()
expired = [
address
for address, info in self._previous_service_info.items()
if now - info.time > self._expire_seconds
]
for address in expired:
del self._previous_service_info[address]
@property
def discovered_devices(self) -> list[BLEDevice]:
"""Return a list of discovered devices."""
infos = self._previous_service_info.values()
return [device_advertisement_data.device for device_advertisement_data in infos]
@property
def discovered_devices_and_advertisement_data(
self,
) -> dict[str, tuple[BLEDevice, AdvertisementData]]:
"""Return a list of discovered devices and advertisement data."""
return self._build_discovered_device_advertisement_datas()
@property
def discovered_addresses(self) -> Iterable[str]:
"""Return an iterable of discovered devices."""
return self._previous_service_info
def get_discovered_device_advertisement_data(
self, address: str
) -> tuple[BLEDevice, AdvertisementData] | None:
"""Return the advertisement data for a discovered device."""
if (info := self._previous_service_info.get(address)) is not None:
return info.device, info.advertisement
return None
def _async_on_raw_advertisement(
self,
address: _str,
rssi: _int,
raw: _bytes,
details: dict[str, Any],
advertisement_monotonic_time: _float,
) -> None:
parsed = parse_advertisement_data_bytes(raw)
self._async_on_advertisement_internal(
address,
rssi,
parsed[0],
parsed[1],
parsed[2],
parsed[3],
parsed[4],
details,
advertisement_monotonic_time,
raw,
)
def _async_on_advertisement(
self,
address: _str,
rssi: _int,
local_name: _str | None,
service_uuids: list[str],
service_data: dict[str, bytes],
manufacturer_data: dict[int, bytes],
tx_power: _int | None,
details: dict[Any, Any],
advertisement_monotonic_time: _float,
) -> None:
self._async_on_advertisement_internal(
address,
rssi,
local_name,
service_uuids,
service_data,
manufacturer_data,
tx_power,
details,
advertisement_monotonic_time,
None,
)
def _async_on_advertisement_internal(
self,
address: _str,
rssi: _int,
local_name: _str | None,
service_uuids: list[str],
service_data: dict[str, bytes],
manufacturer_data: dict[int, bytes],
tx_power: _int | None,
details: dict[Any, Any],
advertisement_monotonic_time: _float,
raw: _bytes | None,
) -> None:
"""Call the registered callback."""
self.scanning = not self._connecting
self._last_detection = advertisement_monotonic_time
info = BluetoothServiceInfoBleak.__new__(BluetoothServiceInfoBleak)
if (prev_info := self._previous_service_info.get(address)) is None:
# We expect this is the rare case and since py3.11+ has
# near zero cost try on success, and we can avoid .get()
# which is slower than [] we use the try/except pattern.
info.device = BLEDevice(
address,
local_name,
{**self._details, **details},
rssi, # deprecated, will be removed in newer bleak
)
info.manufacturer_data = manufacturer_data
info.service_data = service_data
info.service_uuids = service_uuids
info.name = local_name or address
else:
# Merge the new data with the old data
# to function the same as BlueZ which
# merges the dicts on PropertiesChanged
info.device = prev_info.device
prev_name = prev_info.device.name
#
# Bleak updates the BLEDevice via create_or_update_device.
# We need to do the same to ensure integrations that already
# have the BLEDevice object get the updated details when they
# change.
#
# https://github.com/hbldh/bleak/blob/222618b7747f0467dbb32bd3679f8cfaa19b1668/bleak/backends/scanner.py#L203
#
# _rssi is deprecated, will be removed in newer bleak
# pylint: disable-next=protected-access
info.device._rssi = rssi
if prev_name is not None and (
prev_name is local_name
or not local_name
or len(prev_name) > len(local_name)
):
info.name = prev_name
else:
info.device.name = local_name
info.name = local_name if local_name else address
has_service_uuids = bool(service_uuids)
if (
has_service_uuids
and service_uuids is not prev_info.service_uuids
and service_uuids != prev_info.service_uuids
):
info.service_uuids = list({*service_uuids, *prev_info.service_uuids})
elif not has_service_uuids:
info.service_uuids = prev_info.service_uuids
else:
info.service_uuids = service_uuids
has_service_data = bool(service_data)
if has_service_data and service_data is not prev_info.service_data:
for uuid, sub_value in service_data.items():
if (
super_value := prev_info.service_data.get(uuid)
) is None or super_value != sub_value:
info.service_data = {
**prev_info.service_data,
**service_data,
}
break
else:
info.service_data = prev_info.service_data
elif not has_service_data:
info.service_data = prev_info.service_data
else:
info.service_data = service_data
has_manufacturer_data = bool(manufacturer_data)
if (
has_manufacturer_data
and manufacturer_data is not prev_info.manufacturer_data
):
for id_, sub_value in manufacturer_data.items():
if (
super_value := prev_info.manufacturer_data.get(id_)
) is None or super_value != sub_value:
info.manufacturer_data = {
**prev_info.manufacturer_data,
**manufacturer_data,
}
break
else:
info.manufacturer_data = prev_info.manufacturer_data
elif not has_manufacturer_data:
info.manufacturer_data = prev_info.manufacturer_data
else:
info.manufacturer_data = manufacturer_data
info.address = address
info.rssi = rssi
info.source = self.source
info._advertisement = None
info.connectable = self.connectable
info.time = advertisement_monotonic_time
info.tx_power = tx_power
info.raw = raw
self._previous_service_info[address] = info
self._manager.scanner_adv_received(info)
async def async_diagnostics(self) -> dict[str, Any]:
"""Return diagnostic information about the scanner."""
now = monotonic_time_coarse()
discovered_device_timestamps = self._build_discovered_device_timestamps()
return await super().async_diagnostics() | {
"discovered_device_timestamps": discovered_device_timestamps,
"time_since_last_device_detection": {
address: now - timestamp
for address, timestamp in discovered_device_timestamps.items()
},
}
habluetooth-3.48.2/src/habluetooth/central_manager.py 0000664 0000000 0000000 00000001213 15005442573 0022731 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
habluetooth-3.48.2/src/habluetooth/const.py 0000664 0000000 0000000 00000003732 15005442573 0020745 0 ustar 00root root 0000000 0000000 """Constants."""
from __future__ import annotations
from datetime import timedelta
from typing import Callable, Final
CALLBACK_TYPE = Callable[[], None]
SOURCE_LOCAL: Final = "local"
START_TIMEOUT = 15
STOP_TIMEOUT = 5
# The maximum time between advertisements for a device to be considered
# stale when the advertisement tracker cannot determine the interval.
#
# We have to set this quite high as we don't know
# when devices fall out of the ESPHome device (and other non-local scanners)'s
# stack like we do with BlueZ so its safer to assume its available
# since if it does go out of range and it is in range
# of another device the timeout is much shorter and it will
# switch over to using that adapter anyways.
#
FALLBACK_MAXIMUM_STALE_ADVERTISEMENT_SECONDS: Final = 60 * 15
# The maximum time between advertisements for a device to be considered
# stale when the advertisement tracker can determine the interval for
# connectable devices.
#
# BlueZ uses 180 seconds by default but we give it a bit more time
# to account for the esp32's bluetooth stack being a bit slower
# than BlueZ's.
CONNECTABLE_FALLBACK_MAXIMUM_STALE_ADVERTISEMENT_SECONDS: Final = 195
# We must recover before we hit the 180s mark
# where the device is removed from the stack
# or the devices will go unavailable. Since
# we only check every 30s, we need this number
# to be
# 180s Time when device is removed from stack
# - 30s check interval
# - 30s scanner restart time * 2
#
SCANNER_WATCHDOG_TIMEOUT: Final = 90
# How often to check if the scanner has reached
# the SCANNER_WATCHDOG_TIMEOUT without seeing anything
SCANNER_WATCHDOG_INTERVAL: Final = timedelta(seconds=30)
UNAVAILABLE_TRACK_SECONDS: Final = 60 * 5
FAILED_ADAPTER_MAC = "00:00:00:00:00:00"
ADV_RSSI_SWITCH_THRESHOLD: Final = 16
# The switch threshold for the rssi value
# to switch to a different adapter for advertisements
# Note that this does not affect the connection
# selection that uses RSSI_SWITCH_THRESHOLD from
# bleak_retry_connector
habluetooth-3.48.2/src/habluetooth/manager.pxd 0000664 0000000 0000000 00000005343 15005442573 0021374 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 object _subclass_discover_info
@cython.locals(stale_seconds=double)
cdef bint _prefer_previous_adv_from_different_source(
self,
BluetoothServiceInfoBleak old,
BluetoothServiceInfoBleak new
)
@cython.locals(
old_service_info=BluetoothServiceInfoBleak,
old_connectable_service_info=BluetoothServiceInfoBleak,
source=str,
connectable=bint,
scanner=BaseHaScanner,
connectable_scanner=BaseHaScanner,
apple_cstr="const unsigned char *",
bleak_callback=BleakCallback
)
cpdef void scanner_adv_received(self, BluetoothServiceInfoBleak service_info)
cpdef _async_describe_source(self, BluetoothServiceInfoBleak service_info)
habluetooth-3.48.2/src/habluetooth/manager.py 0000664 0000000 0000000 00000110415 15005442573 0021226 0 ustar 00root root 0000000 0000000 """The bluetooth integration."""
from __future__ import annotations
import asyncio
import itertools
import logging
from collections.abc import Callable, Iterable
from dataclasses import asdict
from functools import partial
from typing import TYPE_CHECKING, Any, Final
from bleak.backends.scanner import AdvertisementDataCallback
from bleak_retry_connector import (
NO_RSSI_VALUE,
AllocationChangeEvent,
Allocations,
BleakSlotManager,
)
from bluetooth_adapters import (
ADAPTER_ADDRESS,
ADAPTER_PASSIVE_SCAN,
AdapterDetails,
BluetoothAdapters,
get_adapters,
)
from bluetooth_data_tools import monotonic_time_coarse
from .advertisement_tracker import (
TRACKER_BUFFERING_WOBBLE_SECONDS,
AdvertisementTracker,
)
from .const import (
ADV_RSSI_SWITCH_THRESHOLD,
CALLBACK_TYPE,
FAILED_ADAPTER_MAC,
FALLBACK_MAXIMUM_STALE_ADVERTISEMENT_SECONDS,
UNAVAILABLE_TRACK_SECONDS,
)
from .models import (
BluetoothServiceInfoBleak,
HaBluetoothSlotAllocations,
HaScannerRegistration,
HaScannerRegistrationEvent,
)
from .scanner_device import BluetoothScannerDevice
from .usage import install_multiple_bleak_catcher, uninstall_multiple_bleak_catcher
from .util import async_reset_adapter
if TYPE_CHECKING:
from bleak.backends.device import BLEDevice
from bleak.backends.scanner import AdvertisementData
from .base_scanner import BaseHaScanner
FILTER_UUIDS: Final = "UUIDs"
APPLE_MFR_ID: Final = 76
APPLE_IBEACON_START_BYTE: Final = 0x02 # iBeacon (tilt_ble)
APPLE_HOMEKIT_START_BYTE: Final = 0x06 # homekit_controller
APPLE_DEVICE_ID_START_BYTE: Final = 0x10 # bluetooth_le_tracker
APPLE_HOMEKIT_NOTIFY_START_BYTE: Final = 0x11 # homekit_controller
APPLE_FINDMY_START_BYTE: Final = 0x12 # FindMy network advertisements
_str = str
_int = int
_LOGGER = logging.getLogger(__name__)
def _dispatch_bleak_callback(
bleak_callback: BleakCallback,
device: BLEDevice,
advertisement_data: AdvertisementData,
) -> None:
"""Dispatch the callback."""
if (
uuids := bleak_callback.filters.get(FILTER_UUIDS)
) is not None and not uuids.intersection(advertisement_data.service_uuids):
return
try:
bleak_callback.callback(device, advertisement_data)
except Exception: # pylint: disable=broad-except
_LOGGER.exception("Error in callback: %s", bleak_callback.callback)
class BleakCallback:
"""Bleak callback."""
__slots__ = ("callback", "filters")
def __init__(
self, callback: AdvertisementDataCallback, filters: dict[str, set[str]]
) -> None:
"""Init bleak callback."""
self.callback = callback
self.filters = filters
class BluetoothManager:
"""Manage Bluetooth."""
__slots__ = (
"_adapter_refresh_future",
"_adapter_sources",
"_adapters",
"_advertisement_tracker",
"_all_history",
"_allocations",
"_allocations_callbacks",
"_bleak_callbacks",
"_bluetooth_adapters",
"_cancel_allocation_callbacks",
"_cancel_unavailable_tracking",
"_connectable_history",
"_connectable_scanners",
"_connectable_unavailable_callbacks",
"_connection_history",
"_debug",
"_disappeared_callbacks",
"_fallback_intervals",
"_intervals",
"_loop",
"_non_connectable_scanners",
"_recovery_lock",
"_scanner_registration_callbacks",
"_sources",
"_subclass_discover_info",
"_unavailable_callbacks",
"shutdown",
"slot_manager",
)
def __init__(
self,
bluetooth_adapters: BluetoothAdapters | None = None,
slot_manager: BleakSlotManager | None = None,
) -> None:
"""Init bluetooth manager."""
self._cancel_unavailable_tracking: asyncio.TimerHandle | None = None
self._advertisement_tracker = AdvertisementTracker()
self._fallback_intervals = self._advertisement_tracker.fallback_intervals
self._intervals = self._advertisement_tracker.intervals
self._unavailable_callbacks: dict[
str, set[Callable[[BluetoothServiceInfoBleak], None]]
] = {}
self._connectable_unavailable_callbacks: dict[
str, set[Callable[[BluetoothServiceInfoBleak], None]]
] = {}
self._bleak_callbacks: set[BleakCallback] = set()
self._all_history: dict[str, BluetoothServiceInfoBleak] = {}
self._connectable_history: dict[str, BluetoothServiceInfoBleak] = {}
self._non_connectable_scanners: set[BaseHaScanner] = set()
self._connectable_scanners: set[BaseHaScanner] = set()
self._adapters: dict[str, AdapterDetails] = {}
self._adapter_sources: dict[str, str] = {}
self._allocations: dict[str, HaBluetoothSlotAllocations] = {}
self._sources: dict[str, BaseHaScanner] = {}
self._bluetooth_adapters = bluetooth_adapters or get_adapters()
self.slot_manager = slot_manager or BleakSlotManager()
self._cancel_allocation_callbacks = (
self.slot_manager.register_allocation_callback(
self._async_slot_manager_changed
)
)
self._debug = _LOGGER.isEnabledFor(logging.DEBUG)
self.shutdown = False
self._loop: asyncio.AbstractEventLoop | None = None
self._adapter_refresh_future: asyncio.Future[None] | None = None
self._recovery_lock: asyncio.Lock = asyncio.Lock()
self._disappeared_callbacks: set[Callable[[str], None]] = set()
self._allocations_callbacks: dict[
str | None, set[Callable[[HaBluetoothSlotAllocations], None]]
] = {}
self._scanner_registration_callbacks: dict[
str | None, set[Callable[[HaScannerRegistration], None]]
] = {}
self._subclass_discover_info = self._discover_service_info
if (
self._discover_service_info.__func__ # type: ignore[attr-defined]
is BluetoothManager._discover_service_info
):
_LOGGER.warning(
"%s: does not implement _discover_service_info, "
"subclasses must implement this method to consume "
"discovery data",
type(self).__name__,
)
@property
def supports_passive_scan(self) -> bool:
"""Return if passive scan is supported."""
return any(adapter[ADAPTER_PASSIVE_SCAN] for adapter in self._adapters.values())
def async_scanner_count(self, connectable: bool = True) -> int:
"""Return the number of scanners."""
if connectable:
return len(self._connectable_scanners)
return len(self._connectable_scanners) + len(self._non_connectable_scanners)
async def async_diagnostics(self) -> dict[str, Any]:
"""Diagnostics for the manager."""
scanner_diagnostics = await asyncio.gather(
*[
scanner.async_diagnostics()
for scanner in itertools.chain(
self._non_connectable_scanners, self._connectable_scanners
)
]
)
return {
"adapters": self._adapters,
"slot_manager": self.slot_manager.diagnostics(),
"allocations": {
source: asdict(allocations)
for source, allocations in self._allocations.items()
},
"scanners": scanner_diagnostics,
"connectable_history": [
service_info.as_dict()
for service_info in self._connectable_history.values()
],
"all_history": [
service_info.as_dict() for service_info in self._all_history.values()
],
"advertisement_tracker": self._advertisement_tracker.async_diagnostics(),
}
def _find_adapter_by_address(self, address: str) -> str | None:
for adapter, details in self._adapters.items():
if details[ADAPTER_ADDRESS] == address:
return adapter
return None
def async_scanner_by_source(self, source: str) -> BaseHaScanner | None:
"""Return the scanner for a source."""
return self._sources.get(source)
def async_register_disappeared_callback(
self, callback: Callable[[str], None]
) -> CALLBACK_TYPE:
"""Register a callback to be called when an address disappears."""
self._disappeared_callbacks.add(callback)
return partial(self._disappeared_callbacks.discard, callback)
async def _async_refresh_adapters(self) -> None:
"""Refresh the adapters."""
if self._adapter_refresh_future:
await self._adapter_refresh_future
return
if TYPE_CHECKING:
assert self._loop is not None
self._adapter_refresh_future = self._loop.create_future()
try:
await self._bluetooth_adapters.refresh()
self._adapters = self._bluetooth_adapters.adapters
finally:
self._adapter_refresh_future.set_result(None)
self._adapter_refresh_future = None
async def async_get_bluetooth_adapters(
self, cached: bool = True
) -> dict[str, AdapterDetails]:
"""Get bluetooth adapters."""
if not self._adapters or not cached:
if not cached:
await self._async_refresh_adapters()
self._adapters = self._bluetooth_adapters.adapters
return self._adapters
async def async_get_adapter_from_address(self, address: str) -> str | None:
"""Get adapter from address."""
if adapter := self._find_adapter_by_address(address):
return adapter
await self._async_refresh_adapters()
return self._find_adapter_by_address(address)
async def async_get_adapter_from_address_or_recover(
self, address: str
) -> str | None:
"""Get adapter from address or recover."""
if adapter := self._find_adapter_by_address(address):
return adapter
await self._async_recover_failed_adapters()
return self._find_adapter_by_address(address)
async def _async_recover_failed_adapters(self) -> None:
"""Recover failed adapters."""
if self._recovery_lock.locked():
# Already recovering, no need to
# start another recovery
return
async with self._recovery_lock:
adapters = await self.async_get_bluetooth_adapters()
for adapter in [
adapter
for adapter, details in adapters.items()
if details[ADAPTER_ADDRESS] == FAILED_ADAPTER_MAC
]:
await async_reset_adapter(adapter, FAILED_ADAPTER_MAC, False)
await self._async_refresh_adapters()
async def async_setup(self) -> None:
"""Set up the bluetooth manager."""
from .central_manager import CentralBluetoothManager
if CentralBluetoothManager.manager is None:
CentralBluetoothManager.manager = self
self._loop = asyncio.get_running_loop()
await self._async_refresh_adapters()
install_multiple_bleak_catcher()
self.async_setup_unavailable_tracking()
def async_stop(self) -> None:
"""Stop the Bluetooth integration at shutdown."""
_LOGGER.debug("Stopping bluetooth manager")
self.shutdown = True
if self._cancel_unavailable_tracking:
self._cancel_unavailable_tracking.cancel()
self._cancel_unavailable_tracking = None
uninstall_multiple_bleak_catcher()
self._cancel_allocation_callbacks()
def async_scanner_devices_by_address(
self, address: str, connectable: bool
) -> list[BluetoothScannerDevice]:
"""Get BluetoothScannerDevice by address."""
if not connectable:
scanners: Iterable[BaseHaScanner] = itertools.chain(
self._connectable_scanners, self._non_connectable_scanners
)
else:
scanners = self._connectable_scanners
return [
BluetoothScannerDevice(scanner, *device_adv)
for scanner in scanners
if (device_adv := scanner.get_discovered_device_advertisement_data(address))
]
def _async_all_discovered_addresses(self, connectable: bool) -> Iterable[str]:
"""
Return all of discovered addresses.
Include addresses from all the scanners including duplicates.
"""
yield from itertools.chain.from_iterable(
scanner.discovered_addresses for scanner in self._connectable_scanners
)
if not connectable:
yield from itertools.chain.from_iterable(
scanner.discovered_addresses
for scanner in self._non_connectable_scanners
)
def async_discovered_devices(self, connectable: bool) -> list[BLEDevice]:
"""Return all of combined best path to discovered from all the scanners."""
histories = self._connectable_history if connectable else self._all_history
return [history.device for history in histories.values()]
def async_setup_unavailable_tracking(self) -> None:
"""Set up the unavailable tracking."""
self._schedule_unavailable_tracking()
def _schedule_unavailable_tracking(self) -> None:
"""Schedule the unavailable tracking."""
if TYPE_CHECKING:
assert self._loop is not None
loop = self._loop
self._cancel_unavailable_tracking = loop.call_at(
loop.time() + UNAVAILABLE_TRACK_SECONDS, self._async_check_unavailable
)
def _async_check_unavailable(self) -> None:
"""Watch for unavailable devices and cleanup state history."""
monotonic_now = monotonic_time_coarse()
connectable_history = self._connectable_history
all_history = self._all_history
tracker = self._advertisement_tracker
intervals = tracker.intervals
for connectable in (True, False):
if connectable:
unavailable_callbacks = self._connectable_unavailable_callbacks
else:
unavailable_callbacks = self._unavailable_callbacks
history = connectable_history if connectable else all_history
disappeared = set(history).difference(
self._async_all_discovered_addresses(connectable)
)
for address in disappeared:
if not connectable:
#
# For non-connectable devices we also check the device has exceeded
# the advertising interval before we mark it as unavailable
# since it may have gone to sleep and since we do not need an active
# connection to it we can only determine its availability
# by the lack of advertisements
if advertising_interval := (
intervals.get(address) or self._fallback_intervals.get(address)
):
advertising_interval += TRACKER_BUFFERING_WOBBLE_SECONDS
else:
advertising_interval = (
FALLBACK_MAXIMUM_STALE_ADVERTISEMENT_SECONDS
)
time_since_seen = monotonic_now - all_history[address].time
if time_since_seen <= advertising_interval:
continue
# The second loop (connectable=False) is responsible for removing
# the device from all the interval tracking since it is no longer
# available for both connectable and non-connectable
tracker.async_remove_fallback_interval(address)
tracker.async_remove_address(address)
for disappear_callback in self._disappeared_callbacks:
try:
disappear_callback(address)
except Exception:
_LOGGER.exception("Error in disappeared callback")
self._address_disappeared(address)
service_info = history.pop(address)
if not (callbacks := unavailable_callbacks.get(address)):
continue
for callback in callbacks.copy():
try:
callback(service_info)
except Exception: # pylint: disable=broad-except
_LOGGER.exception("Error in unavailable callback")
self._schedule_unavailable_tracking()
def _address_disappeared(self, address: str) -> None:
"""
Call when an address disappears from the stack.
This method is intended to be overridden by subclasses.
"""
def _prefer_previous_adv_from_different_source(
self,
old: BluetoothServiceInfoBleak,
new: BluetoothServiceInfoBleak,
) -> bool:
"""Prefer previous advertisement from a different source if it is better."""
if stale_seconds := self._intervals.get(
new.address, self._fallback_intervals.get(new.address, 0)
):
stale_seconds += TRACKER_BUFFERING_WOBBLE_SECONDS
else:
stale_seconds = FALLBACK_MAXIMUM_STALE_ADVERTISEMENT_SECONDS
if new.time - old.time > stale_seconds:
# If the old advertisement is stale, any new advertisement is preferred
if self._debug:
_LOGGER.debug(
"%s (%s): Switching from %s to %s (time elapsed:%s > stale"
" seconds:%s)",
new.name,
new.address,
self._async_describe_source(old),
self._async_describe_source(new),
new.time - old.time,
stale_seconds,
)
return False
if (new.rssi or NO_RSSI_VALUE) - ADV_RSSI_SWITCH_THRESHOLD > (
old.rssi or NO_RSSI_VALUE
):
# If new advertisement is ADV_RSSI_SWITCH_THRESHOLD more,
# the new one is preferred.
if self._debug:
_LOGGER.debug(
"%s (%s): Switching from %s to %s (new rssi:%s - threshold:%s >"
" old rssi:%s)",
new.name,
new.address,
self._async_describe_source(old),
self._async_describe_source(new),
new.rssi,
ADV_RSSI_SWITCH_THRESHOLD,
old.rssi,
)
return False
return True
def scanner_adv_received(self, service_info: BluetoothServiceInfoBleak) -> None:
"""
Handle a new advertisement from any scanner.
Callbacks from all the scanners arrive here.
"""
# Pre-filter noisy apple devices as they can account for 20-35% of the
# traffic on a typical network.
if (
not service_info.service_data
and len(service_info.manufacturer_data) == 1
and (apple_data := service_info.manufacturer_data.get(APPLE_MFR_ID))
):
apple_cstr = apple_data
if apple_cstr[0] not in {
APPLE_IBEACON_START_BYTE,
APPLE_HOMEKIT_START_BYTE,
APPLE_HOMEKIT_NOTIFY_START_BYTE,
APPLE_DEVICE_ID_START_BYTE,
APPLE_FINDMY_START_BYTE,
}:
return
if service_info.connectable:
old_connectable_service_info = self._connectable_history.get(
service_info.address
)
else:
old_connectable_service_info = None
# This logic is complex due to the many combinations of scanners
# that are supported.
#
# We need to handle multiple connectable and non-connectable scanners
# and we need to handle the case where a device is connectable on one scanner
# but not on another.
#
# The device may also be connectable only by a scanner that has worse
# signal strength than a non-connectable scanner.
#
# all_history - the history of all advertisements from all scanners with the
# best advertisement from each scanner
# connectable_history - the history of all connectable advertisements from all
# scanners with the best advertisement from each
# connectable scanner
#
if (
(old_service_info := self._all_history.get(service_info.address))
is not None
and service_info.source is not old_service_info.source
and service_info.source != old_service_info.source
and (scanner := self._sources.get(old_service_info.source)) is not None
and scanner.scanning
and self._prefer_previous_adv_from_different_source(
old_service_info, service_info
)
):
# If we are rejecting the new advertisement and the device is connectable
# but not in the connectable history or the connectable source is the same
# as the new source, we need to add it to the connectable history
if service_info.connectable:
if old_connectable_service_info is not None and (
# If its the same as the preferred source, we are done
# as we know we prefer the old advertisement
# from the check above
(old_connectable_service_info is old_service_info)
# If the old connectable source is different from the preferred
# source, we need to check it as well to see if we prefer
# the old connectable advertisement
or (
old_connectable_service_info.source is not service_info.source
and old_connectable_service_info.source != service_info.source
and (
connectable_scanner := self._sources.get(
old_connectable_service_info.source
)
)
is not None
and connectable_scanner.scanning
and self._prefer_previous_adv_from_different_source(
old_connectable_service_info,
service_info,
)
)
):
return
self._connectable_history[service_info.address] = service_info
return
if service_info.connectable:
self._connectable_history[service_info.address] = service_info
self._all_history[service_info.address] = service_info
# Track advertisement intervals to determine when we need to
# switch adapters or mark a device as unavailable
if (
(
last_source := self._advertisement_tracker.sources.get(
service_info.address
)
)
is not None
and last_source is not service_info.source
and last_source != service_info.source
):
# Source changed, remove the old address from the tracker
self._advertisement_tracker.async_remove_address(service_info.address)
if service_info.address not in self._advertisement_tracker.intervals:
self._advertisement_tracker.async_collect(service_info)
# If the advertisement data is the same as the last time we saw it, we
# don't need to do anything else unless its connectable and we are missing
# connectable history for the device so we can make it available again
# after unavailable callbacks.
if (
# Ensure its not a connectable device missing from connectable history
not (service_info.connectable and old_connectable_service_info is None)
# Than check if advertisement data is the same
and old_service_info is not None
# This is a bit complex because we want to skip all the
# PyObject_RichCompare overhead as its can be upwards of
# 65% of the time spent in this method. The common case
# is that its the same object for remote scanners.
and not (
(
service_info.manufacturer_data
is not old_service_info.manufacturer_data
and service_info.manufacturer_data
!= old_service_info.manufacturer_data
)
or (
service_info.service_data is not old_service_info.service_data
and service_info.service_data != old_service_info.service_data
)
or (
service_info.service_uuids is not old_service_info.service_uuids
and service_info.service_uuids != old_service_info.service_uuids
)
or (
service_info.name is not old_service_info.name
and service_info.name != old_service_info.name
)
)
):
return
if not service_info.connectable and old_connectable_service_info is not None:
# Since we have a connectable path and our BleakClient will
# route any connection attempts to the connectable path, we
# mark the service_info as connectable so that the callbacks
# will be called and the device can be discovered.
service_info = service_info._as_connectable()
if (
service_info.connectable or old_connectable_service_info is not None
) and self._bleak_callbacks:
# Bleak callbacks must get a connectable device
advertisement_data = service_info._advertisement_internal()
for bleak_callback in self._bleak_callbacks:
_dispatch_bleak_callback(
bleak_callback, service_info.device, advertisement_data
)
self._subclass_discover_info(service_info)
def _discover_service_info(self, service_info: BluetoothServiceInfoBleak) -> None:
"""
Discover a new service info.
This method is intended to be overridden by subclasses.
"""
def _async_describe_source(self, service_info: BluetoothServiceInfoBleak) -> str:
"""Describe a source."""
if scanner := self._sources.get(service_info.source):
description = scanner.name
else:
description = service_info.source
if service_info.connectable:
description += " [connectable]"
return description
def _async_remove_unavailable_callback_internal(
self,
unavailable_callbacks: dict[
str, set[Callable[[BluetoothServiceInfoBleak], None]]
],
address: str,
callbacks: set[Callable[[BluetoothServiceInfoBleak], None]],
callback: Callable[[BluetoothServiceInfoBleak], None],
) -> None:
"""Remove a callback."""
callbacks.remove(callback)
if not callbacks:
del unavailable_callbacks[address]
def async_track_unavailable(
self,
callback: Callable[[BluetoothServiceInfoBleak], None],
address: str,
connectable: bool,
) -> Callable[[], None]:
"""Register a callback."""
if connectable:
unavailable_callbacks = self._connectable_unavailable_callbacks
else:
unavailable_callbacks = self._unavailable_callbacks
callbacks = unavailable_callbacks.setdefault(address, set())
callbacks.add(callback)
return partial(
self._async_remove_unavailable_callback_internal,
unavailable_callbacks,
address,
callbacks,
callback,
)
def async_ble_device_from_address(
self, address: str, connectable: bool
) -> BLEDevice | None:
"""Return the BLEDevice if present."""
histories = self._connectable_history if connectable else self._all_history
if history := histories.get(address):
return history.device
return None
def async_address_present(self, address: str, connectable: bool) -> bool:
"""Return if the address is present."""
histories = self._connectable_history if connectable else self._all_history
return address in histories
def async_discovered_service_info(
self, connectable: bool
) -> Iterable[BluetoothServiceInfoBleak]:
"""Return all the discovered services info."""
histories = self._connectable_history if connectable else self._all_history
return histories.values()
def async_last_service_info(
self, address: str, connectable: bool
) -> BluetoothServiceInfoBleak | None:
"""Return the last service info for an address."""
histories = self._connectable_history if connectable else self._all_history
return histories.get(address)
def _async_unregister_scanner_internal(
self,
scanners: set[BaseHaScanner],
scanner: BaseHaScanner,
connection_slots: int | None,
) -> None:
"""Unregister a scanner."""
_LOGGER.debug("Unregistering scanner %s", scanner.name)
self._advertisement_tracker.async_remove_source(scanner.source)
scanners.remove(scanner)
scanner._clear_connection_history()
del self._sources[scanner.source]
del self._adapter_sources[scanner.adapter]
self._allocations.pop(scanner.source, None)
if connection_slots:
self.slot_manager.remove_adapter(scanner.adapter)
self._async_on_scanner_registration(scanner, HaScannerRegistrationEvent.REMOVED)
def async_register_scanner(
self,
scanner: BaseHaScanner,
connection_slots: int | None = None,
) -> CALLBACK_TYPE:
"""Register a new scanner."""
_LOGGER.debug("Registering scanner %s", scanner.name)
if scanner.connectable:
scanners = self._connectable_scanners
else:
scanners = self._non_connectable_scanners
self._allocations[scanner.source] = HaBluetoothSlotAllocations(
source=scanner.source, slots=0, free=0, allocated=[]
)
scanners.add(scanner)
scanner._clear_connection_history()
self._sources[scanner.source] = scanner
self._adapter_sources[scanner.adapter] = scanner.source
if connection_slots:
self.slot_manager.register_adapter(scanner.adapter, connection_slots)
self.async_on_allocation_changed(
self.slot_manager.get_allocations(scanner.adapter)
)
self._async_on_scanner_registration(scanner, HaScannerRegistrationEvent.ADDED)
return partial(
self._async_unregister_scanner_internal, scanners, scanner, connection_slots
)
def async_register_bleak_callback(
self, callback: AdvertisementDataCallback, filters: dict[str, set[str]]
) -> CALLBACK_TYPE:
"""Register a callback."""
callback_entry = BleakCallback(callback, filters)
self._bleak_callbacks.add(callback_entry)
# Replay the history since otherwise we miss devices
# that were already discovered before the callback was registered
# or we are in passive mode
for history in self._connectable_history.values():
_dispatch_bleak_callback(
callback_entry, history.device, history.advertisement
)
return partial(self._bleak_callbacks.remove, callback_entry)
def async_release_connection_slot(self, device: BLEDevice) -> None:
"""Release a connection slot."""
self.slot_manager.release_slot(device)
def async_allocate_connection_slot(self, device: BLEDevice) -> bool:
"""Allocate a connection slot."""
return self.slot_manager.allocate_slot(device)
def async_get_learned_advertising_interval(self, address: str) -> float | None:
"""Get the learned advertising interval for a MAC address."""
return self._intervals.get(address)
def async_get_fallback_availability_interval(self, address: str) -> float | None:
"""Get the fallback availability timeout for a MAC address."""
return self._fallback_intervals.get(address)
def async_set_fallback_availability_interval(
self, address: str, interval: float
) -> None:
"""Override the fallback availability timeout for a MAC address."""
self._fallback_intervals[address] = interval
def _async_slot_manager_changed(self, event: AllocationChangeEvent) -> None:
"""Handle slot manager changes."""
self.async_on_allocation_changed(
self.slot_manager.get_allocations(event.adapter)
)
def async_on_allocation_changed(self, allocations: Allocations) -> None:
"""Call allocation callbacks."""
source = self._adapter_sources.get(allocations.adapter, allocations.adapter)
ha_slot_allocations = HaBluetoothSlotAllocations(
source=source,
slots=allocations.slots,
free=allocations.free,
allocated=allocations.allocated,
)
self._allocations[source] = ha_slot_allocations
for source_key in (source, None):
if not (
allocation_callbacks := self._allocations_callbacks.get(source_key)
):
continue
for callback_ in allocation_callbacks:
try:
callback_(ha_slot_allocations)
except Exception:
_LOGGER.exception("Error in allocation callback")
def _async_on_scanner_registration(
self, scanner: BaseHaScanner, event: HaScannerRegistrationEvent
) -> None:
"""Call scanner callbacks."""
for source_key in (scanner.source, None):
if not (
scanner_callbacks := self._scanner_registration_callbacks.get(
source_key
)
):
continue
for callback_ in scanner_callbacks:
try:
callback_(HaScannerRegistration(event, scanner))
except Exception:
_LOGGER.exception("Error in scanner callback")
def async_current_allocations(
self, source: str | None = None
) -> list[HaBluetoothSlotAllocations] | None:
"""Return the current allocations."""
if source:
if allocations := self._allocations.get(source):
return [allocations]
return []
return list(self._allocations.values())
def async_register_allocation_callback(
self,
callback: Callable[[HaBluetoothSlotAllocations], None],
source: str | None = None,
) -> CALLBACK_TYPE:
"""Register a callback to be called when an allocations change."""
self._allocations_callbacks.setdefault(source, set()).add(callback)
return partial(self._async_unregister_allocation_callback, callback, source)
def _async_unregister_allocation_callback(
self, callback: Callable[[HaBluetoothSlotAllocations], None], source: str | None
) -> None:
if (callbacks := self._allocations_callbacks.get(source)) is not None:
callbacks.discard(callback)
if not callbacks:
del self._allocations_callbacks[source]
def async_register_scanner_registration_callback(
self, callback: Callable[[HaScannerRegistration], None], source: str | None
) -> CALLBACK_TYPE:
"""Register a callback to be called when a scanner is added or removed."""
self._scanner_registration_callbacks.setdefault(source, set()).add(callback)
return partial(
self._async_unregister_scanner_registration_callback, callback, source
)
def _async_unregister_scanner_registration_callback(
self, callback: Callable[[HaScannerRegistration], None], source: str | None
) -> None:
if (callbacks := self._scanner_registration_callbacks.get(source)) is not None:
callbacks.discard(callback)
if not callbacks:
del self._scanner_registration_callbacks[source]
def async_current_scanners(self) -> list[BaseHaScanner]:
"""Return the current scanners."""
return list(self._sources.values())
habluetooth-3.48.2/src/habluetooth/models.pxd 0000664 0000000 0000000 00000001757 15005442573 0021252 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)
habluetooth-3.48.2/src/habluetooth/models.py 0000664 0000000 0000000 00000024051 15005442573 0021077 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)
class HaBluetoothConnector:
"""Data for how to connect a BLEDevice from a given scanner."""
client: type[BaseBleakClient]
source: str
can_connect: Callable[[], bool]
@dataclass(slots=True, frozen=True)
class HaScannerDetails:
"""Details for a scanner."""
source: str
connectable: bool
name: str
adapter: str
class BluetoothScanningMode(Enum):
"""The mode of scanning for bluetooth devices."""
PASSIVE = "passive"
ACTIVE = "active"
class BluetoothServiceInfo:
"""Prepared info from bluetooth entries."""
__slots__ = (
"address",
"manufacturer_data",
"name",
"rssi",
"service_data",
"service_uuids",
"source",
)
def __init__(
self,
name: _str, # may be a pyobjc object
address: _str, # may be a pyobjc object
rssi: _int, # may be a pyobjc object
manufacturer_data: dict[_int, bytes],
service_data: dict[_str, bytes],
service_uuids: list[_str],
source: _str,
) -> None:
"""Initialize a bluetooth service info."""
self.name = name
self.address = address
self.rssi = rssi
self.manufacturer_data = manufacturer_data
self.service_data = service_data
self.service_uuids = service_uuids
self.source = source
@classmethod
def from_advertisement(
cls: type[_BluetoothServiceInfoSelfT],
device: BLEDevice,
advertisement_data: AdvertisementData,
source: str,
) -> _BluetoothServiceInfoSelfT:
"""Create a BluetoothServiceInfo from an advertisement."""
return cls(
advertisement_data.local_name or device.name or device.address,
device.address,
advertisement_data.rssi,
advertisement_data.manufacturer_data,
advertisement_data.service_data,
advertisement_data.service_uuids,
source,
)
@property
def manufacturer(self) -> str | None:
"""Convert manufacturer data to a string."""
from bleak.backends._manufacturers import (
MANUFACTURERS, # pylint: disable=import-outside-toplevel
)
for manufacturer in self.manufacturer_data:
if manufacturer in MANUFACTURERS:
name: str = MANUFACTURERS[manufacturer]
return name
return None
@property
def manufacturer_id(self) -> int | None:
"""Get the first manufacturer id."""
for manufacturer in self.manufacturer_data:
return manufacturer
return None
class BluetoothServiceInfoBleak(BluetoothServiceInfo):
"""
BluetoothServiceInfo with bleak data.
Integrations may need BLEDevice and AdvertisementData
to connect to the device without having bleak trigger
another scan to translate the address to the system's
internal details.
"""
__slots__ = ("_advertisement", "connectable", "device", "raw", "time", "tx_power")
def __init__(
self,
name: _str, # may be a pyobjc object
address: _str, # may be a pyobjc object
rssi: _int, # may be a pyobjc object
manufacturer_data: dict[_int, bytes],
service_data: dict[_str, bytes],
service_uuids: list[_str],
source: _str,
device: BLEDevice,
advertisement: AdvertisementData | None,
connectable: bool,
time: _float,
tx_power: _int | None,
raw: bytes | None = None,
) -> None:
self.name = name
self.address = address
self.rssi = rssi
self.manufacturer_data = manufacturer_data
self.service_data = service_data
self.service_uuids = service_uuids
self.source = source
self.device = device
self._advertisement = advertisement
self.connectable = connectable
self.time = time
self.tx_power = tx_power
self.raw = raw
def __repr__(self) -> str:
"""Return the representation of the object."""
return (
f"<{self.__class__.__name__} "
f"name={self.name} "
f"address={self.address} "
f"rssi={self.rssi} "
f"manufacturer_data={self.manufacturer_data} "
f"service_data={self.service_data} "
f"service_uuids={self.service_uuids} "
f"source={self.source} "
f"connectable={self.connectable} "
f"time={self.time} "
f"tx_power={self.tx_power} "
f"raw={self.raw!r}>"
)
def _advertisement_internal(self) -> AdvertisementData:
"""
Get the advertisement data.
Internal method only to be used by this library.
"""
if self._advertisement is None:
self._advertisement = TUPLE_NEW(
AdvertisementData,
(
None if self.name == "" or self.name == self.address else self.name,
self.manufacturer_data,
self.service_data,
self.service_uuids,
NO_RSSI_VALUE if self.tx_power is None else self.tx_power,
self.rssi,
(),
),
)
return self._advertisement
@property
def advertisement(self) -> AdvertisementData:
"""Get the advertisement data."""
return self._advertisement_internal()
def as_dict(self) -> dict[str, Any]:
"""
Return as dict.
The dataclass asdict method is not used because
it will try to deepcopy pyobjc data which will fail.
"""
return {
"name": self.name,
"address": self.address,
"rssi": self.rssi,
"manufacturer_data": self.manufacturer_data,
"service_data": self.service_data,
"service_uuids": self.service_uuids,
"source": self.source,
"advertisement": self.advertisement,
"device": self.device,
"connectable": self.connectable,
"time": self.time,
"tx_power": self.tx_power,
"raw": self.raw,
}
@classmethod
def from_scan(
cls: type[_BluetoothServiceInfoBleakSelfT],
source: str,
device: BLEDevice,
advertisement_data: AdvertisementData,
monotonic_time: _float,
connectable: bool,
) -> _BluetoothServiceInfoBleakSelfT:
"""Create a BluetoothServiceInfoBleak from a scanner."""
return cls(
advertisement_data.local_name or device.name or device.address,
device.address,
advertisement_data.rssi,
advertisement_data.manufacturer_data,
advertisement_data.service_data,
advertisement_data.service_uuids,
source,
device,
advertisement_data,
connectable,
monotonic_time,
advertisement_data.tx_power,
)
@classmethod
def from_device_and_advertisement_data(
cls: type[_BluetoothServiceInfoBleakSelfT],
device: BLEDevice,
advertisement_data: AdvertisementData,
source: str,
time: _float,
connectable: bool,
) -> _BluetoothServiceInfoBleakSelfT:
"""Create a BluetoothServiceInfoBleak from a device and advertisement."""
return cls(
advertisement_data.local_name or device.name or device.address,
device.address,
advertisement_data.rssi,
advertisement_data.manufacturer_data,
advertisement_data.service_data,
advertisement_data.service_uuids,
source,
device,
advertisement_data,
connectable,
time,
advertisement_data.tx_power,
)
def _as_connectable(self) -> BluetoothServiceInfoBleak:
"""Return a connectable version of this object."""
new_obj = BluetoothServiceInfoBleak.__new__(BluetoothServiceInfoBleak)
new_obj.name = self.name
new_obj.address = self.address
new_obj.rssi = self.rssi
new_obj.manufacturer_data = self.manufacturer_data
new_obj.service_data = self.service_data
new_obj.service_uuids = self.service_uuids
new_obj.source = self.source
new_obj.device = self.device
new_obj._advertisement = self._advertisement
new_obj.connectable = True
new_obj.time = self.time
new_obj.tx_power = self.tx_power
new_obj.raw = self.raw
return new_obj
habluetooth-3.48.2/src/habluetooth/py.typed 0000664 0000000 0000000 00000000000 15005442573 0020725 0 ustar 00root root 0000000 0000000 habluetooth-3.48.2/src/habluetooth/scanner.pxd 0000664 0000000 0000000 00000001201 15005442573 0021400 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
)
habluetooth-3.48.2/src/habluetooth/scanner.py 0000664 0000000 0000000 00000053023 15005442573 0021246 0 ustar 00root root 0000000 0000000 # cython: profile=True
"""A local bleak scanner."""
from __future__ import annotations
import asyncio
import logging
import platform
from typing import Any, Coroutine, Iterable, no_type_check
import async_interrupt
import bleak
from bleak import BleakError
from bleak.assigned_numbers import AdvertisementDataType
from bleak.backends.device import BLEDevice
from bleak.backends.scanner import AdvertisementData, AdvertisementDataCallback
from bleak_retry_connector import restore_discoveries
from bleak_retry_connector.bluez import stop_discovery
from bluetooth_adapters import DEFAULT_ADDRESS
from bluetooth_data_tools import monotonic_time_coarse
from .base_scanner import BaseHaScanner
from .const import (
CALLBACK_TYPE,
SCANNER_WATCHDOG_INTERVAL,
SCANNER_WATCHDOG_TIMEOUT,
SOURCE_LOCAL,
START_TIMEOUT,
STOP_TIMEOUT,
)
from .models import BluetoothScanningMode, BluetoothServiceInfoBleak
from .util import async_reset_adapter, is_docker_env
SYSTEM = platform.system()
IS_LINUX = SYSTEM == "Linux"
IS_MACOS = SYSTEM == "Darwin"
if IS_LINUX:
from bleak.backends.bluezdbus.advertisement_monitor import (
AdvertisementMonitor,
OrPattern,
)
from bleak.backends.bluezdbus.scanner import BlueZScannerArgs
from dbus_fast import InvalidMessageError
from dbus_fast.service import method
# or_patterns is a workaround for the fact that passive scanning
# needs at least one matcher to be set. The below matcher
# will match all devices.
PASSIVE_SCANNER_ARGS = BlueZScannerArgs(
or_patterns=[
OrPattern(0, AdvertisementDataType.FLAGS, b"\x02"),
OrPattern(0, AdvertisementDataType.FLAGS, b"\x06"),
OrPattern(0, AdvertisementDataType.FLAGS, b"\x1a"),
]
)
class HaAdvertisementMonitor(AdvertisementMonitor):
"""Implementation of the org.bluez.AdvertisementMonitor1 D-Bus interface."""
@method()
@no_type_check
def DeviceFound(self, device: o): # noqa: F821
"""Device found."""
@method()
@no_type_check
def DeviceLost(self, device: o): # noqa: F821
"""Device lost."""
AdvertisementMonitor.DeviceFound = HaAdvertisementMonitor.DeviceFound
AdvertisementMonitor.DeviceLost = HaAdvertisementMonitor.DeviceLost
else:
class InvalidMessageError(Exception): # type: ignore[no-redef]
"""Invalid message error."""
OriginalBleakScanner = bleak.BleakScanner
_LOGGER = logging.getLogger(__name__)
IN_PROGRESS_ERROR = "org.bluez.Error.InProgress"
# If the adapter is in a stuck state the following errors are raised:
NEED_RESET_ERRORS = [
"org.bluez.Error.Failed",
IN_PROGRESS_ERROR,
"org.bluez.Error.NotReady",
"not found",
]
# When the adapter is still initializing, the scanner will raise an exception
# with org.freedesktop.DBus.Error.UnknownObject
WAIT_FOR_ADAPTER_TO_INIT_ERRORS = ["org.freedesktop.DBus.Error.UnknownObject"]
ADAPTER_INIT_TIME = 1.5
START_ATTEMPTS = 4
SCANNING_MODE_TO_BLEAK = {
BluetoothScanningMode.ACTIVE: "active",
BluetoothScanningMode.PASSIVE: "passive",
}
# The minimum number of seconds to know
# the adapter has not had advertisements
# and we already tried to restart the scanner
# without success when the first time the watch
# dog hit the failure path.
SCANNER_WATCHDOG_MULTIPLE = (
SCANNER_WATCHDOG_TIMEOUT + SCANNER_WATCHDOG_INTERVAL.total_seconds()
)
class _AbortStartError(Exception):
"""Error to indicate that the start should be aborted."""
class ScannerStartError(Exception):
"""Error to indicate that the scanner failed to start."""
def create_bleak_scanner(
detection_callback: AdvertisementDataCallback,
scanning_mode: BluetoothScanningMode,
adapter: str | None,
) -> bleak.BleakScanner:
"""Create a Bleak scanner."""
scanner_kwargs: dict[str, Any] = {
"detection_callback": detection_callback,
"scanning_mode": SCANNING_MODE_TO_BLEAK[scanning_mode],
}
if IS_LINUX:
# Only Linux supports multiple adapters
if adapter:
scanner_kwargs["adapter"] = adapter
if scanning_mode == BluetoothScanningMode.PASSIVE:
scanner_kwargs["bluez"] = PASSIVE_SCANNER_ARGS
elif IS_MACOS:
# We want mac address on macOS
scanner_kwargs["cb"] = {"use_bdaddr": True}
_LOGGER.debug("Initializing bluetooth scanner with %s", scanner_kwargs)
try:
return OriginalBleakScanner(**scanner_kwargs)
except (FileNotFoundError, BleakError) as ex:
raise RuntimeError(f"Failed to initialize Bluetooth: {ex}") from ex
def _error_indicates_reset_needed(error_str: str) -> bool:
"""Return if the error indicates a reset is needed."""
return any(
needs_reset_error in error_str for needs_reset_error in NEED_RESET_ERRORS
)
def _error_indicates_wait_for_adapter_to_init(error_str: str) -> bool:
"""Return if the error indicates the adapter is still initializing."""
return any(
wait_error in error_str for wait_error in WAIT_FOR_ADAPTER_TO_INIT_ERRORS
)
class HaScanner(BaseHaScanner):
"""
Operate and automatically recover a BleakScanner.
Multiple BleakScanner can be used at the same time
if there are multiple adapters. This is only useful
if the adapters are not located physically next to each other.
Example use cases are usbip, a long extension cable, usb to bluetooth
over ethernet, usb over ethernet, etc.
"""
__slots__ = (
"_background_tasks",
"_start_future",
"_start_stop_lock",
"mac_address",
"scanner",
)
def __init__(
self,
mode: BluetoothScanningMode,
adapter: str,
address: str,
) -> None:
"""Init bluetooth discovery."""
self.mac_address = address
source = address if address != DEFAULT_ADDRESS else adapter or SOURCE_LOCAL
super().__init__(source, adapter, requested_mode=mode)
self.connectable = True
self._start_stop_lock = asyncio.Lock()
self.scanning = False
self._background_tasks: set[asyncio.Task[Any]] = set()
self.scanner: bleak.BleakScanner | None = None
self._start_future: asyncio.Future[None] | None = None
def _create_background_task(self, coro: Coroutine[Any, Any, None]) -> None:
"""Create a background task and add it to the background tasks set."""
task = asyncio.create_task(coro)
self._background_tasks.add(task)
task.add_done_callback(self._background_tasks.discard)
@property
def discovered_devices(self) -> list[BLEDevice]:
"""Return a list of discovered devices."""
if not self.scanner:
return []
return self.scanner.discovered_devices
@property
def discovered_devices_and_advertisement_data(
self,
) -> dict[str, tuple[BLEDevice, AdvertisementData]]:
"""Return a list of discovered devices and advertisement data."""
if not self.scanner:
return {}
return self.scanner.discovered_devices_and_advertisement_data
@property
def discovered_addresses(self) -> Iterable[str]:
"""Return an iterable of discovered devices."""
return self.discovered_devices_and_advertisement_data
def get_discovered_device_advertisement_data(
self, address: str
) -> tuple[BLEDevice, AdvertisementData] | None:
"""Return the advertisement data for a discovered device."""
return self.discovered_devices_and_advertisement_data.get(address)
def async_setup(self) -> CALLBACK_TYPE:
"""Set up the scanner."""
super().async_setup()
return self._unsetup
async def async_diagnostics(self) -> dict[str, Any]:
"""Return diagnostic information about the scanner."""
base_diag = await super().async_diagnostics()
return base_diag | {"adapter": self.adapter}
def _async_detection_callback(
self,
device: BLEDevice,
advertisement_data: AdvertisementData,
) -> None:
"""
Call the callback when an advertisement is received.
Currently this is used to feed the callbacks into the
central manager.
"""
callback_time = monotonic_time_coarse()
address = device.address
local_name = advertisement_data.local_name
manufacturer_data = advertisement_data.manufacturer_data
service_data = advertisement_data.service_data
service_uuids = advertisement_data.service_uuids
if local_name or manufacturer_data or service_data or service_uuids:
# Don't count empty advertisements
# as the adapter is in a failure
# state if all the data is empty.
self._last_detection = callback_time
name = local_name or device.name or address
if name is not None and type(name) is not str:
name = str(name)
tx_power = advertisement_data.tx_power
if tx_power is not None and type(tx_power) is not int:
tx_power = int(tx_power)
service_info = BluetoothServiceInfoBleak.__new__(BluetoothServiceInfoBleak)
service_info.name = name
service_info.address = address
service_info.rssi = advertisement_data.rssi
service_info.manufacturer_data = manufacturer_data
service_info.service_data = service_data
service_info.service_uuids = service_uuids
service_info.source = self.source
service_info.device = device
service_info._advertisement = advertisement_data
service_info.connectable = True
service_info.time = callback_time
service_info.tx_power = tx_power
service_info.raw = None # not available in bleak.
self._manager.scanner_adv_received(service_info)
async def async_start(self) -> None:
"""Start bluetooth scanner."""
async with self._start_stop_lock:
await self._async_start()
async def _async_start(self) -> None:
"""Start bluetooth scanner under the lock."""
for attempt in range(1, START_ATTEMPTS + 1):
if await self._async_start_attempt(attempt):
# Everything is fine, break out of the loop
break
await self._async_on_successful_start()
async def _async_on_successful_start(self) -> None:
"""Run when the scanner has successfully started."""
self.scanning = True
self._async_setup_scanner_watchdog()
await restore_discoveries(self.scanner, self.adapter)
async def _async_start_attempt(self, attempt: int) -> bool:
"""Start the scanner and handle errors."""
assert ( # noqa: S101
self._loop is not None
), "Loop is not set, call async_setup first"
self.current_mode = self.requested_mode
# 1st attempt - no auto reset
# 2nd attempt - try to reset the adapter and wait a bit
# 3th attempt - no auto reset
# 4th attempt - fallback to passive if available
if (
IS_LINUX
and attempt == START_ATTEMPTS
and self.requested_mode is BluetoothScanningMode.ACTIVE
):
_LOGGER.debug(
"%s: Falling back to passive scanning mode "
"after active scanning failed (%s/%s)",
self.name,
attempt,
START_ATTEMPTS,
)
self.current_mode = BluetoothScanningMode.PASSIVE
assert self.current_mode is not None # noqa: S101
self.scanner = create_bleak_scanner(
self._async_detection_callback, self.current_mode, self.adapter
)
self._log_start_attempt(attempt)
self._start_future = self._loop.create_future()
try:
async with (
asyncio.timeout(START_TIMEOUT),
async_interrupt.interrupt(self._start_future, _AbortStartError, None),
):
await self.scanner.start()
except _AbortStartError as ex:
await self._async_stop_scanner()
self._raise_for_abort_start(ex)
except InvalidMessageError as ex:
await self._async_stop_scanner()
self._raise_for_invalid_dbus_message(ex)
except BrokenPipeError as ex:
await self._async_stop_scanner()
self._raise_for_broken_pipe_error(ex)
except FileNotFoundError as ex:
await self._async_stop_scanner()
self._raise_for_file_not_found_error(ex)
except asyncio.TimeoutError as ex:
await self._async_stop_scanner()
if attempt == 2:
await self._async_reset_adapter(False)
if attempt < START_ATTEMPTS:
self._log_start_timeout(attempt)
return False
raise ScannerStartError(
f"{self.name}: Timed out starting Bluetooth after"
f" {START_TIMEOUT} seconds; "
"Try power cycling the Bluetooth hardware."
) from ex
except BleakError as ex:
await self._async_stop_scanner()
error_str = str(ex)
if IN_PROGRESS_ERROR in error_str:
# If discovery is stuck on, try to force stop it
await self._async_force_stop_discovery()
if attempt == 2 and _error_indicates_reset_needed(error_str):
await self._async_reset_adapter(False)
elif (
attempt != START_ATTEMPTS
and _error_indicates_wait_for_adapter_to_init(error_str)
):
# If we are not out of retry attempts, and the
# adapter is still initializing, wait a bit and try again.
self._log_adapter_init_wait(attempt)
await asyncio.sleep(ADAPTER_INIT_TIME)
if attempt < START_ATTEMPTS:
self._log_start_failed(ex, attempt)
return False
raise ScannerStartError(
f"{self.name}: Failed to start Bluetooth: {ex}; "
"Try power cycling the Bluetooth hardware."
) from ex
except BaseException:
await self._async_stop_scanner()
raise
finally:
self._start_future = None
self._log_start_success(attempt)
return True
def _log_adapter_init_wait(self, attempt: int) -> None:
_LOGGER.debug(
"%s: Waiting for adapter to initialize; attempt (%s/%s)",
self.name,
attempt,
START_ATTEMPTS,
)
def _log_start_success(self, attempt: int) -> None:
if self.current_mode is not self.requested_mode:
_LOGGER.warning(
"%s: Successful fall-back to passive scanning mode "
"after active scanning failed (%s/%s)",
self.name,
attempt,
START_ATTEMPTS,
)
_LOGGER.debug(
"%s: Success while starting bluetooth; attempt: (%s/%s)",
self.name,
attempt,
START_ATTEMPTS,
)
def _log_start_timeout(self, attempt: int) -> None:
_LOGGER.debug(
"%s: TimeoutError while starting bluetooth; attempt: (%s/%s)",
self.name,
attempt,
START_ATTEMPTS,
)
def _log_start_failed(self, ex: BleakError, attempt: int) -> None:
_LOGGER.debug(
"%s: BleakError while starting bluetooth; attempt: (%s/%s): %s",
self.name,
attempt,
START_ATTEMPTS,
ex,
exc_info=True,
)
def _log_start_attempt(self, attempt: int) -> None:
_LOGGER.debug(
"%s: Starting bluetooth discovery attempt: (%s/%s)",
self.name,
attempt,
START_ATTEMPTS,
)
def _raise_for_abort_start(self, ex: _AbortStartError) -> None:
_LOGGER.debug(
"%s: Starting bluetooth scanner aborted: %s",
self.name,
ex,
exc_info=True,
)
msg = f"{self.name}: Starting bluetooth scanner aborted"
raise ScannerStartError(msg) from ex
def _raise_for_file_not_found_error(self, ex: FileNotFoundError) -> None:
_LOGGER.debug(
"%s: FileNotFoundError while starting bluetooth: %s",
self.name,
ex,
exc_info=True,
)
if is_docker_env():
raise ScannerStartError(
f"{self.name}: DBus service not found; docker config may "
"be missing `-v /run/dbus:/run/dbus:ro`: {ex}"
) from ex
raise ScannerStartError(
f"{self.name}: DBus service not found; make sure the DBus socket "
f"is available: {ex}"
) from ex
def _raise_for_broken_pipe_error(self, ex: BrokenPipeError) -> None:
"""Raise a ScannerStartError for a BrokenPipeError."""
_LOGGER.debug("%s: DBus connection broken: %s", self.name, ex, exc_info=True)
if is_docker_env():
msg = (
f"{self.name}: DBus connection broken: {ex}; try restarting "
"`bluetooth`, `dbus`, and finally the docker container"
)
else:
msg = (
f"{self.name}: DBus connection broken: {ex}; try restarting "
"`bluetooth` and `dbus`"
)
raise ScannerStartError(msg) from ex
def _raise_for_invalid_dbus_message(self, ex: InvalidMessageError) -> None:
"""Raise a ScannerStartError for an InvalidMessageError."""
_LOGGER.debug(
"%s: Invalid DBus message received: %s",
self.name,
ex,
exc_info=True,
)
msg = (
f"{self.name}: Invalid DBus message received: {ex}; "
"try restarting `dbus`"
)
raise ScannerStartError(msg) from ex
def _async_scanner_watchdog(self) -> None:
"""Check if the scanner is running."""
if not self._async_watchdog_triggered():
return
if self._start_stop_lock.locked():
_LOGGER.debug(
"%s: Scanner is already restarting, deferring restart",
self.name,
)
return
_LOGGER.debug(
"%s: Bluetooth scanner has gone quiet for %ss, restarting",
self.name,
self.time_since_last_detection(),
)
# Immediately mark the scanner as not scanning
# since the restart task will have to wait for the lock
self.scanning = False
self._create_background_task(self._async_restart_scanner())
async def _async_restart_scanner(self) -> None:
"""Restart the scanner."""
async with self._start_stop_lock:
# Stop the scanner but not the watchdog
# since we want to try again later if it's still quiet
await self._async_stop_scanner()
# If there have not been any valid advertisements,
# or the watchdog has hit the failure path multiple times,
# do the reset.
if (
self._start_time == self._last_detection
or self.time_since_last_detection() > SCANNER_WATCHDOG_MULTIPLE
):
await self._async_reset_adapter(True)
try:
await self._async_start()
except ScannerStartError as ex:
_LOGGER.exception(
"%s: Failed to restart Bluetooth scanner: %s",
self.name,
ex,
)
async def _async_reset_adapter(self, gone_silent: bool) -> None:
"""Reset the adapter."""
# There is currently nothing the user can do to fix this
# so we log at debug level. If we later come up with a repair
# strategy, we will change this to raise a repair issue as well.
_LOGGER.debug("%s: adapter stopped responding; executing reset", self.name)
result = await async_reset_adapter(self.adapter, self.mac_address, gone_silent)
_LOGGER.debug("%s: adapter reset result: %s", self.name, result)
async def async_stop(self) -> None:
"""Stop bluetooth scanner."""
if self._start_future is not None and not self._start_future.done():
self._start_future.set_exception(_AbortStartError())
async with self._start_stop_lock:
self._async_stop_scanner_watchdog()
await self._async_stop_scanner()
async def _async_stop_scanner(self) -> None:
"""Stop bluetooth discovery under the lock."""
self.scanning = False
if self.scanner is None:
_LOGGER.debug("%s: Scanner is already stopped", self.name)
return
_LOGGER.debug("%s: Stopping bluetooth discovery", self.name)
try:
async with asyncio.timeout(STOP_TIMEOUT):
await self.scanner.stop()
except (asyncio.TimeoutError, BleakError) as ex:
# This is not fatal, and they may want to reload
# the config entry to restart the scanner if they
# change the bluetooth dongle.
_LOGGER.error("%s: Error stopping scanner: %s", self.name, ex)
self.scanner = None
async def _async_force_stop_discovery(self) -> None:
"""Force stop discovery."""
_LOGGER.debug("%s: Force stopping bluetooth discovery", self.name)
try:
async with asyncio.timeout(STOP_TIMEOUT):
await stop_discovery(self.adapter)
except asyncio.TimeoutError as ex:
_LOGGER.error("%s: Timeout force stopping scanner: %s", self.name, ex)
except Exception as ex:
_LOGGER.error("%s: Failed to force stop scanner: %s", self.name, ex)
habluetooth-3.48.2/src/habluetooth/scanner_device.py 0000664 0000000 0000000 00000001334 15005442573 0022563 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)
habluetooth-3.48.2/src/habluetooth/storage.py 0000664 0000000 0000000 00000024127 15005442573 0021264 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
details: dict[str, Any]
LOCAL_NAME: Final = "local_name"
MANUFACTURER_DATA: Final = "manufacturer_data"
SERVICE_DATA: Final = "service_data"
SERVICE_UUIDS: Final = "service_uuids"
TX_POWER: Final = "tx_power"
PLATFORM_DATA: Final = "platform_data"
class AdvertisementDataDict(TypedDict):
"""AdvertisementData dict."""
local_name: str | None
manufacturer_data: dict[str, str]
service_data: dict[str, str]
service_uuids: list[str]
rssi: int
tx_power: int | None
platform_data: list[Any]
class DiscoveredDeviceDict(TypedDict):
"""Discovered device dict."""
device: BLEDeviceDict
advertisement_data: AdvertisementDataDict
def expire_stale_scanner_discovered_device_advertisement_data(
data_by_scanner: dict[str, DiscoveredDeviceAdvertisementDataDict],
) -> None:
"""Expire stale discovered device advertisement data."""
now = time.time()
expired_scanners: list[str] = []
for scanner, data in data_by_scanner.items():
expire: list[str] = []
expire_seconds = data[EXPIRE_SECONDS]
timestamps = data[DISCOVERED_DEVICE_TIMESTAMPS]
discovered_device_advertisement_datas = data[
DISCOVERED_DEVICE_ADVERTISEMENT_DATAS
]
discovered_device_raw = data.get(DISCOVERED_DEVICE_RAW, {})
for address, timestamp in timestamps.items():
time_diff = now - timestamp
if time_diff > expire_seconds:
expire.append(address)
elif time_diff < 0:
_LOGGER.warning(
"Discarding timestamp %s for %s on "
"scanner %s as it is the future (now = %s)",
timestamp,
address,
scanner,
now,
)
expire.append(address)
for address in expire:
del timestamps[address]
del discovered_device_advertisement_datas[address]
discovered_device_raw.pop(address, None)
if not timestamps:
expired_scanners.append(scanner)
_LOGGER.debug(
"Loaded %s fresh discovered devices for %s", len(timestamps), scanner
)
for scanner in expired_scanners:
del data_by_scanner[scanner]
def discovered_device_advertisement_data_from_dict(
data: DiscoveredDeviceAdvertisementDataDict,
) -> DiscoveredDeviceAdvertisementData | None:
"""Build discovered_device_advertisement_data dict."""
try:
return DiscoveredDeviceAdvertisementData(
data[CONNECTABLE],
data[EXPIRE_SECONDS],
_deserialize_discovered_device_advertisement_datas(
data[DISCOVERED_DEVICE_ADVERTISEMENT_DATAS]
),
_deserialize_discovered_device_timestamps(
data[DISCOVERED_DEVICE_TIMESTAMPS]
),
_deserialize_discovered_device_raw(data.get(DISCOVERED_DEVICE_RAW, {})),
)
except Exception as err: # pylint: disable=broad-except
_LOGGER.exception(
"Error deserializing discovered_device_advertisement_data"
", adapter startup will be slow: %s",
err,
)
return None
def discovered_device_advertisement_data_to_dict(
data: DiscoveredDeviceAdvertisementData,
) -> DiscoveredDeviceAdvertisementDataDict:
"""Build discovered_device_advertisement_data dict."""
return DiscoveredDeviceAdvertisementDataDict(
connectable=data.connectable,
expire_seconds=data.expire_seconds,
discovered_device_advertisement_datas=_serialize_discovered_device_advertisement_datas(
data.discovered_device_advertisement_datas
),
discovered_device_timestamps=_serialize_discovered_device_timestamps(
data.discovered_device_timestamps
),
discovered_device_raw=_serialize_discovered_device_raw(
data.discovered_device_raw
),
)
def _serialize_discovered_device_advertisement_datas(
discovered_device_advertisement_datas: dict[
str, tuple[BLEDevice, AdvertisementData]
],
) -> dict[str, DiscoveredDeviceDict]:
"""Serialize discovered_device_advertisement_datas."""
return {
address: DiscoveredDeviceDict(
device=_ble_device_to_dict(device, advertisement_data),
advertisement_data=_advertisement_data_to_dict(advertisement_data),
)
for (
address,
(device, advertisement_data),
) in discovered_device_advertisement_datas.items()
}
def _deserialize_discovered_device_advertisement_datas(
discovered_device_advertisement_datas: dict[str, DiscoveredDeviceDict],
) -> dict[str, tuple[BLEDevice, AdvertisementData]]:
"""Deserialize discovered_device_advertisement_datas."""
return {
address: (
BLEDevice(**device_advertisement_data["device"]),
_advertisement_data_from_dict(
device_advertisement_data["advertisement_data"]
),
)
for (
address,
device_advertisement_data,
) in discovered_device_advertisement_datas.items()
}
def _ble_device_to_dict(
ble_device: BLEDevice, advertisement_data: AdvertisementData
) -> BLEDeviceDict:
"""Serialize ble_device."""
return BLEDeviceDict(
address=ble_device.address,
name=ble_device.name,
rssi=advertisement_data.rssi, # For backwards compatibility
details=ble_device.details,
)
def _advertisement_data_from_dict(
advertisement_data: AdvertisementDataDict,
) -> AdvertisementData:
"""Deserialize advertisement_data."""
return AdvertisementData(
local_name=advertisement_data[LOCAL_NAME],
manufacturer_data={
int(manufacturer_id): bytes.fromhex(manufacturer_data)
for manufacturer_id, manufacturer_data in advertisement_data[
MANUFACTURER_DATA
].items()
},
service_data={
service_uuid: bytes.fromhex(service_data)
for service_uuid, service_data in advertisement_data[SERVICE_DATA].items()
},
service_uuids=advertisement_data[SERVICE_UUIDS],
rssi=advertisement_data[RSSI],
tx_power=advertisement_data[TX_POWER],
platform_data=tuple(advertisement_data[PLATFORM_DATA]),
)
def _advertisement_data_to_dict(
advertisement_data: AdvertisementData,
) -> AdvertisementDataDict:
"""Serialize advertisement_data."""
return AdvertisementDataDict(
local_name=advertisement_data.local_name,
manufacturer_data={
str(manufacturer_id): manufacturer_data.hex()
for manufacturer_id, manufacturer_data in advertisement_data.manufacturer_data.items() # noqa: E501
},
service_data={
service_uuid: service_data.hex()
for service_uuid, service_data in advertisement_data.service_data.items()
},
service_uuids=advertisement_data.service_uuids,
rssi=advertisement_data.rssi,
tx_power=advertisement_data.tx_power,
platform_data=list(advertisement_data.platform_data),
)
def _get_monotonic_time_diff() -> float:
"""Get monotonic time diff."""
return time.time() - time.monotonic()
def _deserialize_discovered_device_timestamps(
discovered_device_timestamps: dict[str, float],
) -> dict[str, float]:
"""Deserialize discovered_device_timestamps."""
time_diff = _get_monotonic_time_diff()
return {
address: unix_time - time_diff
for address, unix_time in discovered_device_timestamps.items()
}
def _serialize_discovered_device_timestamps(
discovered_device_timestamps: dict[str, float],
) -> dict[str, float]:
"""Serialize discovered_device_timestamps."""
time_diff = _get_monotonic_time_diff()
return {
address: monotonic_time + time_diff
for address, monotonic_time in discovered_device_timestamps.items()
}
def _deserialize_discovered_device_raw(
discovered_device_raw: dict[str, str | None],
) -> dict[str, bytes | None]:
"""Deserialize discovered_device_timestamps."""
return {
address: None if raw is None else bytes.fromhex(raw)
for address, raw in discovered_device_raw.items()
}
def _serialize_discovered_device_raw(
discovered_device_raw: dict[str, bytes | None],
) -> dict[str, str | None]:
"""Serialize discovered_device_timestamps."""
return {
address: None if raw is None else raw.hex()
for address, raw in discovered_device_raw.items()
}
DiscoveryStorageType = dict[str, DiscoveredDeviceAdvertisementDataDict]
habluetooth-3.48.2/src/habluetooth/usage.py 0000664 0000000 0000000 00000003305 15005442573 0020717 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.
"""
habluetooth-3.48.2/src/habluetooth/util.py 0000664 0000000 0000000 00000001106 15005442573 0020565 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()
habluetooth-3.48.2/src/habluetooth/wrappers.py 0000664 0000000 0000000 00000034534 15005442573 0021466 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
from bleak.backends.client import BaseBleakClient, get_platform_client_backend_type
from bleak.backends.device import BLEDevice
from bleak.backends.scanner import (
AdvertisementData,
AdvertisementDataCallback,
BaseBleakScanner,
)
from bleak_retry_connector import (
ble_device_description,
clear_cache,
device_source,
)
from .central_manager import get_manager
from .const import CALLBACK_TYPE
FILTER_UUIDS: Final = "UUIDs"
_LOGGER = logging.getLogger(__name__)
if TYPE_CHECKING:
from .base_scanner import BaseHaScanner
from .manager import BluetoothManager
@dataclass(slots=True)
class _HaWrappedBleakBackend:
"""Wrap bleak backend to make it usable by Home Assistant."""
device: BLEDevice
scanner: BaseHaScanner
client: type[BaseBleakClient]
source: str | None
class HaBleakScannerWrapper(BaseBleakScanner):
"""A wrapper that uses the single instance."""
def __init__(
self,
*args: Any,
detection_callback: AdvertisementDataCallback | None = None,
service_uuids: list[str] | None = None,
**kwargs: Any,
) -> None:
"""Initialize the BleakScanner."""
self._detection_cancel: CALLBACK_TYPE | None = None
self._mapped_filters: dict[str, set[str]] = {}
self._advertisement_data_callback: AdvertisementDataCallback | None = None
self._background_tasks: set[asyncio.Task[Any]] = set()
remapped_kwargs = {
"detection_callback": detection_callback,
"service_uuids": service_uuids or [],
**kwargs,
}
self._map_filters(*args, **remapped_kwargs)
super().__init__(
detection_callback=detection_callback, service_uuids=service_uuids or []
)
@classmethod
async def find_device_by_address(
cls, device_identifier: str, timeout: float = 10.0, **kwargs: Any
) -> BLEDevice | None:
"""Find a device by address."""
manager = get_manager()
return manager.async_ble_device_from_address(
device_identifier, True
) or manager.async_ble_device_from_address(device_identifier, False)
@overload
@classmethod
async def discover(
cls, timeout: float = 5.0, *, return_adv: Literal[False] = False, **kwargs: Any
) -> list[BLEDevice]: ...
@overload
@classmethod
async def discover(
cls, timeout: float = 5.0, *, return_adv: Literal[True], **kwargs: Any
) -> dict[str, tuple[BLEDevice, AdvertisementData]]: ...
@classmethod
async def discover(
cls, timeout: float = 5.0, *, return_adv: bool = False, **kwargs: Any
) -> list[BLEDevice] | dict[str, tuple[BLEDevice, AdvertisementData]]:
"""Discover devices."""
infos = get_manager().async_discovered_service_info(True)
if return_adv:
return {info.address: (info.device, info.advertisement) for info in infos}
return [info.device for info in infos]
async def stop(self, *args: Any, **kwargs: Any) -> None:
"""Stop scanning for devices."""
async def start(self, *args: Any, **kwargs: Any) -> None:
"""Start scanning for devices."""
def _map_filters(self, *args: Any, **kwargs: Any) -> bool:
"""Map the filters."""
mapped_filters = {}
if filters := kwargs.get("filters"):
if filter_uuids := filters.get(FILTER_UUIDS):
mapped_filters[FILTER_UUIDS] = set(filter_uuids)
else:
_LOGGER.warning("Only %s filters are supported", FILTER_UUIDS)
if service_uuids := kwargs.get("service_uuids"):
mapped_filters[FILTER_UUIDS] = set(service_uuids)
if mapped_filters == self._mapped_filters:
return False
self._mapped_filters = mapped_filters
return True
def set_scanning_filter(self, *args: Any, **kwargs: Any) -> None:
"""Set the filters to use."""
if self._map_filters(*args, **kwargs):
self._setup_detection_callback()
def _cancel_callback(self) -> None:
"""Cancel callback."""
if self._detection_cancel:
self._detection_cancel()
self._detection_cancel = None
@property
def discovered_devices(self) -> list[BLEDevice]:
"""Return a list of discovered devices."""
return list(get_manager().async_discovered_devices(True))
def register_detection_callback(
self, callback: AdvertisementDataCallback | None
) -> Callable[[], None]:
"""
Register a detection callback.
The callback is called when a device is discovered or has a property changed.
This method takes the callback and registers it with the long running scanner.
"""
self._advertisement_data_callback = callback
self._setup_detection_callback()
if TYPE_CHECKING:
assert self._detection_cancel is not None
return self._detection_cancel
def _setup_detection_callback(self) -> None:
"""Set up the detection callback."""
if self._advertisement_data_callback is None:
return
callback = self._advertisement_data_callback
self._cancel_callback()
super().register_detection_callback(self._advertisement_data_callback)
manager = get_manager()
if not inspect.iscoroutinefunction(callback):
detection_callback = callback
else:
def detection_callback(
ble_device: BLEDevice, advertisement_data: AdvertisementData
) -> None:
task = asyncio.create_task(callback(ble_device, advertisement_data))
self._background_tasks.add(task)
task.add_done_callback(self._background_tasks.discard)
self._detection_cancel = manager.async_register_bleak_callback(
detection_callback, self._mapped_filters
)
def __del__(self) -> None:
"""Delete the BleakScanner."""
if self._detection_cancel:
# Nothing to do if event loop is already closed
with contextlib.suppress(RuntimeError):
asyncio.get_running_loop().call_soon_threadsafe(self._detection_cancel)
class HaBleakClientWrapper(BleakClient):
"""
Wrap the BleakClient to ensure it does not shutdown our scanner.
If an address is passed into BleakClient instead of a BLEDevice,
bleak will quietly start a new scanner under the hood to resolve
the address. This can cause a conflict with our scanner. We need
to handle translating the address to the BLEDevice in this case
to avoid the whole stack from getting stuck in an in progress state
when an integration does this.
"""
def __init__( # pylint: disable=super-init-not-called
self,
address_or_ble_device: str | BLEDevice,
disconnected_callback: Callable[[BleakClient], None] | None = None,
*args: Any,
timeout: float = 10.0,
**kwargs: Any,
) -> None:
"""Initialize the BleakClient."""
if isinstance(address_or_ble_device, BLEDevice):
self.__address = address_or_ble_device.address
else:
# If we are passed an address we need to make sure
# its not a subclassed str
self.__address = str(address_or_ble_device)
self.__disconnected_callback = disconnected_callback
self.__manager = get_manager()
self.__timeout = timeout
self._backend: BaseBleakClient | None = None
@property
def is_connected(self) -> bool:
"""Return True if the client is connected to a device."""
return self._backend is not None and self._backend.is_connected
async def clear_cache(self) -> bool:
"""Clear the GATT cache."""
if self._backend is not None and hasattr(self._backend, "clear_cache"):
return await self._backend.clear_cache()
return await clear_cache(self.__address)
def set_disconnected_callback(
self,
callback: Callable[[BleakClient], None] | None,
**kwargs: Any,
) -> None:
"""Set the disconnect callback."""
self.__disconnected_callback = callback
if self._backend:
self._backend.set_disconnected_callback(
self._make_disconnected_callback(callback),
**kwargs,
)
def _make_disconnected_callback(
self, callback: Callable[[BleakClient], None] | None
) -> Callable[[], None] | None:
"""
Make the disconnected callback.
https://github.com/hbldh/bleak/pull/1256
The disconnected callback needs to get the top level
BleakClientWrapper instance, not the backend instance.
The signature of the callback for the backend is:
Callable[[], None]
To make this work we need to wrap the callback in a partial
that passes the BleakClientWrapper instance as the first
argument.
"""
return None if callback is None else partial(callback, self)
async def connect(self, **kwargs: Any) -> bool:
"""Connect to the specified GATT server."""
manager = self.__manager
if manager.shutdown:
raise BleakError("Bluetooth is already shutdown")
if debug_logging := _LOGGER.isEnabledFor(logging.DEBUG):
_LOGGER.debug("%s: Looking for backend to connect", self.__address)
wrapped_backend = self._async_get_best_available_backend_and_device(manager)
device = wrapped_backend.device
scanner = wrapped_backend.scanner
self._backend = wrapped_backend.client(
device,
disconnected_callback=self._make_disconnected_callback(
self.__disconnected_callback
),
timeout=self.__timeout,
)
if debug_logging:
# Only lookup the description if we are going to log it
description = ble_device_description(device)
device_adv = scanner.get_discovered_device_advertisement_data(
device.address
)
if TYPE_CHECKING:
assert device_adv is not None
adv = device_adv[1]
rssi = adv.rssi
_LOGGER.debug(
"%s: Connecting via %s (last rssi: %s)", description, scanner.name, rssi
)
connected = False
address = device.address
try:
scanner._add_connecting(address)
connected = await super().connect(**kwargs)
finally:
scanner._finished_connecting(address, connected)
# If we failed to connect and its a local adapter (no source)
# we release the connection slot
if not connected and not wrapped_backend.source:
manager.async_release_connection_slot(device)
if debug_logging:
_LOGGER.debug(
"%s: %s via %s (last rssi: %s)",
description,
"Connected" if connected else "Failed to connect",
scanner.name,
rssi,
)
return connected
def _async_get_backend_for_ble_device(
self, manager: BluetoothManager, scanner: BaseHaScanner, ble_device: BLEDevice
) -> _HaWrappedBleakBackend | None:
"""Get the backend for a BLEDevice."""
if not (source := device_source(ble_device)):
# If client is not defined in details
# its the client for this platform
if not manager.async_allocate_connection_slot(ble_device):
return None
cls = get_platform_client_backend_type()
return _HaWrappedBleakBackend(ble_device, scanner, cls, source)
# Make sure the backend can connect to the device
# as some backends have connection limits
if not scanner.connector or not scanner.connector.can_connect():
return None
return _HaWrappedBleakBackend(
ble_device, scanner, scanner.connector.client, source
)
def _async_get_best_available_backend_and_device(
self, manager: BluetoothManager
) -> _HaWrappedBleakBackend:
"""
Get a best available backend and device for the given address.
This method will return the backend with the best rssi
that has a free connection slot.
"""
address = self.__address
sorted_devices = sorted(
manager.async_scanner_devices_by_address(self.__address, True),
key=lambda x: x.advertisement.rssi,
reverse=True,
)
if len(sorted_devices) > 1:
rssi_diff = (
sorted_devices[0].advertisement.rssi
- sorted_devices[1].advertisement.rssi
)
sorted_devices = sorted(
sorted_devices,
key=lambda device: device.score_connection_path(rssi_diff),
reverse=True,
)
if sorted_devices and _LOGGER.isEnabledFor(logging.INFO):
_LOGGER.info(
"%s - %s: Found %s connection path(s), preferred order: %s",
address,
sorted_devices[0].ble_device.name,
len(sorted_devices),
", ".join(
f"{device.scanner.name} "
f"(RSSI={device.advertisement.rssi}) "
f"(failures={device.scanner._connection_failures(address)}) "
f"(in_progress={device.scanner._connections_in_progress()}) "
f"(score={device.score_connection_path(0)})"
for device in sorted_devices
),
)
for device in sorted_devices:
if backend := self._async_get_backend_for_ble_device(
manager, device.scanner, device.ble_device
):
return backend
raise BleakError(
"No backend with an available connection slot that can reach address"
f" {address} was found"
)
async def disconnect(self) -> bool:
"""Disconnect from the device."""
if self._backend is None:
return True
return await self._backend.disconnect()
habluetooth-3.48.2/templates/ 0000775 0000000 0000000 00000000000 15005442573 0016131 5 ustar 00root root 0000000 0000000 habluetooth-3.48.2/templates/CHANGELOG.md.j2 0000664 0000000 0000000 00000001235 15005442573 0020255 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 #}
habluetooth-3.48.2/tests/ 0000775 0000000 0000000 00000000000 15005442573 0015275 5 ustar 00root root 0000000 0000000 habluetooth-3.48.2/tests/__init__.py 0000664 0000000 0000000 00000012006 15005442573 0017405 0 ustar 00root root 0000000 0000000 import asyncio
import time
from contextlib import contextmanager
from datetime import datetime, timezone
from functools import partial
from typing import Any, Generator
from unittest.mock import MagicMock, patch
from bleak.backends.scanner import AdvertisementData, BLEDevice
from habluetooth import get_manager
from habluetooth.models import BluetoothServiceInfoBleak
utcnow = partial(datetime.now, timezone.utc)
HCI0_SOURCE_ADDRESS = "AA:BB:CC:DD:EE:00"
HCI1_SOURCE_ADDRESS = "AA:BB:CC:DD:EE:11"
NON_CONNECTABLE_REMOTE_SOURCE_ADDRESS = "AA:BB:CC:DD:EE:FF"
_MONOTONIC_RESOLUTION = time.get_clock_info("monotonic").resolution
ADVERTISEMENT_DATA_DEFAULTS = {
"local_name": "Unknown",
"manufacturer_data": {},
"service_data": {},
"service_uuids": [],
"rssi": -127,
"platform_data": ((),),
"tx_power": -127,
}
BLE_DEVICE_DEFAULTS = {
"name": None,
"rssi": -127,
"details": None,
}
def generate_advertisement_data(**kwargs: Any) -> AdvertisementData:
"""Generate advertisement data with defaults."""
new = kwargs.copy()
for key, value in ADVERTISEMENT_DATA_DEFAULTS.items():
new.setdefault(key, value)
return AdvertisementData(**new)
def generate_ble_device(
address: str | None = None,
name: str | None = None,
details: Any | None = None,
rssi: int | None = None,
**kwargs: Any,
) -> BLEDevice:
"""Generate a BLEDevice with defaults."""
new = kwargs.copy()
if address is not None:
new["address"] = address
if name is not None:
new["name"] = name
if details is not None:
new["details"] = details
if rssi is not None:
new["rssi"] = rssi
for key, value in BLE_DEVICE_DEFAULTS.items():
new.setdefault(key, value)
return BLEDevice(**new)
@contextmanager
def patch_bluetooth_time(mock_time: float) -> Generator[Any, None, None]:
"""Patch the bluetooth time."""
with (
patch("habluetooth.base_scanner.monotonic_time_coarse", return_value=mock_time),
patch("habluetooth.manager.monotonic_time_coarse", return_value=mock_time),
patch("habluetooth.scanner.monotonic_time_coarse", return_value=mock_time),
):
yield
def async_fire_time_changed(utc_datetime: datetime) -> None:
timestamp = utc_datetime.timestamp()
loop = asyncio.get_running_loop()
for task in list(loop._scheduled): # type: ignore[attr-defined]
if not isinstance(task, asyncio.TimerHandle):
continue
if task.cancelled():
continue
mock_seconds_into_future = timestamp - time.time()
future_seconds = task.when() - (loop.time() + _MONOTONIC_RESOLUTION)
if mock_seconds_into_future >= future_seconds:
task._run()
task.cancel()
class MockBleakClient:
pass
def inject_advertisement(device: BLEDevice, adv: AdvertisementData) -> None:
"""Inject an advertisement into the manager."""
return inject_advertisement_with_source(device, adv, "local")
def inject_advertisement_with_source(
device: BLEDevice, adv: AdvertisementData, source: str
) -> None:
"""Inject an advertisement into the manager from a specific source."""
inject_advertisement_with_time_and_source(device, adv, time.monotonic(), source)
def inject_advertisement_with_time_and_source(
device: BLEDevice,
adv: AdvertisementData,
time: float,
source: str,
) -> None:
"""Inject an advertisement into the manager from a specific source at a time."""
inject_advertisement_with_time_and_source_connectable(
device, adv, time, source, True
)
def inject_advertisement_with_time_and_source_connectable(
device: BLEDevice,
adv: AdvertisementData,
time: float,
source: str,
connectable: bool,
) -> None:
"""
Inject an advertisement into the manager from a specific source at a time.
As well as and connectable status.
"""
manager = get_manager()
manager.scanner_adv_received(
BluetoothServiceInfoBleak(
name=adv.local_name or device.name or device.address,
address=device.address,
rssi=adv.rssi,
manufacturer_data=adv.manufacturer_data,
service_data=adv.service_data,
service_uuids=adv.service_uuids,
source=source,
device=device,
advertisement=adv,
connectable=connectable,
time=time,
tx_power=adv.tx_power,
)
)
@contextmanager
def patch_discovered_devices(
mock_discovered: list[BLEDevice],
) -> Generator[None, None, None]:
"""Mock the combined best path to discovered devices from all the scanners."""
manager = get_manager()
original_all_history = manager._all_history
original_connectable_history = manager._connectable_history
manager._connectable_history = {}
manager._all_history = {
device.address: MagicMock(device=device) for device in mock_discovered
}
yield
manager._all_history = original_all_history
manager._connectable_history = original_connectable_history
habluetooth-3.48.2/tests/conftest.py 0000664 0000000 0000000 00000016234 15005442573 0017502 0 ustar 00root root 0000000 0000000 from collections.abc import Iterable
from typing import AsyncGenerator, Generator
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
import pytest_asyncio
from bleak.backends.scanner import AdvertisementData, BLEDevice
from bleak_retry_connector import BleakSlotManager
from bluetooth_adapters import AdapterDetails, BluetoothAdapters
from habluetooth import (
BaseHaRemoteScanner,
BaseHaScanner,
BluetoothManager,
get_manager,
set_manager,
)
from habluetooth import scanner as bluetooth_scanner
class FakeBluetoothAdapters(BluetoothAdapters):
@property
def adapters(self) -> dict[str, AdapterDetails]:
return {}
class FakeScannerMixin:
def get_discovered_device_advertisement_data(
self, address: str
) -> tuple[BLEDevice, AdvertisementData] | None:
"""Return the advertisement data for a discovered device."""
return self.discovered_devices_and_advertisement_data.get(address) # type: ignore[attr-defined]
@property
def discovered_addresses(self) -> Iterable[str]:
"""Return an iterable of discovered devices."""
return self.discovered_devices_and_advertisement_data # type: ignore[attr-defined]
class FakeScanner(FakeScannerMixin, BaseHaScanner):
"""Fake scanner."""
@property
def discovered_devices(self) -> list[BLEDevice]:
"""Return a list of discovered devices."""
return []
@property
def discovered_devices_and_advertisement_data(
self,
) -> dict[str, tuple[BLEDevice, AdvertisementData]]:
"""Return a list of discovered devices and their advertisement data."""
return {}
@pytest_asyncio.fixture(scope="session", autouse=True)
async def manager() -> AsyncGenerator[None, None]:
slot_manager = BleakSlotManager()
bluetooth_adapters = FakeBluetoothAdapters()
manager = BluetoothManager(bluetooth_adapters, slot_manager)
set_manager(manager)
await manager.async_setup()
yield
manager.async_stop()
@pytest_asyncio.fixture(name="enable_bluetooth")
async def mock_enable_bluetooth(
mock_bleak_scanner_start: MagicMock,
mock_bluetooth_adapters: None,
) -> AsyncGenerator[None, None]:
"""Fixture to mock starting the bleak scanner."""
manager = get_manager()
assert manager._bluetooth_adapters is not None
await manager.async_setup()
yield
manager._all_history.clear()
manager._connectable_history.clear()
manager._unavailable_callbacks.clear()
manager._connectable_unavailable_callbacks.clear()
manager._bleak_callbacks.clear()
manager._fallback_intervals.clear()
manager._intervals.clear()
manager._adapter_sources.clear()
manager._adapters.clear()
manager._sources.clear()
manager._allocations.clear()
manager._non_connectable_scanners.clear()
manager._connectable_scanners.clear()
@pytest.fixture(scope="session")
def mock_bluetooth_adapters() -> Generator[None, None, None]:
"""Fixture to mock bluetooth adapters."""
with (
patch("bluetooth_auto_recovery.recover_adapter"),
patch("bluetooth_adapters.systems.platform.system", return_value="Linux"),
patch("bluetooth_adapters.systems.linux.LinuxAdapters.refresh"),
patch(
"bluetooth_adapters.systems.linux.LinuxAdapters.adapters",
{
"hci0": {
"address": "00:00:00:00:00:01",
"hw_version": "usb:v1D6Bp0246d053F",
"passive_scan": False,
"sw_version": "homeassistant",
"manufacturer": "ACME",
"product": "Bluetooth Adapter 5.0",
"product_id": "aa01",
"vendor_id": "cc01",
},
},
),
):
yield
@pytest.fixture
def mock_bleak_scanner_start() -> Generator[MagicMock, None, None]:
"""Fixture to mock starting the bleak scanner."""
bluetooth_scanner.OriginalBleakScanner.stop = AsyncMock()
with (
patch.object(
bluetooth_scanner.OriginalBleakScanner,
"start",
) as mock_bleak_scanner_start,
patch.object(bluetooth_scanner, "HaScanner"),
):
yield mock_bleak_scanner_start
@pytest.fixture(name="two_adapters")
def two_adapters_fixture():
"""Fixture that mocks two adapters on Linux."""
with (
patch(
"habluetooth.scanner.platform.system",
return_value="Linux",
),
patch("bluetooth_adapters.systems.platform.system", return_value="Linux"),
patch("bluetooth_adapters.systems.linux.LinuxAdapters.refresh"),
patch(
"bluetooth_adapters.systems.linux.LinuxAdapters.adapters",
{
"hci0": {
"address": "00:00:00:00:00:01",
"hw_version": "usb:v1D6Bp0246d053F",
"passive_scan": False,
"sw_version": "homeassistant",
"manufacturer": "ACME",
"product": "Bluetooth Adapter 5.0",
"product_id": "aa01",
"vendor_id": "cc01",
"connection_slots": 1,
},
"hci1": {
"address": "00:00:00:00:00:02",
"hw_version": "usb:v1D6Bp0246d053F",
"passive_scan": True,
"sw_version": "homeassistant",
"manufacturer": "ACME",
"product": "Bluetooth Adapter 5.0",
"product_id": "aa01",
"vendor_id": "cc01",
"connection_slots": 2,
},
},
),
):
yield
@pytest.fixture(name="macos_adapter")
def macos_adapter() -> Generator[None, None, None]:
"""Fixture that mocks the macos adapter."""
with (
patch("bleak.get_platform_scanner_backend_type"),
patch(
"habluetooth.scanner.platform.system",
return_value="Darwin",
),
patch(
"bluetooth_adapters.systems.platform.system",
return_value="Darwin",
),
patch("habluetooth.scanner.SYSTEM", "Darwin"),
):
yield
@pytest.fixture
def register_hci0_scanner() -> Generator[None, None, None]:
"""Register an hci0 scanner."""
hci0_scanner = FakeScanner("AA:BB:CC:DD:EE:00", "hci0")
hci0_scanner.connectable = True
manager = get_manager()
cancel = manager.async_register_scanner(hci0_scanner, connection_slots=5)
yield
cancel()
@pytest.fixture
def register_hci1_scanner() -> Generator[None, None, None]:
"""Register an hci1 scanner."""
hci1_scanner = FakeScanner("AA:BB:CC:DD:EE:11", "hci1")
hci1_scanner.connectable = True
manager = get_manager()
cancel = manager.async_register_scanner(hci1_scanner, connection_slots=5)
yield
cancel()
@pytest.fixture
def register_non_connectable_scanner() -> Generator[None, None, None]:
"""Register an non connectable remote scanner."""
remote_scanner = BaseHaRemoteScanner(
"AA:BB:CC:DD:EE:FF", "non connectable", None, False
)
manager = get_manager()
cancel = manager.async_register_scanner(remote_scanner)
yield
cancel()
habluetooth-3.48.2/tests/test_base_scanner.py 0000664 0000000 0000000 00000062126 15005442573 0021340 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 unittest.mock import ANY
import pytest
from bleak.backends.device import BLEDevice
from bleak.backends.scanner import AdvertisementData
from bluetooth_data_tools import monotonic_time_coarse
from habluetooth import (
BaseHaRemoteScanner,
BluetoothScanningMode,
HaBluetoothConnector,
HaScannerDetails,
get_manager,
)
from habluetooth.const import (
CONNECTABLE_FALLBACK_MAXIMUM_STALE_ADVERTISEMENT_SECONDS,
FALLBACK_MAXIMUM_STALE_ADVERTISEMENT_SECONDS,
SCANNER_WATCHDOG_INTERVAL,
SCANNER_WATCHDOG_TIMEOUT,
)
from habluetooth.storage import (
DiscoveredDeviceAdvertisementData,
)
from . import (
HCI0_SOURCE_ADDRESS,
MockBleakClient,
async_fire_time_changed,
generate_advertisement_data,
generate_ble_device,
patch_bluetooth_time,
utcnow,
)
class FakeScanner(BaseHaRemoteScanner):
"""Fake scanner."""
def inject_advertisement(
self,
device: BLEDevice,
advertisement_data: AdvertisementData,
now: float | None = None,
) -> None:
"""Inject an advertisement."""
self._async_on_advertisement(
device.address,
advertisement_data.rssi,
device.name,
advertisement_data.service_uuids,
advertisement_data.service_data,
advertisement_data.manufacturer_data,
advertisement_data.tx_power,
{"scanner_specific_data": "test"},
now or monotonic_time_coarse(),
)
def inject_raw_advertisement(
self,
address: str,
rssi: int,
adv: bytes,
now: float | None = None,
) -> None:
"""Inject a raw advertisement."""
self._async_on_raw_advertisement(
address,
rssi,
adv,
{"scanner_specific_data": "test"},
now or monotonic_time_coarse(),
)
@pytest.mark.parametrize("name_2", [None, "w"])
@pytest.mark.usefixtures("enable_bluetooth")
@pytest.mark.asyncio
async def test_remote_scanner(name_2: str | None) -> None:
"""Test the remote scanner base class merges advertisement_data."""
manager = get_manager()
switchbot_device = generate_ble_device(
"44:44:33:11:23:45",
"wohand",
{},
rssi=-100,
)
switchbot_device_adv = generate_advertisement_data(
local_name="wohand",
service_uuids=["050a021a-0000-1000-8000-00805f9b34fb"],
service_data={"050a021a-0000-1000-8000-00805f9b34fb": b"\n\xff"},
manufacturer_data={1: b"\x01"},
rssi=-100,
)
switchbot_device_2 = generate_ble_device(
"44:44:33:11:23:45",
name_2,
{},
rssi=-100,
)
switchbot_device_adv_2 = generate_advertisement_data(
local_name=name_2,
service_uuids=["00000001-0000-1000-8000-00805f9b34fb"],
service_data={"00000001-0000-1000-8000-00805f9b34fb": b"\n\xff"},
manufacturer_data={1: b"\x01", 2: b"\x02"},
rssi=-100,
)
switchbot_device_3 = generate_ble_device(
"44:44:33:11:23:45",
"wohandlonger",
{},
rssi=-100,
)
switchbot_device_adv_3 = generate_advertisement_data(
local_name="wohandlonger",
service_uuids=["00000001-0000-1000-8000-00805f9b34fb"],
service_data={"00000001-0000-1000-8000-00805f9b34fb": b"\n\xff"},
manufacturer_data={1: b"\x01", 2: b"\x02"},
rssi=-100,
)
switchbot_device_adv_4 = generate_advertisement_data(
local_name="wohandlonger",
service_uuids=["00000001-0000-1000-8000-00805f9b34fb"],
service_data={"00000001-0000-1000-8000-00805f9b34fb": b"\n\xff"},
manufacturer_data={1: b"\x04", 2: b"\x02", 3: b"\x03"},
rssi=-100,
)
switchbot_device_adv_5 = generate_advertisement_data(
local_name="wohandlonger",
service_uuids=["00000001-0000-1000-8000-00805f9b34fb"],
service_data={"00000001-0000-1000-8000-00805f9b34fb": b"\n\xff"},
manufacturer_data={1: b"\x04", 2: b"\x01"},
rssi=-100,
)
connector = HaBluetoothConnector(
MockBleakClient, "mock_bleak_client", lambda: False
)
scanner = FakeScanner("esp32", "esp32", connector, True)
details = scanner.details
assert details == HaScannerDetails(
source=scanner.source,
connectable=scanner.connectable,
name=scanner.name,
adapter=scanner.adapter,
)
unsetup = scanner.async_setup()
cancel = manager.async_register_scanner(scanner)
scanner.inject_advertisement(switchbot_device, switchbot_device_adv)
data = scanner.discovered_devices_and_advertisement_data
discovered_device, discovered_adv_data = data[switchbot_device.address]
assert discovered_device.address == switchbot_device.address
assert discovered_device.name == switchbot_device.name
assert (
discovered_adv_data.manufacturer_data == switchbot_device_adv.manufacturer_data
)
assert discovered_adv_data.service_data == switchbot_device_adv.service_data
assert discovered_adv_data.service_uuids == switchbot_device_adv.service_uuids
scanner.inject_advertisement(switchbot_device_2, switchbot_device_adv_2)
data = scanner.discovered_devices_and_advertisement_data
discovered_device, discovered_adv_data = data[switchbot_device.address]
assert discovered_device.address == switchbot_device.address
assert discovered_device.name == switchbot_device.name
assert discovered_adv_data.manufacturer_data == {1: b"\x01", 2: b"\x02"}
assert discovered_adv_data.service_data == {
"050a021a-0000-1000-8000-00805f9b34fb": b"\n\xff",
"00000001-0000-1000-8000-00805f9b34fb": b"\n\xff",
}
assert set(discovered_adv_data.service_uuids) == {
"050a021a-0000-1000-8000-00805f9b34fb",
"00000001-0000-1000-8000-00805f9b34fb",
}
# The longer name should be used
scanner.inject_advertisement(switchbot_device_3, switchbot_device_adv_3)
assert discovered_device.name == switchbot_device_3.name
# Inject the shorter name / None again to make
# sure we always keep the longer name
scanner.inject_advertisement(switchbot_device_2, switchbot_device_adv_2)
assert discovered_device.name == switchbot_device_3.name
scanner.inject_advertisement(switchbot_device_2, switchbot_device_adv_4)
assert scanner.discovered_devices_and_advertisement_data[
switchbot_device_2.address
][1].manufacturer_data == {1: b"\x04", 2: b"\x02", 3: b"\x03"}
scanner.inject_advertisement(switchbot_device_2, switchbot_device_adv_5)
assert scanner.discovered_devices_and_advertisement_data[
switchbot_device_2.address
][1].manufacturer_data == {1: b"\x04", 2: b"\x01", 3: b"\x03"}
assert (
"00090401-0052-036b-3206-ff0a050a021a"
not in scanner.discovered_devices_and_advertisement_data[
switchbot_device_2.address
][1].service_data
)
scanner.inject_raw_advertisement(
switchbot_device_2.address,
switchbot_device_2.rssi,
b"\x12\x21\x1a\x02\n\x05\n\xff\x062k\x03R\x00\x01\x04\t\x00\x04",
)
assert (
"00090401-0052-036b-3206-ff0a050a021a"
in scanner.discovered_devices_and_advertisement_data[
switchbot_device_2.address
][1].service_data
)
assert scanner.serialize_discovered_devices() == DiscoveredDeviceAdvertisementData(
connectable=True,
expire_seconds=195,
discovered_device_advertisement_datas={"44:44:33:11:23:45": ANY},
discovered_device_timestamps={"44:44:33:11:23:45": ANY},
discovered_device_raw={
"44:44:33:11:23:45": b"\x12!\x1a\x02"
b"\n\x05\n\xff"
b"\x062k\x03"
b"R\x00\x01\x04"
b"\t\x00\x04"
},
)
cancel()
unsetup()
@pytest.mark.usefixtures("enable_bluetooth")
@pytest.mark.asyncio
async def test_remote_scanner_expires_connectable() -> None:
"""Test the remote scanner expires stale connectable data."""
manager = get_manager()
switchbot_device = generate_ble_device(
"44:44:33:11:23:45",
"wohand",
{},
rssi=-100,
)
switchbot_device_adv = generate_advertisement_data(
local_name="wohand",
service_uuids=[],
manufacturer_data={1: b"\x01"},
rssi=-100,
)
connector = HaBluetoothConnector(
MockBleakClient, "mock_bleak_client", lambda: False
)
scanner = FakeScanner("esp32", "esp32", connector, True)
unsetup = scanner.async_setup()
cancel = manager.async_register_scanner(scanner)
start_time_monotonic = time.monotonic()
scanner.inject_advertisement(switchbot_device, switchbot_device_adv)
devices = scanner.discovered_devices
assert len(scanner.discovered_devices) == 1
assert len(scanner.discovered_devices_and_advertisement_data) == 1
assert devices[0].name == "wohand"
expire_monotonic = (
start_time_monotonic
+ CONNECTABLE_FALLBACK_MAXIMUM_STALE_ADVERTISEMENT_SECONDS
+ 1
)
expire_utc = utcnow() + timedelta(
seconds=CONNECTABLE_FALLBACK_MAXIMUM_STALE_ADVERTISEMENT_SECONDS + 1
)
with patch_bluetooth_time(expire_monotonic):
async_fire_time_changed(expire_utc)
await asyncio.sleep(0)
devices = scanner.discovered_devices
assert len(scanner.discovered_devices) == 0
assert len(scanner.discovered_devices_and_advertisement_data) == 0
cancel()
unsetup()
@pytest.mark.usefixtures("enable_bluetooth")
@pytest.mark.asyncio
async def test_remote_scanner_expires_non_connectable() -> None:
"""Test the remote scanner expires stale non connectable data."""
manager = get_manager()
switchbot_device = generate_ble_device(
"44:44:33:11:23:45",
"wohand",
{},
rssi=-100,
)
switchbot_device_adv = generate_advertisement_data(
local_name="wohand",
service_uuids=[],
manufacturer_data={1: b"\x01"},
rssi=-100,
)
connector = HaBluetoothConnector(
MockBleakClient, "mock_bleak_client", lambda: False
)
scanner = FakeScanner("esp32", "esp32", connector, True)
unsetup = scanner.async_setup()
cancel = manager.async_register_scanner(scanner)
start_time_monotonic = time.monotonic()
scanner.inject_advertisement(switchbot_device, switchbot_device_adv)
devices = scanner.discovered_devices
assert len(scanner.discovered_devices) == 1
assert len(scanner.discovered_devices_and_advertisement_data) == 1
assert len(scanner.discovered_device_timestamps) == 1
assert len(scanner._discovered_device_timestamps) == 1
dev_adv = scanner.get_discovered_device_advertisement_data(switchbot_device.address)
assert dev_adv is not None
dev, adv = dev_adv
assert dev.name == "wohand"
assert adv.local_name == "wohand"
assert adv.manufacturer_data == switchbot_device_adv.manufacturer_data
assert devices[0].name == "wohand"
assert (
FALLBACK_MAXIMUM_STALE_ADVERTISEMENT_SECONDS
> CONNECTABLE_FALLBACK_MAXIMUM_STALE_ADVERTISEMENT_SECONDS
)
# The connectable timeout is used for all devices
# as the manager takes care of availability and the scanner
# if only concerned about making a connection
expire_monotonic = (
start_time_monotonic
+ CONNECTABLE_FALLBACK_MAXIMUM_STALE_ADVERTISEMENT_SECONDS
+ 1
)
expire_utc = utcnow() + timedelta(
seconds=CONNECTABLE_FALLBACK_MAXIMUM_STALE_ADVERTISEMENT_SECONDS + 1
)
with patch_bluetooth_time(expire_monotonic):
async_fire_time_changed(expire_utc)
await asyncio.sleep(0)
assert len(scanner.discovered_devices) == 0
assert len(scanner.discovered_devices_and_advertisement_data) == 0
assert len(scanner.discovered_device_timestamps) == 0
assert len(scanner._discovered_device_timestamps) == 0
assert (
scanner.get_discovered_device_advertisement_data(switchbot_device.address)
is None
)
expire_monotonic = (
start_time_monotonic + FALLBACK_MAXIMUM_STALE_ADVERTISEMENT_SECONDS + 1
)
expire_utc = utcnow() + timedelta(
seconds=FALLBACK_MAXIMUM_STALE_ADVERTISEMENT_SECONDS + 1
)
with patch_bluetooth_time(expire_monotonic):
async_fire_time_changed(expire_utc)
await asyncio.sleep(0)
assert len(scanner.discovered_devices) == 0
assert len(scanner.discovered_devices_and_advertisement_data) == 0
assert len(scanner.discovered_device_timestamps) == 0
assert len(scanner._discovered_device_timestamps) == 0
assert (
scanner.get_discovered_device_advertisement_data(switchbot_device.address)
is None
)
cancel()
unsetup()
@pytest.mark.usefixtures("enable_bluetooth")
@pytest.mark.asyncio
async def test_base_scanner_connecting_behavior() -> None:
"""Test the default behavior is to mark the scanner as not scanning on connect."""
manager = get_manager()
switchbot_device = generate_ble_device(
"44:44:33:11:23:45",
"wohand",
{},
rssi=-100,
)
switchbot_device_adv = generate_advertisement_data(
local_name="wohand",
service_uuids=[],
manufacturer_data={1: b"\x01"},
rssi=-100,
)
connector = HaBluetoothConnector(
MockBleakClient, "mock_bleak_client", lambda: False
)
scanner = FakeScanner("esp32", "esp32", connector, True)
unsetup = scanner.async_setup()
cancel = manager.async_register_scanner(scanner)
with scanner.connecting():
assert scanner.scanning is False
# We should still accept new advertisements while connecting
# since advertisements are delivered asynchronously and
# we don't want to miss any even when we are willing to
# accept advertisements from another scanner in the brief window
# between when we start connecting and when we stop scanning
scanner.inject_advertisement(switchbot_device, switchbot_device_adv)
devices = scanner.discovered_devices
assert len(scanner.discovered_devices) == 1
assert len(scanner.discovered_devices_and_advertisement_data) == 1
assert devices[0].name == "wohand"
cancel()
unsetup()
@pytest.mark.usefixtures("enable_bluetooth")
@pytest.mark.asyncio
async def test_scanner_stops_responding() -> None:
"""Test we mark a scanner are not scanning when it stops responding."""
manager = get_manager()
connector = HaBluetoothConnector(
MockBleakClient, "mock_bleak_client", lambda: False
)
scanner = FakeScanner(
"esp32",
"esp32",
connector,
True,
current_mode=BluetoothScanningMode.ACTIVE,
requested_mode=BluetoothScanningMode.ACTIVE,
)
unsetup = scanner.async_setup()
cancel = manager.async_register_scanner(scanner)
start_time_monotonic = time.monotonic()
assert scanner.scanning is True
failure_reached_time = (
start_time_monotonic
+ SCANNER_WATCHDOG_TIMEOUT
+ SCANNER_WATCHDOG_INTERVAL.total_seconds()
)
# We hit the timer with no detections,
# so we reset the adapter and restart the scanner
with patch_bluetooth_time(failure_reached_time):
async_fire_time_changed(utcnow() + SCANNER_WATCHDOG_INTERVAL)
await asyncio.sleep(0)
assert scanner.scanning is False
bparasite_device = generate_ble_device( # type: ignore[unreachable]
"44:44:33:11:23:45",
"bparasite",
{},
rssi=-100,
)
bparasite_device_adv = generate_advertisement_data(
local_name="bparasite",
service_uuids=[],
manufacturer_data={1: b"\x01"},
rssi=-100,
)
failure_reached_time += 1
with patch_bluetooth_time(failure_reached_time):
scanner.inject_advertisement(
bparasite_device, bparasite_device_adv, failure_reached_time
)
# As soon as we get a detection, we know the scanner is working again
assert scanner.scanning is True
assert scanner.requested_mode == BluetoothScanningMode.ACTIVE
assert scanner.current_mode == BluetoothScanningMode.ACTIVE
cancel()
unsetup()
@pytest.mark.usefixtures("enable_bluetooth")
@pytest.mark.asyncio
async def test_merge_manufacturer_data_history_existing() -> None:
"""Test merging manufacturer data history."""
manager = get_manager()
sensor_push_device = generate_ble_device(
"44:44:33:11:23:45",
"",
{},
rssi=-60,
)
sensor_push_device_adv = generate_advertisement_data(
local_name="",
rssi=-60,
manufacturer_data={
64256: b"B\r.\xa9\xb6",
31488: b"\x98\xfa\xb6\x91\xb6",
},
service_uuids=["ef090000-11d6-42ba-93b8-9dd7ec090ab0"],
service_data={},
)
sensor_push_adv_2 = generate_advertisement_data(
local_name="",
service_uuids=["ef090000-11d6-42ba-93b8-9dd7ec090ab0"],
service_data={},
manufacturer_data={
31488: b"\x98\xfa\xb6\x91\xb6",
},
rssi=-100,
)
connector = HaBluetoothConnector(
MockBleakClient, "mock_bleak_client", lambda: False
)
scanner = FakeScanner("esp32", "esp32", connector, True)
details = scanner.details
assert details == HaScannerDetails(
source=scanner.source,
connectable=scanner.connectable,
name=scanner.name,
adapter=scanner.adapter,
)
unsetup = scanner.async_setup()
cancel = manager.async_register_scanner(scanner)
scanner.inject_advertisement(sensor_push_device, sensor_push_device_adv)
data = scanner.discovered_devices_and_advertisement_data
discovered_device, discovered_adv_data = data[sensor_push_device.address]
assert discovered_device.address == sensor_push_device.address
assert discovered_device.name == sensor_push_device.name
assert (
discovered_adv_data.manufacturer_data
== sensor_push_device_adv.manufacturer_data
)
assert discovered_adv_data.service_data == sensor_push_device_adv.service_data
assert discovered_adv_data.service_uuids == sensor_push_device_adv.service_uuids
scanner.inject_advertisement(sensor_push_device, sensor_push_adv_2)
data = scanner.discovered_devices_and_advertisement_data
discovered_device, discovered_adv_data = data[sensor_push_device.address]
assert discovered_device.address == sensor_push_device.address
assert discovered_device.name == sensor_push_device.name
assert discovered_adv_data.manufacturer_data == {
**sensor_push_device_adv.manufacturer_data,
**sensor_push_adv_2.manufacturer_data,
}
assert discovered_adv_data.service_data == {}
assert set(discovered_adv_data.service_uuids) == {
*sensor_push_device_adv.service_uuids
}
cancel()
unsetup()
@pytest.mark.usefixtures("enable_bluetooth")
@pytest.mark.asyncio
async def test_merge_manufacturer_data_history_new() -> None:
"""Test merging manufacturer data history."""
manager = get_manager()
sensor_push_device = generate_ble_device(
"44:44:33:11:23:45",
"",
{},
rssi=-60,
)
sensor_push_device_adv = generate_advertisement_data(
local_name="",
rssi=-60,
manufacturer_data={
64256: b"B\r.\xa9\xb6",
31488: b"\x98\xfa\xb6\x91\xb6",
},
service_uuids=["ef090000-11d6-42ba-93b8-9dd7ec090ab0"],
service_data={},
)
sensor_push_adv_2 = generate_advertisement_data(
local_name="",
service_uuids=["ef090000-11d6-42ba-93b8-9dd7ec090ab0"],
service_data={},
manufacturer_data={
21248: b"\xb9\xe9\xe1\xb9\xb6",
},
rssi=-100,
)
connector = HaBluetoothConnector(
MockBleakClient, "mock_bleak_client", lambda: False
)
scanner = FakeScanner("esp32", "esp32", connector, True)
details = scanner.details
assert details == HaScannerDetails(
source=scanner.source,
connectable=scanner.connectable,
name=scanner.name,
adapter=scanner.adapter,
)
unsetup = scanner.async_setup()
cancel = manager.async_register_scanner(scanner)
scanner.inject_advertisement(sensor_push_device, sensor_push_device_adv)
data = scanner.discovered_devices_and_advertisement_data
discovered_device, discovered_adv_data = data[sensor_push_device.address]
assert discovered_device.address == sensor_push_device.address
assert discovered_device.name == sensor_push_device.name
assert (
discovered_adv_data.manufacturer_data
== sensor_push_device_adv.manufacturer_data
)
assert discovered_adv_data.service_data == sensor_push_device_adv.service_data
assert discovered_adv_data.service_uuids == sensor_push_device_adv.service_uuids
scanner.inject_advertisement(sensor_push_device, sensor_push_adv_2)
data = scanner.discovered_devices_and_advertisement_data
discovered_device, discovered_adv_data = data[sensor_push_device.address]
assert discovered_device.address == sensor_push_device.address
assert discovered_device.name == sensor_push_device.name
assert discovered_adv_data.manufacturer_data == {
**sensor_push_device_adv.manufacturer_data,
**sensor_push_adv_2.manufacturer_data,
}
assert discovered_adv_data.service_data == {}
assert set(discovered_adv_data.service_uuids) == {
*sensor_push_device_adv.service_uuids
}
cancel()
unsetup()
@pytest.mark.usefixtures("enable_bluetooth")
@pytest.mark.asyncio
async def test_filter_apple_data() -> None:
"""Test filtering apple data accepts bytes that start with 01."""
manager = get_manager()
device = generate_ble_device(
"44:44:33:11:23:45",
"",
{},
rssi=-60,
)
device_adv = generate_advertisement_data(
local_name="",
rssi=-60,
manufacturer_data={
76: b"\x01\r.\xa9\xb6",
},
service_uuids=["ef090000-11d6-42ba-93b8-9dd7ec090ab0"],
service_data={},
)
connector = HaBluetoothConnector(
MockBleakClient, "mock_bleak_client", lambda: False
)
scanner = FakeScanner("esp32", "esp32", connector, True)
details = scanner.details
assert details == HaScannerDetails(
source=scanner.source,
connectable=scanner.connectable,
name=scanner.name,
adapter=scanner.adapter,
)
unsetup = scanner.async_setup()
cancel = manager.async_register_scanner(scanner)
scanner.inject_advertisement(device, device_adv)
data = scanner.discovered_devices_and_advertisement_data
discovered_device, discovered_adv_data = data[device.address]
assert discovered_device.address == device.address
assert discovered_device.name == device.name
assert discovered_adv_data.manufacturer_data == device_adv.manufacturer_data
unsetup()
cancel()
@pytest.mark.usefixtures("register_hci0_scanner")
def test_connection_history_count_in_progress() -> None:
"""Test connection history in process counting."""
manager = get_manager()
device1_address = "44:44:33:11:23:12"
device2_address = "44:44:33:11:23:13"
hci0_scanner = manager.async_scanner_by_source(HCI0_SOURCE_ADDRESS)
assert hci0_scanner is not None
hci0_scanner._add_connecting(device1_address)
assert hci0_scanner._connections_in_progress() == 1
hci0_scanner._add_connecting(device1_address)
hci0_scanner._add_connecting(device2_address)
assert hci0_scanner._connections_in_progress() == 3
hci0_scanner._finished_connecting(device1_address, True)
assert hci0_scanner._connections_in_progress() == 2
hci0_scanner._finished_connecting(device1_address, False)
assert hci0_scanner._connections_in_progress() == 1
hci0_scanner._finished_connecting(device2_address, False)
assert hci0_scanner._connections_in_progress() == 0
@pytest.mark.usefixtures("register_hci0_scanner")
def test_connection_history_failure_count(caplog: pytest.LogCaptureFixture) -> None:
"""Test connection history failure count."""
manager = get_manager()
device1_address = "44:44:33:11:23:12"
device2_address = "44:44:33:11:23:13"
hci0_scanner = manager.async_scanner_by_source(HCI0_SOURCE_ADDRESS)
assert hci0_scanner is not None
hci0_scanner._add_connecting(device1_address)
hci0_scanner._finished_connecting(device1_address, False)
assert hci0_scanner._connection_failures(device1_address) == 1
hci0_scanner._add_connecting(device1_address)
hci0_scanner._add_connecting(device2_address)
hci0_scanner._finished_connecting(device1_address, False)
assert hci0_scanner._connection_failures(device1_address) == 2
hci0_scanner._finished_connecting(device2_address, False)
assert hci0_scanner._connection_failures(device2_address) == 1
hci0_scanner._add_connecting(device1_address)
hci0_scanner._finished_connecting(device1_address, True)
# On success, we should reset the failure count
assert hci0_scanner._connection_failures(device1_address) == 0
assert "Removing a non-existing connecting" not in caplog.text
hci0_scanner._finished_connecting(device1_address, True)
assert "Removing a non-existing connecting" in caplog.text
habluetooth-3.48.2/tests/test_benchmark_base_scanner.py 0000664 0000000 0000000 00000061151 15005442573 0023347 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
_rssi = switchbot_device_adv.rssi
_name = switchbot_device.name
_service_uuids = switchbot_device_adv.service_uuids
_service_data = switchbot_device_adv.service_data
_manufacturer_data = switchbot_device_adv.manufacturer_data
_tx_power = switchbot_device_adv.tx_power
_details = {"scanner_specific_data": "test"}
_now = monotonic_time_coarse()
@benchmark
def run():
for _ in range(100):
scanner._async_on_advertisement(
_address,
_rssi,
_name,
_service_uuids,
_service_data,
_manufacturer_data,
_tx_power,
_details,
_now,
)
cancel()
unsetup()
@pytest.mark.usefixtures("enable_bluetooth")
@pytest.mark.asyncio
async def test_inject_100_complex_advertisements(benchmark: BenchmarkFixture) -> None:
"""Test injecting 100 complex advertisements."""
manager = get_manager()
switchbot_device = generate_ble_device(
"44:44:33:11:23:45",
"wohand",
{},
rssi=-100,
)
switchbot_device_adv = generate_advertisement_data(
local_name="wohand",
service_uuids=["050a021a-0000-1000-8000-00805f9b34fb"],
service_data={"050a021a-0000-1000-8000-00805f9b34fb": b"\n\xff"},
manufacturer_data=dict.fromkeys(range(100), b"\x01"),
rssi=-100,
)
connector = HaBluetoothConnector(
MockBleakClient, "mock_bleak_client", lambda: False
)
scanner = BaseHaRemoteScanner("esp32", "esp32", connector, True)
unsetup = scanner.async_setup()
cancel = manager.async_register_scanner(scanner)
_address = switchbot_device.address
_rssi = switchbot_device_adv.rssi
_name = switchbot_device.name
_service_uuids = switchbot_device_adv.service_uuids
_service_data = switchbot_device_adv.service_data
_manufacturer_data = switchbot_device_adv.manufacturer_data
_tx_power = switchbot_device_adv.tx_power
_details = {"scanner_specific_data": "test"}
_now = monotonic_time_coarse()
@benchmark
def run():
for _ in range(100):
scanner._async_on_advertisement(
_address,
_rssi,
_name,
_service_uuids,
_service_data,
_manufacturer_data,
_tx_power,
_details,
_now,
)
cancel()
unsetup()
@pytest.mark.usefixtures("enable_bluetooth")
@pytest.mark.asyncio
async def test_inject_100_different_advertisements(benchmark: BenchmarkFixture) -> None:
"""Test injecting 100 different advertisements."""
manager = get_manager()
switchbot_device = generate_ble_device(
"44:44:33:11:23:45",
"wohand",
{},
rssi=-100,
)
advs: list[AdvertisementData] = []
for i in range(100):
switchbot_device_adv = generate_advertisement_data(
local_name="wohand",
service_uuids=["050a021a-0000-1000-8000-00805f9b34fb"],
service_data={"050a021a-0000-1000-8000-00805f9b34fb": b"\n\xff"},
manufacturer_data={i: b"\x01"},
rssi=-100,
)
advs.append(switchbot_device_adv)
connector = HaBluetoothConnector(
MockBleakClient, "mock_bleak_client", lambda: False
)
scanner = BaseHaRemoteScanner("esp32", "esp32", connector, True)
unsetup = scanner.async_setup()
cancel = manager.async_register_scanner(scanner)
_address = switchbot_device.address
_rssi = switchbot_device_adv.rssi
_name = switchbot_device.name
_service_uuids = switchbot_device_adv.service_uuids
_service_data = switchbot_device_adv.service_data
_tx_power = switchbot_device_adv.tx_power
_details = {"scanner_specific_data": "test"}
_now = monotonic_time_coarse()
@benchmark
def run():
for adv in advs:
scanner._async_on_advertisement(
_address,
_rssi,
_name,
_service_uuids,
_service_data,
adv.manufacturer_data,
_tx_power,
_details,
_now,
)
cancel()
unsetup()
@pytest.mark.usefixtures("enable_bluetooth")
@pytest.mark.asyncio
async def test_inject_100_different_manufacturer_data(
benchmark: BenchmarkFixture,
) -> None:
"""Test injecting 100 different manufacturer_data."""
manager = get_manager()
switchbot_device = generate_ble_device(
"44:44:33:11:23:45",
"wohand",
{},
rssi=-100,
)
advs: list[AdvertisementData] = []
for i in range(100):
switchbot_device_adv = generate_advertisement_data(
local_name="wohand",
service_uuids=["050a021a-0000-1000-8000-00805f9b34fb"],
service_data={"050a021a-0000-1000-8000-00805f9b34fb": b"\n\xff"},
manufacturer_data={1: b"\x01", 3: bytes((i,) * 20)},
rssi=-100,
)
advs.append(switchbot_device_adv)
connector = HaBluetoothConnector(
MockBleakClient, "mock_bleak_client", lambda: False
)
scanner = BaseHaRemoteScanner("esp32", "esp32", connector, True)
unsetup = scanner.async_setup()
cancel = manager.async_register_scanner(scanner)
_address = switchbot_device.address
_rssi = switchbot_device_adv.rssi
_name = switchbot_device.name
_service_uuids = switchbot_device_adv.service_uuids
_service_data = switchbot_device_adv.service_data
_tx_power = switchbot_device_adv.tx_power
_details = {"scanner_specific_data": "test"}
_now = monotonic_time_coarse()
@benchmark
def run():
for adv in advs:
scanner._async_on_advertisement(
_address,
_rssi,
_name,
_service_uuids,
_service_data,
adv.manufacturer_data,
_tx_power,
_details,
_now,
)
cancel()
unsetup()
@pytest.mark.usefixtures("enable_bluetooth")
@pytest.mark.asyncio
async def test_inject_100_different_service_data(
benchmark: BenchmarkFixture,
) -> None:
"""Test injecting 100 different service_data."""
manager = get_manager()
switchbot_device = generate_ble_device(
"44:44:33:11:23:45",
"wohand",
{},
rssi=-100,
)
advs: list[AdvertisementData] = []
for i in range(100):
switchbot_device_adv = generate_advertisement_data(
local_name="wohand",
service_uuids=["050a021a-0000-1000-8000-00805f9b34fb"],
service_data={"050a021a-0000-1000-8000-00805f9b34fb": bytes((i,) * 20)},
manufacturer_data={1: b"\x01"},
rssi=-100,
)
advs.append(switchbot_device_adv)
connector = HaBluetoothConnector(
MockBleakClient, "mock_bleak_client", lambda: False
)
scanner = BaseHaRemoteScanner("esp32", "esp32", connector, True)
unsetup = scanner.async_setup()
cancel = manager.async_register_scanner(scanner)
_address = switchbot_device.address
_rssi = switchbot_device_adv.rssi
_name = switchbot_device.name
_service_uuids = switchbot_device_adv.service_uuids
_service_data = switchbot_device_adv.service_data
_tx_power = switchbot_device_adv.tx_power
_details = {"scanner_specific_data": "test"}
_now = monotonic_time_coarse()
@benchmark
def run():
for adv in advs:
scanner._async_on_advertisement(
_address,
_rssi,
_name,
_service_uuids,
_service_data,
adv.manufacturer_data,
_tx_power,
_details,
_now,
)
cancel()
unsetup()
@pytest.mark.usefixtures("enable_bluetooth")
@pytest.mark.asyncio
async def test_inject_100_rotating_manufacturer_data(
benchmark: BenchmarkFixture,
) -> None:
"""Test injecting 100 different manufacturer_data to mimic a sensor push device."""
manager = get_manager()
sensor_push_device = generate_ble_device(
"44:44:33:11:23:45",
"",
{},
rssi=-60,
)
sensor_push_device_adv = generate_advertisement_data(
local_name="",
rssi=-60,
manufacturer_data={
17667: b"\xad\x00\x01\x00\x00",
1280: b"\xe7\xb4\xe1\xaf\xb6",
2304: b"7\xe1:\xb7\xb6",
55552: b"#\xc7$\xad\xb6",
58624: b";\x01%\x9d\xb6",
44288: b"\xa2|'x\xb6",
64000: b";\xad\xdc\xa7\xb6",
28672: b"\xdb\xe8\\\xa2\xb6",
7168: b"\xb5\xbd\xe0\xaf\xb6",
11264: b"\x00S}\xae\xb6",
4096: b"\xe9\xef\x8e\xba\xb6",
44800: b"\x85\xa2=\xb5\xb6",
32768: b"\x86b\xe9\xc1\xb6",
37376: b"\x8bS<\xc1\xb6",
25344: b"\xb4\xb2\xe7\xbb\xb6",
51200: b"\xae\xdc\xc8\x97\xb6",
49152: b"O\x80O\xc7\xb6",
17664: b"\x0e\xb7q\xa0\xb6",
34816: b"\x9a\xf6\xf8\xc3\xb6",
21760: b"G\xd9\xd6\xa7\xb6",
512: b"\xaa\x14M\xc5\xb6",
41984: b"\xfd\xb4\xd7\xa5\xb6",
16640: b"\x9b\xdd\xd9\xa5\xb6",
33024: b"\x99\xdbB\xb9\xb6",
25088: b"\xee\xec\xea\xbf\xb6",
24576: b"\xc3G\x16\x99\xb6",
50176: b"\x88Q\x9d\xc4\xb6",
57856: b"~\x1a\xb0\x87\xb6",
2816: b"08\xa2\xc4\xb6",
19712: b",\xf1u\x9a\xb6",
26880: b"\x8f\x0f(\xa9\xb6",
54528: b"U\xbe\x1c\x9b\xb6",
7936: b"\x01\x1e\x93\xbc\xb6",
52992: b"R\x19\xb9\x91\xb6",
9472: b"\x0f\xb9\x9a\x87\xb6",
47360: b"A\xe16\xb5\xb6",
14080: b"r\x82S\xc7\xb6",
60416: b"#A\xc5v\xb6",
19968: b"\xf5=\x80\xa0\xb6",
30976: b"\r\x99\x13\x91\xb6",
9216: b'\x08">\xbd\xb6',
16896: b'"\x94L\xc7\xb6',
54784: b"\xae\xce%\x9d\xb6",
21248: b"\xb9\xe9\xe1\xb9\xb6",
40960: b"\x15}\xda\xbb\xb6",
16128: b"s\xe9\xf7\xc5\xb6",
36608: b"\xad\xd6\x8f\xc0\xb6",
1536: b"\x1a\xd1\x8c\xb0\xb6",
30720: b"\xf4`\x93\xb4\xb6",
17920: b"mIi\xae\xb6",
30464: b"\x8c}\x19\x99\xb6",
61952: b"\xb4{\xec\xbd\xb6",
30208: b'\xa8\xac"\x9b\xb6',
27904: b"D\xcb8\xb5\xb6",
45568: b"\xfc\xb5\xdf\xa9\xb6",
12288: b"\xe9\x11\xa7\x8f\xb6",
6400: b"\\\xcf\xe0\xb7\xb6",
10496: b"P_\xe1\xbb\xb6",
52736: b"fv\xd3\xa1\xb6",
37888: b"\xb1\x7f'\xaf\xb6",
6656: b"\x80Wh\x90\xb6",
15872: b"\xd7\x91\xe0\xb7\xb6",
28160: b"P<\xc5\x95\xb6",
37632: b"NN\xc7x\xb6",
11776: b"\x03z0\xab\xb6",
48896: b"B\x9e\xaa\xc8\xb6",
65280: b"w\xb1\xee\xb9\xb6",
56320: b"\xb1\xfa\x1f\x99\xb6",
59136: b"_\xd5\x1c\x97\xb6",
26368: b"\xbe\x82\xbd\x93\xb6",
7424: b"A\xc8\x19\x99\xb6",
49408: b"\xef\xda\x91\xb4\xb6",
24832: b"l\xc03\xbd\xb6",
48128: b"Vs4\xa9\xb6",
48384: b"\xack;\xbb\xb6",
20224: b"\xd8O\xe5\xb9\xb6",
35840: b"Nj\xe1\xbb\xb6",
51712: b"\x96\xba\xcc\x9b\xb6",
23296: b"\xda\\v\x9c\xb6",
39168: b"0j\xe3\xb3\xb6",
29440: b"\xf9\xc9J\xc3\xb6",
54016: b"\xe9\x1c\x88\xa6\xb6",
62208: b"\x1b\x0f\xe3\xbf\xb6",
33280: b"\xc2s\x83\xa2\xb6",
20480: b"\xa9\xc5\xc4\x95\xb6",
50688: b"\xd5O\xe5\xb7\xb6",
19456: b"T }\x9e\xb6",
27136: b"\xd3\n\xda\xbb\xb6",
34304: b"\x10\x164\xb7\xb6",
3328: b'"\xb1\x1c\x99\xb6',
50944: b"it\xbf\x91\xb6",
29952: b"\xd7\xc5\xb8\x93\xb6",
46592: b"\x14-\xbc\x95\xb6",
60928: b"|\xcd\xb8\x8d\xb6",
16384: b"4\x95\xce\x9b\xb6",
23040: b"\x99\xca\x9f\x8d\xb6",
58112: b"P\xcc;\xb7\xb6",
22784: b"\x8a4L\xc5\xb6",
12800: b"el\xe0\xad\xb6",
8960: b"xe\x8e\xb8\xb6",
13568: b"\xec2\x8f\xb8\xb6",
36864: b"\r\xde1\xb5\xb6",
64512: b"\xf7\xf8\x17n\xb6",
39424: b"?\xbc\x87\xa8\xb6",
8448: b"\xfa\x8c\xa6\x8f\xb6",
53760: b"\xf3\x92\xdd\xb3\xb6",
23552: b"A\xb5A\xc3\xb6",
51968: b"\xb6\xc9\xa5^\xb6",
9728: b"\xff\xa1\x7f\xa6\xb6",
18944: b"\xc0\xddI\xc1\xb6",
46848: b"\x05t\xea\xb9\xb6",
33792: b"\xdb\xa8\xd9\xa3\xb6",
6144: b"+\xcb?\xb9\xb6",
10752: b";\x93:\xb9\xb6",
40704: b"\x8e\x85p\x96\xb6",
58368: b"\x91\xf2\xd0\x9d\xb6",
32512: b"\xec\x80\x85\xa4\xb6",
55808: b"-\x98\x80\xb0\xb6",
25856: b"\x90\xd5\x85\xaa\xb6",
58880: b":J\x81\xba\xb6",
31232: b"\x80\xfe\xdd\xa5\xb6",
55040: b"o13\xa9\xb6",
50432: b"t\xe5I\xc3\xb6",
37120: b"\xd3\x05\x89\xa6\xb6",
12544: b"\x06\x00<\xbd\xb6",
59904: b"\xddb\xbe\x93\xb6",
27392: b"OB\x0f\x8f\xb6",
61696: b"\x1d\xe8\x18\x97\xb6",
29696: b"#\xcc\xde\xbd\xb6",
32000: b"X}3\xb5\xb6",
44544: b"\xb8\xa1\x1e\x99\xb6",
7680: b"\xe7Qr\x98\xb6",
45312: b"\xfbI\x10\x8f\xb6",
63488: b"\xc6\xda(\xa5\xb6",
25600: b"O\xda\xe2\xb7\xb6",
24320: b"r\x14n\x98\xb6",
62464: b"\xb0\x87\xf4\xc1\xb6",
63744: b"\x96\xd6\x14\x95\xb6",
21504: b"[\x85\x0f\x93\xb6",
8192: b"\xb7\x84\xd3\xa5\xb6",
29184: b"\xbf\xdfg\x90\xb6",
64768: b"\xa2\x84\xe5\xbf\xb6",
57088: b"9\t\x8a\xb4\xb6",
22272: b"r~(\x9f\xb6",
55296: b".\x03\xc6\x97\xb6",
34560: b"\xb5r\x7f\xa0\xb6",
52224: b"\xe2\xc3\x1c\xa3\xb6",
13824: b"8>\xe6\xb5\xb6",
46080: b"Y\x7f@\xb9\xb6",
34048: b"/_k\x96\xb6",
4608: b"9\x95K\xc3\xb6",
62720: b"K-;\x84\xb6",
44032: b"\xd5\xd0\xa7\xc6\xb6",
35584: b"?}D\xcd\xb6",
43008: b"2\x8f\x8a\xae\xb6",
47104: b"\xa1\xff\xe6\xbb\xb6",
61184: b"\xa3\x7f%\xa5\xb6",
59648: b"\xf1\xb8\x8d\xb4\xb6",
57344: b"\x88\xee2\xb7\xb6",
36096: b"\\\xd5\x9c\xc0\xb6",
38912: b"n\x12_\x90\xb6",
56832: b"$gG\xc3\xb6",
18176: b"\xf9\x96\xfc\xc5\xb6",
18432: b"b\xcdA\xbf\xb6",
57600: b":\x19@\xbf\xb6",
18688: b"$\xe2\xcb\x99\xb6",
38656: b"\x0cA-\xb9\xb6",
48640: b"V\x8c\xda\xab\xb6",
46336: b'"WL]\xb6',
9984: b"\xa8\xab\xa2\xd2\xb6",
42496: b"4\x0b\x1f\x9b\xb6",
41216: b")M%\xab\xb6",
49664: b"M%6\xa9\xb6",
42240: b",\x1e\x86\xb6\xb6",
20992: b"\xab\x052\xbd\xb6",
53504: b"\x8a\xf6\x84\xaa\xb6",
56064: b"\xda\xbf\xa4`\xb6",
53248: b"\x18:\xc8\x99\xb6",
19200: b"\xb1\xb4\x89\xbc\xb6",
38400: b"\xba=\x1f\x99\xb6",
41728: b"\xe4\xa3+\xb1\xb6",
5376: b"z\xd6\x94\xb2\xb6",
47616: b"\x88\x1f\xe3\xb9\xb6",
60672: b"\x9c\x85{\xb4\xb6",
3584: b"\xe7\xdc\xa8\xc6\xb6",
28416: b"\xdc\xddT\x90\xb6",
14336: b"\x87\xa6\xf2\xc5\xb6",
43776: b"9y\x8a\xae\xb6",
39936: b"\xe2\x8cSa\xb6",
5632: b"\xa5_0\xab\xb6",
14592: b"\xbf\xa9\x80\xae\xb6",
63232: b"\xd6A\xc5\x99\xb6",
13312: b"=\xcdL\xc3\xb6",
8704: b"\xf9\xd1'\xa1\xb6",
11008: b"\xdc\xed\xf6\xc5\xb6",
26624: b"\x9b\x81\xc2\x99\xb6",
13056: b"\x88@\xda\xab\xb6",
5888: b"p\xea\x85\xaa\xb6",
12032: b"L\xdb\xe9\xb9\xb6",
3072: b"$\x1e\x83\xac\xb6",
31744: b"\xcb\xe60\xad\xb6",
14848: b"\xee\x9d\xe8\xc9\xb6",
45824: b"Mo\x8e\xb2\xb6",
768: b"\xa6\x8d=\xb5\xb6",
56576: b"\x02\xba\x8d\xb0\xb6",
49920: b"\xa3\xac$\x9d\xb6",
41472: b"\x9dM\xe0\xab\xb6",
65024: b"\x89!\xf2\xbf\xb6",
1024: b"\x89\xbf\x8d\xb4\xb6",
0: b"\xb6\xc5\xc2\x97\xb6",
61440: b"\xad\xa8s\x98\xb6",
17408: b"\xc2\x99?\xbb\xb6",
42752: b"R\xf81\xa9\xb6",
38144: b'\x83\x89"\x9d\xb6',
43520: b"\xb7\xa2'\x9f\xb6",
35328: b";d\xa2\xd0\xb6",
51456: b"\xa4\x85h\x90\xb6",
35072: b"\xfb\x90@\xbf\xb6",
39680: b"\xf5\xcb\x04\xa1\xb6",
4352: b"j\xd0e\x92\xb6",
32256: b"\xcc\x99\xbf\x95\xb6",
3840: b"\xd0\xdd\xc7\x99\xb6",
45056: b"U\xf2\xf0\xc3\xb6",
47872: b'\xdc\x07"\x9b\xb6',
60160: b"0\x8a\xdf\xbb\xb6",
28928: b"\xe7\xa8\xdc\xaf\xb6",
54272: b"c\x15\x85\xb4\xb6",
17152: b"\xc0Q\x7f\xa0\xb6",
5120: b"B@w\x9a\xb6",
43264: b"rC\x85\xaa\xb6",
23808: b"[\xe3=\xb7\xb6",
256: b"\x9c\x9f\x90\xb2\xb6",
6912: b"\xf2\x18\xdc\xab\xb6",
15616: b"\x1b,/\xb5\xb6",
15104: b"{=\xbf\x91\xb6",
4864: b"g?\xe3\xb9\xb6",
36352: b"\x88\xac2\xad\xb6",
22016: b"O\x91\x18\x95\xb6",
52480: b"q\x8dS\xc9\xb6",
62976: b"ZX\x7f\xa2\xb6",
59392: b"\xef\xdc\xa5\xc4\xb6",
15360: b"\xd0\x9aD\xc1\xb6",
10240: b"a\x92\x92\xb0\xb6",
2048: b" /]\x9a\xb6",
20736: b"\x9d\xdek\x94\xb6",
2560: b"\xf5z=\xb3\xb6",
22528: b"j@\xe2\xad\xb6",
26112: b"\x18\x1f\xc5x\xb6",
40448: b"\xdf\xfe=\xbb\xb6",
11520: b"2\xf7<\xbb\xb6",
1792: b"$\n\x1c\x99\xb6",
40192: b"\xaa\x88\xff\xc9\xb6",
27648: b"\x87\xac\xb8\x8d\xb6",
33536: b"{:\x1b\x97\xb6",
64256: b"B\r.\xa9\xb6",
31488: b"\x98\xfa\xb6\x91\xb6",
},
service_uuids=["ef090000-11d6-42ba-93b8-9dd7ec090ab0"],
service_data={},
)
connector = HaBluetoothConnector(
MockBleakClient, "mock_bleak_client", lambda: False
)
scanner = BaseHaRemoteScanner("esp32", "esp32", connector, True)
unsetup = scanner.async_setup()
cancel = manager.async_register_scanner(scanner)
scanner._async_on_advertisement(
sensor_push_device.address,
sensor_push_device.rssi,
sensor_push_device.name,
sensor_push_device_adv.service_uuids,
sensor_push_device_adv.service_data,
sensor_push_device_adv.manufacturer_data,
sensor_push_device_adv.tx_power,
{"scanner_specific_data": "test"},
monotonic_time_coarse(),
)
advs: list[AdvertisementData] = []
for i in range(100):
sensorpush_device_adv = generate_advertisement_data(
local_name="",
service_uuids=["ef090000-11d6-42ba-93b8-9dd7ec090ab0"],
service_data={},
manufacturer_data={i: bytes((i,) * 20)},
rssi=-(i),
)
advs.append(sensorpush_device_adv)
_address = sensor_push_device.address
_rssi = sensorpush_device_adv.rssi
_name = sensor_push_device.name
_service_uuids = sensorpush_device_adv.service_uuids
_service_data = sensorpush_device_adv.service_data
_tx_power = sensorpush_device_adv.tx_power
_details = {"scanner_specific_data": "test"}
_now = monotonic_time_coarse()
@benchmark
def run():
for adv in advs:
scanner._async_on_advertisement(
_address,
_rssi,
_name,
_service_uuids,
_service_data,
adv.manufacturer_data,
_tx_power,
_details,
_now,
)
cancel()
unsetup()
@pytest.mark.usefixtures("enable_bluetooth")
@pytest.mark.asyncio
async def test_filter_unwanted_apple_advs(benchmark: BenchmarkFixture) -> None:
"""Test filtering unwanted apple data."""
manager = get_manager()
device = generate_ble_device(
"44:44:33:11:23:45",
"beacon",
{},
rssi=-100,
)
device_adv = generate_advertisement_data(
local_name="beacon",
service_uuids=[],
service_data={},
manufacturer_data={76: b"\xff"},
rssi=-100,
)
connector = HaBluetoothConnector(
MockBleakClient, "mock_bleak_client", lambda: False
)
scanner = BaseHaRemoteScanner("esp32", "esp32", connector, True)
unsetup = scanner.async_setup()
cancel = manager.async_register_scanner(scanner)
_address = device.address
_rssi = device.rssi
_name = device.name
_service_uuids = device_adv.service_uuids
_service_data = device_adv.service_data
_manufacturer_data = device_adv.manufacturer_data
_tx_power = device_adv.tx_power
_details = {"scanner_specific_data": "test"}
_now = monotonic_time_coarse()
@benchmark
def run():
for _ in range(100):
scanner._async_on_advertisement(
_address,
_rssi,
_name,
_service_uuids,
_service_data,
_manufacturer_data,
_tx_power,
_details,
_now,
)
cancel()
unsetup()
@pytest.mark.usefixtures("enable_bluetooth")
@pytest.mark.asyncio
async def test_filter_wanted_apple_advs(benchmark: BenchmarkFixture) -> None:
"""Test filtering wanted apple data."""
manager = get_manager()
device = generate_ble_device(
"44:44:33:11:23:45",
"beacon",
{},
rssi=-100,
)
device_adv = generate_advertisement_data(
local_name="beacon",
service_uuids=[],
service_data={},
manufacturer_data={76: b"\x02"},
rssi=-100,
)
connector = HaBluetoothConnector(
MockBleakClient, "mock_bleak_client", lambda: False
)
scanner = BaseHaRemoteScanner("esp32", "esp32", connector, True)
unsetup = scanner.async_setup()
cancel = manager.async_register_scanner(scanner)
_address = device.address
_rssi = device.rssi
_name = device.name
_service_uuids = device_adv.service_uuids
_service_data = device_adv.service_data
_manufacturer_data = device_adv.manufacturer_data
_tx_power = device_adv.tx_power
_details = {"scanner_specific_data": "test"}
_now = monotonic_time_coarse()
@benchmark
def run():
for _ in range(100):
scanner._async_on_advertisement(
_address,
_rssi,
_name,
_service_uuids,
_service_data,
_manufacturer_data,
_tx_power,
_details,
_now,
)
cancel()
unsetup()
habluetooth-3.48.2/tests/test_init.py 0000664 0000000 0000000 00000015613 15005442573 0017657 0 ustar 00root root 0000000 0000000 from unittest.mock import ANY
import pytest
from bleak.backends.device import BLEDevice
from bleak.backends.scanner import AdvertisementData
from bleak_retry_connector import BleakSlotManager
from bluetooth_adapters import BluetoothAdapters
from habluetooth import (
BaseHaRemoteScanner,
BaseHaScanner,
BluetoothManager,
BluetoothScanningMode,
HaBluetoothConnector,
HaScanner,
set_manager,
)
@pytest.fixture(scope="session", autouse=True)
def manager():
slot_manager = BleakSlotManager()
bluetooth_adapters = BluetoothAdapters()
set_manager(BluetoothManager(bluetooth_adapters, slot_manager))
class MockBleakClient:
pass
def test_create_scanner():
connector = HaBluetoothConnector(MockBleakClient, "any", lambda: True)
class MockScanner(BaseHaScanner):
pass
@property
def discovered_devices_and_advertisement_data(self):
return []
@property
def discovered_devices(self):
return []
scanner = MockScanner("any", "any", connector)
assert isinstance(scanner, BaseHaScanner)
def test_create_remote_scanner():
connector = HaBluetoothConnector(MockBleakClient, "any", lambda: True)
scanner = BaseHaRemoteScanner("any", "any", connector, True)
assert isinstance(scanner, BaseHaRemoteScanner)
def test__async_on_advertisement():
connector = HaBluetoothConnector(MockBleakClient, "any", lambda: True)
scanner = BaseHaRemoteScanner("any", "any", connector, True)
details = scanner._details | {}
scanner._async_on_advertisement(
"AA:BB:CC:DD:EE:FF",
-88,
"name",
["service_uuid"],
{"service_uuid": b"\x00\x01\x02\x03\x04\x05\x06\x07\x08\x09\x0a\x0b"},
{32: b"\x00\x01\x02\x03\x04\x05\x06\x07\x08\x09\x0a\x0b"},
-88,
details,
1.0,
)
scanner._async_on_advertisement(
"AA:BB:CC:DD:EE:FF",
-21,
"name",
["service_uuid2"],
{"service_uuid2": b"\x00\x01\x02\x03\x04\x05\x06\x07\x08\x09\x0a\x0b"},
{21: b"\x00\x01\x02\x03\x04\x05\x06\x07\x08\x09\x0a\x0b"},
-88,
details,
1.0,
)
ble_device = BLEDevice(
"AA:BB:CC:DD:EE:FF",
"name",
details,
-21,
)
first_device = scanner.discovered_devices[0]
assert first_device.address == ble_device.address
assert first_device.details == ble_device.details
assert first_device.name == ble_device.name
assert first_device.rssi == ble_device.rssi
assert "AA:BB:CC:DD:EE:FF" in scanner.discovered_devices_and_advertisement_data
adv = scanner.discovered_devices_and_advertisement_data["AA:BB:CC:DD:EE:FF"][1]
assert set(adv.service_data) == {"service_uuid", "service_uuid2"}
assert adv == AdvertisementData(
local_name="name",
manufacturer_data={
32: b"\x00\x01\x02\x03\x04\x05\x06\x07\x08\t\n\x0b",
21: b"\x00\x01\x02\x03\x04\x05\x06\x07\x08\t\n\x0b",
},
service_data={
"service_uuid": b"\x00\x01\x02\x03\x04\x05\x06\x07\x08\t\n\x0b",
"service_uuid2": b"\x00\x01\x02\x03\x04\x05\x06\x07\x08\t\n\x0b",
},
service_uuids=ANY,
tx_power=-88,
rssi=-21,
platform_data=(),
)
assert len(scanner.discovered_devices) == 1
assert scanner.discovered_devices[0].address == "AA:BB:CC:DD:EE:FF"
assert len(scanner.discovered_devices_and_advertisement_data) == 1
assert (
scanner.discovered_devices_and_advertisement_data["AA:BB:CC:DD:EE:FF"][0].rssi
== -21
)
assert (
scanner.discovered_devices_and_advertisement_data["AA:BB:CC:DD:EE:FF"][1].rssi
== -21
)
assert "AA:BB:CC:DD:EE:FF" in scanner.discovered_addresses
device_adv = scanner.get_discovered_device_advertisement_data("AA:BB:CC:DD:EE:FF")
assert device_adv is not None
assert device_adv[1] == adv
def test__async_on_advertisement_first():
connector = HaBluetoothConnector(MockBleakClient, "any", lambda: True)
scanner = BaseHaRemoteScanner("any", "any", connector, True)
details = scanner._details | {}
scanner._async_on_advertisement(
"AA:BB:CC:DD:EE:FF",
-88,
"name",
["service_uuid"],
{"service_uuid": b"\x00\x01\x02\x03\x04\x05\x06\x07\x08\x09\x0a\x0b"},
{32: b"\x00\x01\x02\x03\x04\x05\x06\x07\x08\x09\x0a\x0b"},
-88,
details,
1.0,
)
device_adv = scanner.get_discovered_device_advertisement_data("AA:BB:CC:DD:EE:FF")
assert device_adv is not None
device, adv = device_adv
assert device is not None
assert adv is not None
assert device.address == "AA:BB:CC:DD:EE:FF"
assert adv.rssi == -88
assert adv.service_uuids == ["service_uuid"]
assert adv.service_data == {
"service_uuid": b"\x00\x01\x02\x03\x04\x05\x06\x07\x08\t\n\x0b"
}
assert adv.manufacturer_data == {
32: b"\x00\x01\x02\x03\x04\x05\x06\x07\x08\t\n\x0b"
}
assert adv.service_uuids == ANY
assert adv.tx_power == -88
assert adv.rssi == -88
assert adv.platform_data == ()
assert device.name == "name"
assert device.details == details
def test__async_on_advertisement_prefers_longest_local_name():
connector = HaBluetoothConnector(MockBleakClient, "any", lambda: True)
scanner = BaseHaRemoteScanner("any", "any", connector, True)
details = scanner._details | {}
scanner._async_on_advertisement(
"AA:BB:CC:DD:EE:FF",
-88,
"shortname",
[],
{},
{},
-88,
details,
1.0,
)
device_adv = scanner.get_discovered_device_advertisement_data("AA:BB:CC:DD:EE:FF")
assert device_adv is not None
device, adv = device_adv
assert device is not None
assert adv is not None
assert device.name == "shortname"
assert adv.local_name == "shortname"
scanner._async_on_advertisement(
"AA:BB:CC:DD:EE:FF",
-88,
"tinyname",
[],
{},
{},
-88,
details,
1.0,
)
device_adv = scanner.get_discovered_device_advertisement_data("AA:BB:CC:DD:EE:FF")
assert device_adv is not None
device, adv = device_adv
assert device is not None
assert adv is not None
assert device.name == "shortname"
assert adv.local_name == "shortname"
scanner._async_on_advertisement(
"AA:BB:CC:DD:EE:FF",
-88,
"longername",
[],
{},
{},
-88,
details,
1.0,
)
device_adv = scanner.get_discovered_device_advertisement_data("AA:BB:CC:DD:EE:FF")
assert device_adv is not None
device, adv = device_adv
assert device is not None
assert adv is not None
assert device.name == "longername"
assert adv.local_name == "longername"
def test_create_ha_scanner():
scanner = HaScanner(BluetoothScanningMode.ACTIVE, "hci0", "AA:BB:CC:DD:EE:FF")
assert isinstance(scanner, HaScanner)
habluetooth-3.48.2/tests/test_manager.py 0000664 0000000 0000000 00000117502 15005442573 0020326 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, patch
import pytest
from bleak_retry_connector import AllocationChange, Allocations, BleakSlotManager
from bluetooth_adapters.systems.linux import LinuxAdapters
from freezegun import freeze_time
from habluetooth import (
FALLBACK_MAXIMUM_STALE_ADVERTISEMENT_SECONDS,
TRACKER_BUFFERING_WOBBLE_SECONDS,
UNAVAILABLE_TRACK_SECONDS,
BluetoothManager,
BluetoothServiceInfoBleak,
HaBluetoothSlotAllocations,
HaScannerRegistration,
HaScannerRegistrationEvent,
get_manager,
set_manager,
)
from . import (
HCI0_SOURCE_ADDRESS,
HCI1_SOURCE_ADDRESS,
async_fire_time_changed,
generate_advertisement_data,
generate_ble_device,
inject_advertisement_with_source,
inject_advertisement_with_time_and_source,
inject_advertisement_with_time_and_source_connectable,
patch_bluetooth_time,
utcnow,
)
from .conftest import FakeBluetoothAdapters, FakeScanner
SOURCE_LOCAL = "local"
@pytest.mark.asyncio
@pytest.mark.skipif("platform.system() == 'Windows'")
async def test_async_recover_failed_adapters() -> None:
"""Return the BluetoothManager instance."""
attempt = 0
class MockLinuxAdapters(LinuxAdapters):
@property
def adapters(self) -> dict[str, Any]:
nonlocal attempt
attempt += 1
if attempt == 1:
return {
"hci0": {
"address": "00:00:00:00:00:01",
"hw_version": "usb:v1D6Bp0246d053F",
"passive_scan": False,
"sw_version": "homeassistant",
"manufacturer": "ACME",
"product": "Bluetooth Adapter 5.0",
"product_id": "aa01",
"vendor_id": "cc01",
},
"hci1": {
"address": "00:00:00:00:00:00",
"hw_version": "usb:v1D6Bp0246d053F",
"passive_scan": False,
"sw_version": "homeassistant",
"manufacturer": "ACME",
"product": "Bluetooth Adapter 5.0",
"product_id": "aa01",
"vendor_id": "cc01",
},
"hci2": {
"address": "00:00:00:00:00:00",
"hw_version": "usb:v1D6Bp0246d053F",
"passive_scan": False,
"sw_version": "homeassistant",
"manufacturer": "ACME",
"product": "Bluetooth Adapter 5.0",
"product_id": "aa01",
"vendor_id": "cc01",
},
}
return {
"hci0": {
"address": "00:00:00:00:00:01",
"hw_version": "usb:v1D6Bp0246d053F",
"passive_scan": False,
"sw_version": "homeassistant",
"manufacturer": "ACME",
"product": "Bluetooth Adapter 5.0",
"product_id": "aa01",
"vendor_id": "cc01",
},
"hci1": {
"address": "00:00:00:00:00:02",
"hw_version": "usb:v1D6Bp0246d053F",
"passive_scan": False,
"sw_version": "homeassistant",
"manufacturer": "ACME",
"product": "Bluetooth Adapter 5.0",
"product_id": "aa01",
"vendor_id": "cc01",
},
"hci2": {
"address": "00:00:00:00:00:03",
"hw_version": "usb:v1D6Bp0246d053F",
"passive_scan": False,
"sw_version": "homeassistant",
"manufacturer": "ACME",
"product": "Bluetooth Adapter 5.0",
"product_id": "aa01",
"vendor_id": "cc01",
},
}
with (
patch("habluetooth.manager.async_reset_adapter") as mock_async_reset_adapter,
):
adapters = MockLinuxAdapters()
slot_manager = BleakSlotManager()
manager = BluetoothManager(adapters, slot_manager)
await manager.async_setup()
set_manager(manager)
adapter = await manager.async_get_adapter_from_address_or_recover(
"00:00:00:00:00:03"
)
assert adapter == "hci2"
adapter = await manager.async_get_adapter_from_address_or_recover(
"00:00:00:00:00:02"
)
assert adapter == "hci1"
adapter = await manager.async_get_adapter_from_address_or_recover(
"00:00:00:00:00:01"
)
assert adapter == "hci0"
assert mock_async_reset_adapter.call_count == 2
assert mock_async_reset_adapter.call_args_list == [
(("hci1", "00:00:00:00:00:00", False),),
(("hci2", "00:00:00:00:00:00", False),),
]
@pytest.mark.asyncio
async def test_create_manager() -> None:
"""Return the BluetoothManager instance."""
adapters = FakeBluetoothAdapters()
slot_manager = BleakSlotManager()
manager = BluetoothManager(adapters, slot_manager)
set_manager(manager)
assert manager
@pytest.mark.asyncio
@pytest.mark.usefixtures("enable_bluetooth")
async def test_async_register_disappeared_callback(
register_hci0_scanner: None,
register_hci1_scanner: None,
) -> None:
"""Test bluetooth async_register_disappeared_callback handles failures."""
manager = get_manager()
assert manager._loop is not None
address = "44:44:33:11:23:12"
switchbot_device_signal_100 = generate_ble_device(
address, "wohand_signal_100", rssi=-100
)
switchbot_adv_signal_100 = generate_advertisement_data(
local_name="wohand_signal_100", service_uuids=[]
)
inject_advertisement_with_source(
switchbot_device_signal_100, switchbot_adv_signal_100, "hci0"
)
failed_disappeared: list[str] = []
def _failing_callback(_address: str) -> None:
"""Failing callback."""
failed_disappeared.append(_address)
raise ValueError("This is a test")
ok_disappeared: list[str] = []
def _ok_callback(_address: str) -> None:
"""Ok callback."""
ok_disappeared.append(_address)
cancel1 = manager.async_register_disappeared_callback(_failing_callback)
# Make sure the second callback still works if the first one fails and
# raises an exception
cancel2 = manager.async_register_disappeared_callback(_ok_callback)
switchbot_adv_signal_100 = generate_advertisement_data(
local_name="wohand_signal_100",
manufacturer_data={123: b"abc"},
service_uuids=[],
rssi=-80,
)
inject_advertisement_with_source(
switchbot_device_signal_100, switchbot_adv_signal_100, "hci1"
)
future_time = utcnow() + timedelta(seconds=3600)
future_monotonic_time = time.monotonic() + 3600
with (
freeze_time(future_time),
patch(
"habluetooth.manager.monotonic_time_coarse",
return_value=future_monotonic_time,
),
):
manager._async_check_unavailable()
async_fire_time_changed(future_time)
assert len(ok_disappeared) == 1
assert ok_disappeared[0] == address
assert len(failed_disappeared) == 1
assert failed_disappeared[0] == address
cancel1()
cancel2()
@pytest.mark.asyncio
@pytest.mark.usefixtures("enable_bluetooth")
async def test_async_register_allocation_callback(
register_hci0_scanner: None,
register_hci1_scanner: None,
) -> None:
"""Test bluetooth async_register_allocation_callback handles failures."""
manager = get_manager()
assert manager._loop is not None
address = "44:44:33:11:23:12"
switchbot_device_signal_100 = generate_ble_device(
address, "wohand_signal_100", rssi=-100
)
switchbot_adv_signal_100 = generate_advertisement_data(
local_name="wohand_signal_100", service_uuids=[]
)
inject_advertisement_with_source(
switchbot_device_signal_100, switchbot_adv_signal_100, "hci0"
)
failed_allocations: list[HaBluetoothSlotAllocations] = []
def _failing_callback(allocations: HaBluetoothSlotAllocations) -> None:
"""Failing callback."""
failed_allocations.append(allocations)
raise ValueError("This is a test")
ok_allocations: list[HaBluetoothSlotAllocations] = []
def _ok_callback(allocations: HaBluetoothSlotAllocations) -> None:
"""Ok callback."""
ok_allocations.append(allocations)
cancel1 = manager.async_register_allocation_callback(_failing_callback)
# Make sure the second callback still works if the first one fails and
# raises an exception
cancel2 = manager.async_register_allocation_callback(_ok_callback)
switchbot_adv_signal_100 = generate_advertisement_data(
local_name="wohand_signal_100",
manufacturer_data={123: b"abc"},
service_uuids=[],
rssi=-80,
)
inject_advertisement_with_source(
switchbot_device_signal_100, switchbot_adv_signal_100, "hci1"
)
assert manager.async_current_allocations() == [
HaBluetoothSlotAllocations(
source="AA:BB:CC:DD:EE:00", slots=5, free=5, allocated=[]
),
HaBluetoothSlotAllocations(
source="AA:BB:CC:DD:EE:11", slots=5, free=5, allocated=[]
),
]
manager.async_on_allocation_changed(
Allocations(
"AA:BB:CC:DD:EE:00",
5,
4,
["44:44:33:11:23:12"],
)
)
assert len(ok_allocations) == 1
assert ok_allocations[0] == HaBluetoothSlotAllocations(
"AA:BB:CC:DD:EE:00",
5,
4,
["44:44:33:11:23:12"],
)
assert len(failed_allocations) == 1
assert failed_allocations[0] == HaBluetoothSlotAllocations(
"AA:BB:CC:DD:EE:00",
5,
4,
["44:44:33:11:23:12"],
)
with patch.object(
manager.slot_manager,
"get_allocations",
return_value=Allocations(
adapter="hci0",
slots=5,
free=4,
allocated=["44:44:33:11:23:12"],
),
):
manager.slot_manager._call_callbacks(
AllocationChange.ALLOCATED, "/org/bluez/hci0/dev_44_44_33_11_23_12"
)
assert len(ok_allocations) == 2
assert manager.async_current_allocations() == [
HaBluetoothSlotAllocations("AA:BB:CC:DD:EE:00", 5, 4, ["44:44:33:11:23:12"]),
HaBluetoothSlotAllocations(
source="AA:BB:CC:DD:EE:11", slots=5, free=5, allocated=[]
),
]
assert manager.async_current_allocations("AA:BB:CC:DD:EE:00") == [
HaBluetoothSlotAllocations("AA:BB:CC:DD:EE:00", 5, 4, ["44:44:33:11:23:12"]),
]
cancel1()
cancel2()
@pytest.mark.asyncio
@pytest.mark.usefixtures("enable_bluetooth")
async def test_async_register_allocation_callback_non_connectable(
register_non_connectable_scanner: None,
) -> None:
"""Test async_current_allocations for a non-connectable scanner."""
manager = get_manager()
assert manager._loop is not None
assert manager.async_current_allocations() == [
HaBluetoothSlotAllocations(
source="AA:BB:CC:DD:EE:FF",
slots=0,
free=0,
allocated=[],
),
]
@pytest.mark.asyncio
@pytest.mark.usefixtures("enable_bluetooth")
async def test_async_register_scanner_registration_callback(
register_hci0_scanner: None,
register_hci1_scanner: None,
) -> None:
"""Test bluetooth async_register_scanner_registration_callback handles failures."""
manager = get_manager()
assert manager._loop is not None
scanners = manager.async_current_scanners()
assert len(scanners) == 2
sources = {scanner.source for scanner in scanners}
assert sources == {"AA:BB:CC:DD:EE:00", "AA:BB:CC:DD:EE:11"}
failed_scanner_callbacks: list[HaScannerRegistration] = []
def _failing_callback(scanner_registration: HaScannerRegistration) -> None:
"""Failing callback."""
failed_scanner_callbacks.append(scanner_registration)
raise ValueError("This is a test")
ok_scanner_callbacks: list[HaScannerRegistration] = []
def _ok_callback(scanner_registration: HaScannerRegistration) -> None:
"""Ok callback."""
ok_scanner_callbacks.append(scanner_registration)
cancel1 = manager.async_register_scanner_registration_callback(
_failing_callback, None
)
# Make sure the second callback still works if the first one fails and
# raises an exception
cancel2 = manager.async_register_scanner_registration_callback(_ok_callback, None)
hci3_scanner = FakeScanner("AA:BB:CC:DD:EE:33", "hci3")
hci3_scanner.connectable = True
manager = get_manager()
cancel = manager.async_register_scanner(hci3_scanner, connection_slots=5)
assert len(ok_scanner_callbacks) == 1
assert ok_scanner_callbacks[0] == HaScannerRegistration(
HaScannerRegistrationEvent.ADDED, hci3_scanner
)
assert len(failed_scanner_callbacks) == 1
cancel()
assert len(ok_scanner_callbacks) == 2
assert ok_scanner_callbacks[1] == HaScannerRegistration(
HaScannerRegistrationEvent.REMOVED, hci3_scanner
)
cancel1()
cancel2()
@pytest.mark.asyncio
async def test_async_register_scanner_with_connection_slots() -> None:
"""Test registering a scanner with connection slots."""
manager = get_manager()
assert manager._loop is not None
scanners = manager.async_current_scanners()
assert len(scanners) == 0
hci3_scanner = FakeScanner("AA:BB:CC:DD:EE:33", "hci3")
hci3_scanner.connectable = True
manager = get_manager()
cancel = manager.async_register_scanner(hci3_scanner, connection_slots=5)
assert manager.async_current_allocations(hci3_scanner.source) == [
HaBluetoothSlotAllocations(hci3_scanner.source, 5, 5, [])
]
cancel()
@pytest.mark.asyncio
@pytest.mark.usefixtures("enable_bluetooth")
async def test_diagnostics(register_hci0_scanner: None) -> None:
"""Test bluetooth diagnostics."""
manager = get_manager()
assert manager._loop is not None
manager.async_on_allocation_changed(
Allocations(
"AA:BB:CC:DD:EE:00",
5,
4,
["44:44:33:11:23:12"],
)
)
diagnostics = await manager.async_diagnostics()
assert diagnostics == {
"adapters": {},
"advertisement_tracker": ANY,
"all_history": ANY,
"allocations": {
"AA:BB:CC:DD:EE:00": {
"allocated": ["44:44:33:11:23:12"],
"free": 4,
"slots": 5,
"source": "AA:BB:CC:DD:EE:00",
}
},
"connectable_history": ANY,
"scanners": [
{
"discovered_devices_and_advertisement_data": [],
"connectable": True,
"current_mode": None,
"requested_mode": None,
"last_detection": 0.0,
"monotonic_time": ANY,
"name": "hci0 (AA:BB:CC:DD:EE:00)",
"scanning": True,
"source": "AA:BB:CC:DD:EE:00",
"start_time": 0.0,
"type": "FakeScanner",
}
],
"slot_manager": {
"adapter_slots": {"hci0": 5},
"allocations_by_adapter": {"hci0": []},
"manager": False,
},
}
@pytest.mark.usefixtures("enable_bluetooth")
@pytest.mark.asyncio
async def test_advertisements_do_not_switch_adapters_for_no_reason(
register_hci0_scanner: None,
register_hci1_scanner: None,
) -> None:
"""Test we only switch adapters when needed."""
address = "44:44:33:11:23:12"
switchbot_device_signal_100 = generate_ble_device(
address, "wohand_signal_100", rssi=-100
)
switchbot_adv_signal_100 = generate_advertisement_data(
local_name="wohand_signal_100", service_uuids=[]
)
inject_advertisement_with_source(
switchbot_device_signal_100, switchbot_adv_signal_100, HCI0_SOURCE_ADDRESS
)
assert (
get_manager().async_ble_device_from_address(address, True)
is switchbot_device_signal_100
)
switchbot_device_signal_99 = generate_ble_device(
address, "wohand_signal_99", rssi=-99
)
switchbot_adv_signal_99 = generate_advertisement_data(
local_name="wohand_signal_99", service_uuids=[]
)
inject_advertisement_with_source(
switchbot_device_signal_99, switchbot_adv_signal_99, HCI0_SOURCE_ADDRESS
)
assert (
get_manager().async_ble_device_from_address(address, True)
is switchbot_device_signal_99
)
switchbot_device_signal_98 = generate_ble_device(
address, "wohand_good_signal", rssi=-98
)
switchbot_adv_signal_98 = generate_advertisement_data(
local_name="wohand_good_signal", service_uuids=[]
)
inject_advertisement_with_source(
switchbot_device_signal_98, switchbot_adv_signal_98, HCI1_SOURCE_ADDRESS
)
# should not switch to hci1
assert (
get_manager().async_ble_device_from_address(address, True)
is switchbot_device_signal_99
)
@pytest.mark.usefixtures("enable_bluetooth")
@pytest.mark.asyncio
async def test_switching_adapters_based_on_rssi(
register_hci0_scanner: None,
register_hci1_scanner: None,
) -> None:
"""Test switching adapters based on rssi."""
address = "44:44:33:11:23:45"
switchbot_device_poor_signal = generate_ble_device(address, "wohand_poor_signal")
switchbot_adv_poor_signal = generate_advertisement_data(
local_name="wohand_poor_signal", service_uuids=[], rssi=-100
)
inject_advertisement_with_source(
switchbot_device_poor_signal,
switchbot_adv_poor_signal,
HCI0_SOURCE_ADDRESS,
)
assert (
get_manager().async_ble_device_from_address(address, True)
is switchbot_device_poor_signal
)
switchbot_device_good_signal = generate_ble_device(address, "wohand_good_signal")
switchbot_adv_good_signal = generate_advertisement_data(
local_name="wohand_good_signal", service_uuids=[], rssi=-60
)
inject_advertisement_with_source(
switchbot_device_good_signal,
switchbot_adv_good_signal,
HCI1_SOURCE_ADDRESS,
)
assert (
get_manager().async_ble_device_from_address(address, True)
is switchbot_device_good_signal
)
inject_advertisement_with_source(
switchbot_device_good_signal,
switchbot_adv_poor_signal,
HCI0_SOURCE_ADDRESS,
)
assert (
get_manager().async_ble_device_from_address(address, True)
is switchbot_device_good_signal
)
# We should not switch adapters unless the signal hits the threshold
switchbot_device_similar_signal = generate_ble_device(
address, "wohand_similar_signal"
)
switchbot_adv_similar_signal = generate_advertisement_data(
local_name="wohand_similar_signal", service_uuids=[], rssi=-62
)
inject_advertisement_with_source(
switchbot_device_similar_signal,
switchbot_adv_similar_signal,
HCI0_SOURCE_ADDRESS,
)
assert (
get_manager().async_ble_device_from_address(address, True)
is switchbot_device_good_signal
)
@pytest.mark.usefixtures("enable_bluetooth")
@pytest.mark.asyncio
async def test_switching_adapters_based_on_zero_rssi(
register_hci0_scanner: None,
register_hci1_scanner: None,
) -> None:
"""Test switching adapters based on zero rssi."""
address = "44:44:33:11:23:45"
switchbot_device_no_rssi = generate_ble_device(address, "wohand_poor_signal")
switchbot_adv_no_rssi = generate_advertisement_data(
local_name="wohand_no_rssi", service_uuids=[], rssi=0
)
inject_advertisement_with_source(
switchbot_device_no_rssi, switchbot_adv_no_rssi, HCI0_SOURCE_ADDRESS
)
assert (
get_manager().async_ble_device_from_address(address, True)
is switchbot_device_no_rssi
)
switchbot_device_good_signal = generate_ble_device(address, "wohand_good_signal")
switchbot_adv_good_signal = generate_advertisement_data(
local_name="wohand_good_signal", service_uuids=[], rssi=-60
)
inject_advertisement_with_source(
switchbot_device_good_signal,
switchbot_adv_good_signal,
HCI1_SOURCE_ADDRESS,
)
assert (
get_manager().async_ble_device_from_address(address, True)
is switchbot_device_good_signal
)
inject_advertisement_with_source(
switchbot_device_good_signal, switchbot_adv_no_rssi, HCI0_SOURCE_ADDRESS
)
assert (
get_manager().async_ble_device_from_address(address, True)
is switchbot_device_good_signal
)
# We should not switch adapters unless the signal hits the threshold
switchbot_device_similar_signal = generate_ble_device(
address, "wohand_similar_signal"
)
switchbot_adv_similar_signal = generate_advertisement_data(
local_name="wohand_similar_signal", service_uuids=[], rssi=-62
)
inject_advertisement_with_source(
switchbot_device_similar_signal,
switchbot_adv_similar_signal,
HCI0_SOURCE_ADDRESS,
)
assert (
get_manager().async_ble_device_from_address(address, True)
is switchbot_device_good_signal
)
@pytest.mark.usefixtures("enable_bluetooth")
@pytest.mark.asyncio
async def test_switching_adapters_based_on_stale(
register_hci0_scanner: None,
register_hci1_scanner: None,
) -> None:
"""Test switching adapters based on the previous advertisement being stale."""
address = "44:44:33:11:23:41"
start_time_monotonic = 50.0
switchbot_device_poor_signal_hci0 = generate_ble_device(
address, "wohand_poor_signal_hci0"
)
switchbot_adv_poor_signal_hci0 = generate_advertisement_data(
local_name="wohand_poor_signal_hci0", service_uuids=[], rssi=-100
)
inject_advertisement_with_time_and_source(
switchbot_device_poor_signal_hci0,
switchbot_adv_poor_signal_hci0,
start_time_monotonic,
HCI0_SOURCE_ADDRESS,
)
assert (
get_manager().async_ble_device_from_address(address, True)
is switchbot_device_poor_signal_hci0
)
switchbot_device_poor_signal_hci1 = generate_ble_device(
address, "wohand_poor_signal_hci1"
)
switchbot_adv_poor_signal_hci1 = generate_advertisement_data(
local_name="wohand_poor_signal_hci1", service_uuids=[], rssi=-99
)
inject_advertisement_with_time_and_source(
switchbot_device_poor_signal_hci1,
switchbot_adv_poor_signal_hci1,
start_time_monotonic,
HCI1_SOURCE_ADDRESS,
)
# Should not switch adapters until the advertisement is stale
assert (
get_manager().async_ble_device_from_address(address, True)
is switchbot_device_poor_signal_hci0
)
# Should switch to hci1 since the previous advertisement is stale
# even though the signal is poor because the device is now
# likely unreachable via hci0
inject_advertisement_with_time_and_source(
switchbot_device_poor_signal_hci1,
switchbot_adv_poor_signal_hci1,
start_time_monotonic + FALLBACK_MAXIMUM_STALE_ADVERTISEMENT_SECONDS + 1,
"hci1",
)
assert (
get_manager().async_ble_device_from_address(address, True)
is switchbot_device_poor_signal_hci1
)
@pytest.mark.usefixtures("enable_bluetooth")
@pytest.mark.asyncio
async def test_switching_adapters_based_on_stale_with_discovered_interval(
register_hci0_scanner: None,
register_hci1_scanner: None,
) -> None:
"""Test switching with discovered interval."""
address = "44:44:33:11:23:41"
start_time_monotonic = 50.0
switchbot_device_poor_signal_hci0 = generate_ble_device(
address, "wohand_poor_signal_hci0"
)
switchbot_adv_poor_signal_hci0 = generate_advertisement_data(
local_name="wohand_poor_signal_hci0", service_uuids=[], rssi=-100
)
inject_advertisement_with_time_and_source(
switchbot_device_poor_signal_hci0,
switchbot_adv_poor_signal_hci0,
start_time_monotonic,
HCI0_SOURCE_ADDRESS,
)
assert (
get_manager().async_ble_device_from_address(address, True)
is switchbot_device_poor_signal_hci0
)
get_manager().async_set_fallback_availability_interval(address, 10)
switchbot_device_poor_signal_hci1 = generate_ble_device(
address, "wohand_poor_signal_hci1"
)
switchbot_adv_poor_signal_hci1 = generate_advertisement_data(
local_name="wohand_poor_signal_hci1", service_uuids=[], rssi=-99
)
inject_advertisement_with_time_and_source(
switchbot_device_poor_signal_hci1,
switchbot_adv_poor_signal_hci1,
start_time_monotonic,
HCI1_SOURCE_ADDRESS,
)
# Should not switch adapters until the advertisement is stale
assert (
get_manager().async_ble_device_from_address(address, True)
is switchbot_device_poor_signal_hci0
)
inject_advertisement_with_time_and_source(
switchbot_device_poor_signal_hci1,
switchbot_adv_poor_signal_hci1,
start_time_monotonic + 10 + 1,
HCI1_SOURCE_ADDRESS,
)
# Should not switch yet since we are not within the
# wobble period
assert (
get_manager().async_ble_device_from_address(address, True)
is switchbot_device_poor_signal_hci0
)
inject_advertisement_with_time_and_source(
switchbot_device_poor_signal_hci1,
switchbot_adv_poor_signal_hci1,
start_time_monotonic + 10 + TRACKER_BUFFERING_WOBBLE_SECONDS + 1,
HCI1_SOURCE_ADDRESS,
)
# Should switch to hci1 since the previous advertisement is stale
# even though the signal is poor because the device is now
# likely unreachable via hci0
assert (
get_manager().async_ble_device_from_address(address, True)
is switchbot_device_poor_signal_hci1
)
@pytest.mark.usefixtures("enable_bluetooth")
@pytest.mark.asyncio
async def test_switching_adapters_based_on_rssi_connectable_to_non_connectable(
register_hci0_scanner: None,
register_hci1_scanner: None,
) -> None:
"""Test switching adapters based on rssi from connectable to non connectable."""
address = "44:44:33:11:23:45"
now = time.monotonic()
switchbot_device_poor_signal = generate_ble_device(address, "wohand_poor_signal")
switchbot_adv_poor_signal = generate_advertisement_data(
local_name="wohand_poor_signal", service_uuids=[], rssi=-100
)
inject_advertisement_with_time_and_source_connectable(
switchbot_device_poor_signal,
switchbot_adv_poor_signal,
now,
HCI0_SOURCE_ADDRESS,
True,
)
assert (
get_manager().async_ble_device_from_address(address, False)
is switchbot_device_poor_signal
)
assert (
get_manager().async_ble_device_from_address(address, True)
is switchbot_device_poor_signal
)
switchbot_device_good_signal = generate_ble_device(address, "wohand_good_signal")
switchbot_adv_good_signal = generate_advertisement_data(
local_name="wohand_good_signal", service_uuids=[], rssi=-60
)
inject_advertisement_with_time_and_source_connectable(
switchbot_device_good_signal,
switchbot_adv_good_signal,
now,
"hci1",
False,
)
assert (
get_manager().async_ble_device_from_address(address, False)
is switchbot_device_good_signal
)
assert (
get_manager().async_ble_device_from_address(address, True)
is switchbot_device_poor_signal
)
inject_advertisement_with_time_and_source_connectable(
switchbot_device_good_signal,
switchbot_adv_poor_signal,
now,
"hci0",
False,
)
assert (
get_manager().async_ble_device_from_address(address, False)
is switchbot_device_good_signal
)
assert (
get_manager().async_ble_device_from_address(address, True)
is switchbot_device_poor_signal
)
switchbot_device_excellent_signal = generate_ble_device(
address, "wohand_excellent_signal"
)
switchbot_adv_excellent_signal = generate_advertisement_data(
local_name="wohand_excellent_signal", service_uuids=[], rssi=-25
)
inject_advertisement_with_time_and_source_connectable(
switchbot_device_excellent_signal,
switchbot_adv_excellent_signal,
now,
"hci2",
False,
)
assert (
get_manager().async_ble_device_from_address(address, False)
is switchbot_device_excellent_signal
)
assert (
get_manager().async_ble_device_from_address(address, True)
is switchbot_device_poor_signal
)
@pytest.mark.usefixtures("enable_bluetooth")
@pytest.mark.asyncio
async def test_connectable_advertisement_can_be_retrieved_best_path_is_non_connectable(
register_hci0_scanner: None,
register_hci1_scanner: None,
) -> None:
"""
Test we can still get a connectable BLEDevice when the best path is non-connectable.
In this case the device is closer to a non-connectable scanner, but the
at least one connectable scanner has the device in range.
"""
address = "44:44:33:11:23:45"
now = time.monotonic()
switchbot_device_good_signal = generate_ble_device(address, "wohand_good_signal")
switchbot_adv_good_signal = generate_advertisement_data(
local_name="wohand_good_signal", service_uuids=[], rssi=-60
)
inject_advertisement_with_time_and_source_connectable(
switchbot_device_good_signal,
switchbot_adv_good_signal,
now,
HCI1_SOURCE_ADDRESS,
False,
)
assert (
get_manager().async_ble_device_from_address(address, False)
is switchbot_device_good_signal
)
assert get_manager().async_ble_device_from_address(address, True) is None
switchbot_device_poor_signal = generate_ble_device(address, "wohand_poor_signal")
switchbot_adv_poor_signal = generate_advertisement_data(
local_name="wohand_poor_signal", service_uuids=[], rssi=-100
)
inject_advertisement_with_time_and_source_connectable(
switchbot_device_poor_signal,
switchbot_adv_poor_signal,
now,
HCI0_SOURCE_ADDRESS,
True,
)
assert (
get_manager().async_ble_device_from_address(address, False)
is switchbot_device_good_signal
)
assert (
get_manager().async_ble_device_from_address(address, True)
is switchbot_device_poor_signal
)
@pytest.mark.usefixtures("enable_bluetooth")
@pytest.mark.asyncio
async def test_switching_adapters_when_one_goes_away(
register_hci0_scanner: None,
) -> None:
"""Test switching adapters when one goes away."""
cancel_hci2 = get_manager().async_register_scanner(FakeScanner("hci2", "hci2"))
address = "44:44:33:11:23:45"
switchbot_device_good_signal = generate_ble_device(address, "wohand_good_signal")
switchbot_adv_good_signal = generate_advertisement_data(
local_name="wohand_good_signal", service_uuids=[], rssi=-60
)
inject_advertisement_with_source(
switchbot_device_good_signal, switchbot_adv_good_signal, "hci2"
)
assert (
get_manager().async_ble_device_from_address(address, True)
is switchbot_device_good_signal
)
switchbot_device_poor_signal = generate_ble_device(address, "wohand_poor_signal")
switchbot_adv_poor_signal = generate_advertisement_data(
local_name="wohand_poor_signal", service_uuids=[], rssi=-100
)
inject_advertisement_with_source(
switchbot_device_poor_signal,
switchbot_adv_poor_signal,
HCI0_SOURCE_ADDRESS,
)
# We want to prefer the good signal when we have options
assert (
get_manager().async_ble_device_from_address(address, True)
is switchbot_device_good_signal
)
cancel_hci2()
inject_advertisement_with_source(
switchbot_device_poor_signal,
switchbot_adv_poor_signal,
HCI0_SOURCE_ADDRESS,
)
# Now that hci2 is gone, we should prefer the poor signal
# since no poor signal is better than no signal
assert (
get_manager().async_ble_device_from_address(address, True)
is switchbot_device_poor_signal
)
@pytest.mark.usefixtures("enable_bluetooth")
@pytest.mark.asyncio
async def test_switching_adapters_when_one_stop_scanning(
register_hci0_scanner: None,
) -> None:
"""Test switching adapters when stops scanning."""
hci2_scanner = FakeScanner("hci2", "hci2")
cancel_hci2 = get_manager().async_register_scanner(hci2_scanner)
address = "44:44:33:11:23:45"
switchbot_device_good_signal = generate_ble_device(address, "wohand_good_signal")
switchbot_adv_good_signal = generate_advertisement_data(
local_name="wohand_good_signal", service_uuids=[], rssi=-60
)
inject_advertisement_with_source(
switchbot_device_good_signal, switchbot_adv_good_signal, "hci2"
)
assert (
get_manager().async_ble_device_from_address(address, True)
is switchbot_device_good_signal
)
switchbot_device_poor_signal = generate_ble_device(address, "wohand_poor_signal")
switchbot_adv_poor_signal = generate_advertisement_data(
local_name="wohand_poor_signal", service_uuids=[], rssi=-100
)
inject_advertisement_with_source(
switchbot_device_poor_signal,
switchbot_adv_poor_signal,
HCI0_SOURCE_ADDRESS,
)
# We want to prefer the good signal when we have options
assert (
get_manager().async_ble_device_from_address(address, True)
is switchbot_device_good_signal
)
hci2_scanner.scanning = False
inject_advertisement_with_source(
switchbot_device_poor_signal,
switchbot_adv_poor_signal,
HCI0_SOURCE_ADDRESS,
)
# Now that hci2 has stopped scanning, we should prefer the poor signal
# since poor signal is better than no signal
assert (
get_manager().async_ble_device_from_address(address, True)
is switchbot_device_poor_signal
)
cancel_hci2()
@pytest.mark.usefixtures("enable_bluetooth", "macos_adapter")
@pytest.mark.asyncio
async def test_set_fallback_interval_small() -> None:
"""Test we can set the fallback advertisement interval."""
assert (
get_manager().async_get_fallback_availability_interval("44:44:33:11:23:12")
is None
)
get_manager().async_set_fallback_availability_interval("44:44:33:11:23:12", 2.0)
assert (
get_manager().async_get_fallback_availability_interval("44:44:33:11:23:12")
== 2.0
)
start_monotonic_time = time.monotonic()
switchbot_device = generate_ble_device("44:44:33:11:23:12", "wohand")
switchbot_adv = generate_advertisement_data(
local_name="wohand", service_uuids=["cba20d00-224d-11e6-9fb8-0002a5d5c51b"]
)
switchbot_device_went_unavailable = False
inject_advertisement_with_time_and_source(
switchbot_device,
switchbot_adv,
start_monotonic_time,
SOURCE_LOCAL,
)
def _switchbot_device_unavailable_callback(
_address: BluetoothServiceInfoBleak,
) -> None:
"""Switchbot device unavailable callback."""
nonlocal switchbot_device_went_unavailable
switchbot_device_went_unavailable = True
assert (
get_manager().async_get_learned_advertising_interval("44:44:33:11:23:12")
is None
)
switchbot_device_unavailable_cancel = get_manager().async_track_unavailable(
_switchbot_device_unavailable_callback,
switchbot_device.address,
connectable=False,
)
monotonic_now = start_monotonic_time + 2
with patch_bluetooth_time(
monotonic_now + UNAVAILABLE_TRACK_SECONDS,
):
async_fire_time_changed(utcnow() + timedelta(seconds=UNAVAILABLE_TRACK_SECONDS))
await asyncio.sleep(0)
assert switchbot_device_went_unavailable is True
switchbot_device_unavailable_cancel()
# We should forget fallback interval after it expires
assert (
get_manager().async_get_fallback_availability_interval("44:44:33:11:23:12")
is None
)
@pytest.mark.usefixtures("enable_bluetooth", "macos_adapter")
@pytest.mark.asyncio
async def test_set_fallback_interval_big() -> None:
"""Test we can set the fallback advertisement interval."""
assert (
get_manager().async_get_fallback_availability_interval("44:44:33:11:23:12")
is None
)
# Force the interval to be really big and check it doesn't expire using the default
# timeout (900)
get_manager().async_set_fallback_availability_interval(
"44:44:33:11:23:12", 604800.0
)
assert (
get_manager().async_get_fallback_availability_interval("44:44:33:11:23:12")
== 604800.0
)
start_monotonic_time = time.monotonic()
switchbot_device = generate_ble_device("44:44:33:11:23:12", "wohand")
switchbot_adv = generate_advertisement_data(
local_name="wohand", service_uuids=["cba20d00-224d-11e6-9fb8-0002a5d5c51b"]
)
switchbot_device_went_unavailable = False
inject_advertisement_with_time_and_source(
switchbot_device,
switchbot_adv,
start_monotonic_time,
SOURCE_LOCAL,
)
def _switchbot_device_unavailable_callback(
_address: BluetoothServiceInfoBleak,
) -> None:
"""Switchbot device unavailable callback."""
nonlocal switchbot_device_went_unavailable
switchbot_device_went_unavailable = True
assert (
get_manager().async_get_learned_advertising_interval("44:44:33:11:23:12")
is None
)
switchbot_device_unavailable_cancel = get_manager().async_track_unavailable(
_switchbot_device_unavailable_callback,
switchbot_device.address,
connectable=False,
)
# Check that device hasn't expired after a day
monotonic_now = start_monotonic_time + 86400
with patch_bluetooth_time(
monotonic_now + UNAVAILABLE_TRACK_SECONDS,
):
async_fire_time_changed(utcnow() + timedelta(seconds=UNAVAILABLE_TRACK_SECONDS))
await asyncio.sleep(0)
assert switchbot_device_went_unavailable is False
# Try again after it has expired
monotonic_now = start_monotonic_time + 604800
with patch_bluetooth_time(
monotonic_now + UNAVAILABLE_TRACK_SECONDS,
):
async_fire_time_changed(utcnow() + timedelta(seconds=UNAVAILABLE_TRACK_SECONDS))
await asyncio.sleep(0)
assert switchbot_device_went_unavailable is True
switchbot_device_unavailable_cancel() # type: ignore[unreachable]
# We should forget fallback interval after it expires
assert (
get_manager().async_get_fallback_availability_interval("44:44:33:11:23:12")
is None
)
@pytest.mark.asyncio
async def test_subclassing_bluetooth_manager(caplog: pytest.LogCaptureFixture) -> None:
"""Test subclassing BluetoothManager."""
slot_manager = BleakSlotManager()
bluetooth_adapters = FakeBluetoothAdapters()
class TestBluetoothManager(BluetoothManager):
"""
Test class for BluetoothManager.
This class implements _discover_service_info.
"""
def _discover_service_info(
self, service_info: BluetoothServiceInfoBleak
) -> None:
"""
Discover a new service info.
This method is intended to be overridden by subclasses.
"""
TestBluetoothManager(bluetooth_adapters, slot_manager)
assert "does not implement _discover_service_info" not in caplog.text
class TestBluetoothManager2(BluetoothManager):
"""
Test class for BluetoothManager.
This class does not implement _discover_service_info.
"""
TestBluetoothManager2(bluetooth_adapters, slot_manager)
assert "does not implement _discover_service_info" in caplog.text
habluetooth-3.48.2/tests/test_models.py 0000664 0000000 0000000 00000023005 15005442573 0020171 0 ustar 00root root 0000000 0000000 from __future__ import annotations
import time
from bleak.backends.device import BLEDevice
from habluetooth import BluetoothServiceInfo, BluetoothServiceInfoBleak
from . import generate_advertisement_data
SOURCE_LOCAL = "local"
def test_model():
service_info = BluetoothServiceInfo(
name="Test",
address="00:00:00:00:00:00",
rssi=0,
manufacturer_data={97: b"\x00\x00\x00\x00\x00\x00"},
service_data={},
service_uuids=[],
source=SOURCE_LOCAL,
)
assert service_info.manufacturer == "RDA Microelectronics"
assert service_info.manufacturer_id == 97
service_info = BluetoothServiceInfo(
name="Test",
address="00:00:00:00:00:00",
rssi=0,
manufacturer_data={954547: b"\x00\x00\x00\x00\x00\x00"},
service_data={},
service_uuids=[],
source=SOURCE_LOCAL,
)
assert service_info.manufacturer is None
assert service_info.manufacturer_id == 954547
def test_model_from_bleak():
switchbot_device = BLEDevice("44:44:33:11:23:45", "wohand", {}, -127)
switchbot_adv = generate_advertisement_data(
local_name="wohand", service_uuids=["cba20d00-224d-11e6-9fb8-0002a5d5c51b"]
)
service_info = BluetoothServiceInfo.from_advertisement(
switchbot_device, switchbot_adv, SOURCE_LOCAL
)
assert service_info.service_uuids == ["cba20d00-224d-11e6-9fb8-0002a5d5c51b"]
assert service_info.name == "wohand"
assert service_info.source == SOURCE_LOCAL
assert service_info.manufacturer is None
assert service_info.manufacturer_id is None
def test_model_from_scanner():
switchbot_device = BLEDevice("44:44:33:11:23:45", "wohand", {}, -127)
switchbot_adv = generate_advertisement_data(
local_name="wohand", service_uuids=["cba20d00-224d-11e6-9fb8-0002a5d5c51b"]
)
now = time.monotonic()
service_info = BluetoothServiceInfoBleak.from_scan(
SOURCE_LOCAL, switchbot_device, switchbot_adv, now, True
)
assert service_info.service_uuids == ["cba20d00-224d-11e6-9fb8-0002a5d5c51b"]
assert service_info.name == "wohand"
assert service_info.source == SOURCE_LOCAL
assert service_info.manufacturer is None
assert service_info.manufacturer_id is None
assert service_info.time == now
assert service_info.connectable is True
safe_as_dict = service_info.as_dict()
assert safe_as_dict == {
"address": "44:44:33:11:23:45",
"advertisement": switchbot_adv,
"device": switchbot_device,
"connectable": True,
"manufacturer_data": {},
"name": "wohand",
"raw": None,
"rssi": -127,
"service_data": {},
"service_uuids": ["cba20d00-224d-11e6-9fb8-0002a5d5c51b"],
"source": "local",
"time": now,
"tx_power": -127,
}
def test_construct_service_info_bleak():
switchbot_device = BLEDevice("44:44:33:11:23:45", "wohand", {}, -127)
switchbot_adv = generate_advertisement_data(
local_name="wohand", service_uuids=["cba20d00-224d-11e6-9fb8-0002a5d5c51b"]
)
now = time.monotonic()
service_info = BluetoothServiceInfoBleak(
name="wohand",
address="44:44:33:11:23:45",
rssi=-127,
manufacturer_data=switchbot_adv.manufacturer_data,
service_data=switchbot_adv.service_data,
service_uuids=switchbot_adv.service_uuids,
source=SOURCE_LOCAL,
device=switchbot_device,
advertisement=switchbot_adv,
connectable=False,
time=now,
tx_power=1,
)
assert service_info.service_uuids == ["cba20d00-224d-11e6-9fb8-0002a5d5c51b"]
assert service_info.name == "wohand"
assert service_info.source == SOURCE_LOCAL
assert service_info.manufacturer is None
assert service_info.manufacturer_id is None
assert service_info.time == now
assert service_info.connectable is False
safe_as_dict = service_info.as_dict()
assert safe_as_dict == {
"address": "44:44:33:11:23:45",
"advertisement": switchbot_adv,
"device": switchbot_device,
"connectable": False,
"raw": None,
"manufacturer_data": {},
"name": "wohand",
"rssi": -127,
"service_data": {},
"service_uuids": ["cba20d00-224d-11e6-9fb8-0002a5d5c51b"],
"source": "local",
"time": now,
"tx_power": 1,
}
def test_from_device_and_advertisement_data():
"""
Test creating a BluetoothServiceInfoBleak.
From a BLEDevice and AdvertisementData.
"""
switchbot_device = BLEDevice("44:44:33:11:23:45", "wohand", {}, -127)
switchbot_adv = generate_advertisement_data(
local_name="wohand", service_uuids=["cba20d00-224d-11e6-9fb8-0002a5d5c51b"]
)
now_monotonic = time.monotonic()
service_info = BluetoothServiceInfoBleak.from_device_and_advertisement_data(
switchbot_device, switchbot_adv, SOURCE_LOCAL, now_monotonic, True
)
assert service_info.service_uuids == ["cba20d00-224d-11e6-9fb8-0002a5d5c51b"]
assert service_info.name == "wohand"
assert service_info.source == SOURCE_LOCAL
assert service_info.manufacturer is None
assert service_info.manufacturer_id is None
safe_as_dict = service_info.as_dict()
assert safe_as_dict == {
"address": "44:44:33:11:23:45",
"advertisement": switchbot_adv,
"device": switchbot_device,
"connectable": True,
"manufacturer_data": {},
"name": "wohand",
"raw": None,
"rssi": -127,
"service_data": {},
"service_uuids": ["cba20d00-224d-11e6-9fb8-0002a5d5c51b"],
"source": "local",
"time": now_monotonic,
"tx_power": -127,
}
assert str(service_info) == (
""
)
def test_pyobjc_compat():
class pyobjc_str(str):
pass
class pyobjc_int(int):
pass
name = pyobjc_str("wohand")
address = pyobjc_str("44:44:33:11:23:45")
rssi = pyobjc_int(-127)
assert name == "wohand"
assert address == "44:44:33:11:23:45"
assert rssi == -127
switchbot_device = BLEDevice(address, name, {}, rssi)
switchbot_adv = generate_advertisement_data(
local_name=name, service_uuids=["cba20d00-224d-11e6-9fb8-0002a5d5c51b"]
)
now = time.monotonic()
service_info = BluetoothServiceInfoBleak(
name=str(name),
address=str(address),
rssi=rssi,
manufacturer_data=switchbot_adv.manufacturer_data,
service_data=switchbot_adv.service_data,
service_uuids=switchbot_adv.service_uuids,
source=SOURCE_LOCAL,
device=switchbot_device,
advertisement=switchbot_adv,
connectable=False,
time=now,
tx_power=1,
)
assert service_info.service_uuids == ["cba20d00-224d-11e6-9fb8-0002a5d5c51b"]
assert service_info.name == "wohand"
assert service_info.source == SOURCE_LOCAL
assert service_info.manufacturer is None
assert service_info.manufacturer_id is None
assert service_info.time == now
assert service_info.connectable is False
safe_as_dict = service_info.as_dict()
assert safe_as_dict == {
"address": "44:44:33:11:23:45",
"advertisement": switchbot_adv,
"device": switchbot_device,
"connectable": False,
"manufacturer_data": {},
"name": "wohand",
"raw": None,
"rssi": -127,
"service_data": {},
"service_uuids": ["cba20d00-224d-11e6-9fb8-0002a5d5c51b"],
"source": "local",
"time": now,
"tx_power": 1,
}
def test_as_connectable():
switchbot_device = BLEDevice("44:44:33:11:23:45", "wohand", {}, -127)
switchbot_adv = generate_advertisement_data(
local_name="wohand", service_uuids=["cba20d00-224d-11e6-9fb8-0002a5d5c51b"]
)
now = time.monotonic()
service_info = BluetoothServiceInfoBleak(
name="wohand",
address="44:44:33:11:23:45",
rssi=-127,
manufacturer_data=switchbot_adv.manufacturer_data,
service_data=switchbot_adv.service_data,
service_uuids=switchbot_adv.service_uuids,
source=SOURCE_LOCAL,
device=switchbot_device,
advertisement=switchbot_adv,
connectable=False,
time=now,
tx_power=1,
raw=b"\x00\x00\x00\x00\x00\x00",
)
connectable_service_info = service_info._as_connectable()
assert connectable_service_info.connectable is True
assert service_info.connectable is False
assert connectable_service_info is not service_info
assert service_info.name == connectable_service_info.name
assert service_info.address == connectable_service_info.address
assert service_info.rssi == connectable_service_info.rssi
assert service_info.manufacturer_data == connectable_service_info.manufacturer_data
assert service_info.service_data == connectable_service_info.service_data
assert service_info.service_uuids == connectable_service_info.service_uuids
assert service_info.source == connectable_service_info.source
assert service_info.device == connectable_service_info.device
assert service_info.advertisement == connectable_service_info.advertisement
assert service_info.time == connectable_service_info.time
assert service_info.tx_power == connectable_service_info.tx_power
assert service_info.raw == connectable_service_info.raw
habluetooth-3.48.2/tests/test_scanner.py 0000664 0000000 0000000 00000065521 15005442573 0020350 0 ustar 00root root 0000000 0000000 """Tests for the Bluetooth integration scanners."""
import asyncio
import time
from datetime import timedelta
from typing import Any
from unittest.mock import ANY, MagicMock, Mock, patch
import pytest
from bleak import BleakError
from bleak.backends.scanner import AdvertisementDataCallback
from bleak_retry_connector import BleakSlotManager
from habluetooth import (
SCANNER_WATCHDOG_INTERVAL,
SCANNER_WATCHDOG_TIMEOUT,
BluetoothManager,
BluetoothScanningMode,
HaScanner,
ScannerStartError,
scanner,
set_manager,
)
from habluetooth.scanner import InvalidMessageError
from . import (
async_fire_time_changed,
generate_advertisement_data,
generate_ble_device,
patch_bluetooth_time,
utcnow,
)
from .conftest import FakeBluetoothAdapters
IS_WINDOWS = 'os.name == "nt"'
IS_POSIX = 'os.name == "posix"'
NOT_POSIX = 'os.name != "posix"'
# or_patterns is a workaround for the fact that passive scanning
# needs at least one matcher to be set. The below matcher
# will match all devices.
scanner.PASSIVE_SCANNER_ARGS = Mock()
# If the adapter is in a stuck state the following errors are raised:
NEED_RESET_ERRORS = [
"org.bluez.Error.Failed",
"org.bluez.Error.InProgress",
"org.bluez.Error.NotReady",
"not found",
]
@pytest.fixture(autouse=True, scope="module")
def manager():
"""Return the BluetoothManager instance."""
adapters = FakeBluetoothAdapters()
slot_manager = BleakSlotManager()
manager = BluetoothManager(adapters, slot_manager)
set_manager(manager)
return manager
@pytest.mark.asyncio
async def test_empty_data_no_scanner() -> None:
"""Test we handle empty data."""
scanner = HaScanner(BluetoothScanningMode.ACTIVE, "hci0", "AA:BB:CC:DD:EE:FF")
scanner.async_setup()
assert scanner.discovered_devices == []
assert scanner.discovered_devices_and_advertisement_data == {}
@pytest.mark.asyncio
@pytest.mark.skipif(NOT_POSIX)
async def test_dbus_socket_missing_in_container(
caplog: pytest.LogCaptureFixture,
) -> None:
"""Test we handle dbus being missing in the container."""
with (
patch("habluetooth.scanner.is_docker_env", return_value=True),
patch(
"habluetooth.scanner.OriginalBleakScanner.start",
side_effect=FileNotFoundError,
),
patch(
"habluetooth.scanner.OriginalBleakScanner.stop",
) as mock_stop,
pytest.raises(
ScannerStartError,
match="DBus service not found; docker config may be missing",
),
):
scanner = HaScanner(BluetoothScanningMode.ACTIVE, "hci0", "AA:BB:CC:DD:EE:FF")
scanner.async_setup()
await scanner.async_start()
assert mock_stop.called
await scanner.async_stop()
@pytest.mark.asyncio
@pytest.mark.skipif(NOT_POSIX)
async def test_dbus_socket_missing(caplog: pytest.LogCaptureFixture) -> None:
"""Test we handle dbus being missing."""
with (
patch("habluetooth.scanner.is_docker_env", return_value=False),
patch(
"habluetooth.scanner.OriginalBleakScanner.start",
side_effect=FileNotFoundError,
),
patch(
"habluetooth.scanner.OriginalBleakScanner.stop",
) as mock_stop,
pytest.raises(
ScannerStartError,
match="DBus service not found; make sure the DBus socket is available",
),
):
scanner = HaScanner(BluetoothScanningMode.ACTIVE, "hci0", "AA:BB:CC:DD:EE:FF")
scanner.async_setup()
await scanner.async_start()
assert mock_stop.called
await scanner.async_stop()
@pytest.mark.asyncio
@pytest.mark.skipif(NOT_POSIX)
async def test_handle_cancellation(caplog: pytest.LogCaptureFixture) -> None:
"""Test cancellation stops."""
with (
patch("habluetooth.scanner.is_docker_env", return_value=False),
patch(
"habluetooth.scanner.OriginalBleakScanner.start",
side_effect=asyncio.CancelledError,
),
patch(
"habluetooth.scanner.OriginalBleakScanner.stop",
) as mock_stop,
):
scanner = HaScanner(BluetoothScanningMode.ACTIVE, "hci0", "AA:BB:CC:DD:EE:FF")
scanner.async_setup()
with pytest.raises(asyncio.CancelledError):
await scanner.async_start()
assert mock_stop.called
@pytest.mark.asyncio
@pytest.mark.skipif(NOT_POSIX)
async def test_handle_stop_while_starting(caplog: pytest.LogCaptureFixture) -> None:
"""Test stop while starting."""
async def _start(*args, **kwargs):
await asyncio.sleep(1000)
with (
patch("habluetooth.scanner.is_docker_env", return_value=False),
patch("habluetooth.scanner.OriginalBleakScanner.start", _start),
patch(
"habluetooth.scanner.OriginalBleakScanner.stop",
) as mock_stop,
):
scanner = HaScanner(BluetoothScanningMode.ACTIVE, "hci0", "AA:BB:CC:DD:EE:FF")
scanner.async_setup()
task = asyncio.create_task(scanner.async_start())
await asyncio.sleep(0)
await scanner.async_stop()
with pytest.raises(
ScannerStartError, match="Starting bluetooth scanner aborted"
):
await task
assert mock_stop.called
@pytest.mark.asyncio
@pytest.mark.skipif(NOT_POSIX)
async def test_dbus_broken_pipe_in_container(caplog: pytest.LogCaptureFixture) -> None:
"""Test we handle dbus broken pipe in the container."""
with (
patch("habluetooth.scanner.is_docker_env", return_value=True),
patch(
"habluetooth.scanner.OriginalBleakScanner.start",
side_effect=BrokenPipeError,
),
patch(
"habluetooth.scanner.OriginalBleakScanner.stop",
) as mock_stop,
pytest.raises(ScannerStartError, match="DBus connection broken"),
):
scanner = HaScanner(BluetoothScanningMode.ACTIVE, "hci0", "AA:BB:CC:DD:EE:FF")
scanner.async_setup()
await scanner.async_start()
assert mock_stop.called
await scanner.async_stop()
@pytest.mark.asyncio
@pytest.mark.skipif(NOT_POSIX)
async def test_dbus_broken_pipe(caplog: pytest.LogCaptureFixture) -> None:
"""Test we handle dbus broken pipe."""
with (
patch("habluetooth.scanner.is_docker_env", return_value=False),
patch(
"habluetooth.scanner.OriginalBleakScanner.start",
side_effect=BrokenPipeError,
),
patch(
"habluetooth.scanner.OriginalBleakScanner.stop",
) as mock_stop,
pytest.raises(ScannerStartError, match="DBus connection broken:"),
):
scanner = HaScanner(BluetoothScanningMode.ACTIVE, "hci0", "AA:BB:CC:DD:EE:FF")
scanner.async_setup()
await scanner.async_start()
assert mock_stop.called
await scanner.async_stop()
@pytest.mark.asyncio
@pytest.mark.skipif(NOT_POSIX)
async def test_invalid_dbus_message(caplog: pytest.LogCaptureFixture) -> None:
"""Test we handle invalid dbus message."""
with (
patch(
"habluetooth.scanner.OriginalBleakScanner.start",
side_effect=InvalidMessageError,
),
pytest.raises(ScannerStartError, match="Invalid DBus message received"),
):
scanner = HaScanner(BluetoothScanningMode.ACTIVE, "hci0", "AA:BB:CC:DD:EE:FF")
scanner.async_setup()
await scanner.async_start()
await scanner.async_stop()
@pytest.mark.asyncio
@pytest.mark.skipif(IS_WINDOWS)
@pytest.mark.parametrize("error", NEED_RESET_ERRORS)
async def test_adapter_needs_reset_at_start(
caplog: pytest.LogCaptureFixture, error: str
) -> None:
"""Test we cycle the adapter when it needs a restart."""
called_start = 0
called_stop = 0
_callback = None
mock_discovered: list[Any] = []
class MockBleakScanner:
async def start(self, *args, **kwargs):
"""Mock Start."""
nonlocal called_start
called_start += 1
if called_start < 3:
raise BleakError(error)
async def stop(self, *args, **kwargs):
"""Mock Start."""
nonlocal called_stop
called_stop += 1
@property
def discovered_devices(self):
"""Mock discovered_devices."""
nonlocal mock_discovered
return mock_discovered
def register_detection_callback(
self, callback: AdvertisementDataCallback
) -> None:
"""Mock Register Detection Callback."""
nonlocal _callback
_callback = callback
mock_scanner = MockBleakScanner()
with (
patch("habluetooth.scanner.OriginalBleakScanner", return_value=mock_scanner),
patch(
"habluetooth.util.recover_adapter", return_value=True
) as mock_recover_adapter,
):
scanner = HaScanner(BluetoothScanningMode.ACTIVE, "hci0", "AA:BB:CC:DD:EE:FF")
scanner.async_setup()
await scanner.async_start()
assert len(mock_recover_adapter.mock_calls) == 1
await scanner.async_stop()
@pytest.mark.asyncio
@pytest.mark.skipif(IS_WINDOWS)
async def test_recovery_from_dbus_restart() -> None:
"""Test we can recover when DBus gets restarted out from under us."""
called_start = 0
called_stop = 0
_callback = None
mock_discovered: list[Any] = []
class MockBleakScanner:
def __init__(self, detection_callback, *args, **kwargs):
nonlocal _callback
_callback = detection_callback
async def start(self, *args, **kwargs):
"""Mock Start."""
nonlocal called_start
called_start += 1
async def stop(self, *args, **kwargs):
"""Mock Start."""
nonlocal called_stop
called_stop += 1
@property
def discovered_devices(self):
"""Mock discovered_devices."""
nonlocal mock_discovered
return mock_discovered
with patch(
"habluetooth.scanner.OriginalBleakScanner",
MockBleakScanner,
):
scanner = HaScanner(BluetoothScanningMode.ACTIVE, "hci0", "AA:BB:CC:DD:EE:FF")
scanner.async_setup()
await scanner.async_start()
assert called_start == 1
start_time_monotonic = time.monotonic()
mock_discovered = [MagicMock()]
# Ensure we don't restart the scanner if we don't need to
with patch_bluetooth_time(
start_time_monotonic + 10,
):
async_fire_time_changed(utcnow() + SCANNER_WATCHDOG_INTERVAL)
assert called_start == 1
# Fire a callback to reset the timer
with patch_bluetooth_time(
start_time_monotonic,
):
_callback( # type: ignore
generate_ble_device("44:44:33:11:23:42", "any_name"),
generate_advertisement_data(local_name="any_name"),
)
# Ensure we don't restart the scanner if we don't need to
with patch_bluetooth_time(
start_time_monotonic + 20,
):
async_fire_time_changed(utcnow() + SCANNER_WATCHDOG_INTERVAL)
await asyncio.sleep(0)
assert called_start == 1
# We hit the timer, so we restart the scanner
with patch_bluetooth_time(
start_time_monotonic + SCANNER_WATCHDOG_TIMEOUT + 20,
):
async_fire_time_changed(
utcnow() + SCANNER_WATCHDOG_INTERVAL + timedelta(seconds=20)
)
await asyncio.sleep(0)
assert called_start == 2
await scanner.async_stop()
@pytest.mark.asyncio
@pytest.mark.skipif(IS_WINDOWS)
async def test_adapter_recovery() -> None:
"""Test we can recover when the adapter stops responding."""
called_start = 0
called_stop = 0
_callback = None
mock_discovered: list[Any] = []
class MockBleakScanner:
async def start(self, *args, **kwargs):
"""Mock Start."""
nonlocal called_start
called_start += 1
async def stop(self, *args, **kwargs):
"""Mock Start."""
nonlocal called_stop
called_stop += 1
@property
def discovered_devices(self):
"""Mock discovered_devices."""
nonlocal mock_discovered
return mock_discovered
def register_detection_callback(
self, callback: AdvertisementDataCallback
) -> None:
"""Mock Register Detection Callback."""
nonlocal _callback
_callback = callback
mock_scanner = MockBleakScanner()
start_time_monotonic = time.monotonic()
with (
patch_bluetooth_time(
start_time_monotonic,
),
patch(
"habluetooth.scanner.OriginalBleakScanner",
return_value=mock_scanner,
),
):
scanner = HaScanner(BluetoothScanningMode.ACTIVE, "hci0", "AA:BB:CC:DD:EE:FF")
scanner.async_setup()
await scanner.async_start()
assert called_start == 1
mock_discovered = [MagicMock()]
# Ensure we don't restart the scanner if we don't need to
with patch_bluetooth_time(
start_time_monotonic + 10,
):
async_fire_time_changed(utcnow() + SCANNER_WATCHDOG_INTERVAL)
await asyncio.sleep(0)
assert called_start == 1
# Ensure we don't restart the scanner if we don't need to
with patch_bluetooth_time(
start_time_monotonic + 20,
):
async_fire_time_changed(utcnow() + SCANNER_WATCHDOG_INTERVAL)
await asyncio.sleep(0)
assert called_start == 1
# We hit the timer with no detections, so we
# reset the adapter and restart the scanner
with (
patch_bluetooth_time(
start_time_monotonic
+ SCANNER_WATCHDOG_TIMEOUT
+ SCANNER_WATCHDOG_INTERVAL.total_seconds(),
),
patch(
"habluetooth.util.recover_adapter", return_value=True
) as mock_recover_adapter,
):
async_fire_time_changed(utcnow() + SCANNER_WATCHDOG_INTERVAL)
await asyncio.sleep(0)
assert len(mock_recover_adapter.mock_calls) == 1
assert mock_recover_adapter.call_args_list[0][0] == (
0,
"AA:BB:CC:DD:EE:FF",
True,
)
assert called_start == 2
await scanner.async_stop()
@pytest.mark.asyncio
@pytest.mark.skipif(IS_WINDOWS)
async def test_adapter_scanner_fails_to_start_first_time() -> None:
"""
Test we can recover when the adapter stops responding.
The first recovery fails.
"""
called_start = 0
called_stop = 0
_callback = None
mock_discovered: list[Any] = []
class MockBleakScanner:
async def start(self, *args, **kwargs):
"""Mock Start."""
nonlocal called_start
called_start += 1
if called_start == 1:
return # Start ok the first time
if called_start < 4:
raise BleakError("Failed to start")
async def stop(self, *args, **kwargs):
"""Mock Start."""
nonlocal called_stop
called_stop += 1
@property
def discovered_devices(self):
"""Mock discovered_devices."""
nonlocal mock_discovered
return mock_discovered
def register_detection_callback(
self, callback: AdvertisementDataCallback
) -> None:
"""Mock Register Detection Callback."""
nonlocal _callback
_callback = callback
mock_scanner = MockBleakScanner()
start_time_monotonic = time.monotonic()
with (
patch_bluetooth_time(
start_time_monotonic,
),
patch(
"habluetooth.scanner.OriginalBleakScanner",
return_value=mock_scanner,
),
):
scanner = HaScanner(BluetoothScanningMode.ACTIVE, "hci0", "AA:BB:CC:DD:EE:FF")
scanner.async_setup()
await scanner.async_start()
assert called_start == 1
mock_discovered = [MagicMock()]
# Ensure we don't restart the scanner if we don't need to
with patch_bluetooth_time(
start_time_monotonic + 10,
):
async_fire_time_changed(utcnow() + SCANNER_WATCHDOG_INTERVAL)
await asyncio.sleep(0)
assert called_start == 1
# Ensure we don't restart the scanner if we don't need to
with patch_bluetooth_time(
start_time_monotonic + 20,
):
async_fire_time_changed(utcnow() + SCANNER_WATCHDOG_INTERVAL)
await asyncio.sleep(0)
assert called_start == 1
# We hit the timer with no detections,
# so we reset the adapter and restart the scanner
with (
patch_bluetooth_time(
start_time_monotonic
+ SCANNER_WATCHDOG_TIMEOUT
+ SCANNER_WATCHDOG_INTERVAL.total_seconds(),
),
patch(
"habluetooth.util.recover_adapter", return_value=True
) as mock_recover_adapter,
):
async_fire_time_changed(utcnow() + SCANNER_WATCHDOG_INTERVAL)
await asyncio.sleep(0)
assert len(mock_recover_adapter.mock_calls) == 1
assert called_start == 4
assert scanner.scanning is True
now_monotonic = time.monotonic()
# We hit the timer again the previous start call failed, make sure
# we try again
with (
patch_bluetooth_time(
now_monotonic
+ SCANNER_WATCHDOG_TIMEOUT * 2
+ SCANNER_WATCHDOG_INTERVAL.total_seconds(),
),
patch(
"habluetooth.util.recover_adapter", return_value=True
) as mock_recover_adapter,
):
async_fire_time_changed(utcnow() + SCANNER_WATCHDOG_INTERVAL)
await asyncio.sleep(0)
assert len(mock_recover_adapter.mock_calls) == 1
assert called_start == 5
await scanner.async_stop()
@pytest.mark.asyncio
async def test_adapter_fails_to_start_and_takes_a_bit_to_init(
caplog: pytest.LogCaptureFixture,
) -> None:
"""Test we can recover the adapter at startup and we wait for Dbus to init."""
called_start = 0
called_stop = 0
_callback = None
mock_discovered: list[Any] = []
class MockBleakScanner:
async def start(self, *args, **kwargs):
"""Mock Start."""
nonlocal called_start
called_start += 1
if called_start == 1:
raise BleakError("org.freedesktop.DBus.Error.UnknownObject")
if called_start == 2:
raise BleakError("org.bluez.Error.InProgress")
if called_start == 3:
raise BleakError("org.bluez.Error.InProgress")
async def stop(self, *args, **kwargs):
"""Mock Start."""
nonlocal called_stop
called_stop += 1
@property
def discovered_devices(self):
"""Mock discovered_devices."""
nonlocal mock_discovered
return mock_discovered
def register_detection_callback(
self, callback: AdvertisementDataCallback
) -> None:
"""Mock Register Detection Callback."""
nonlocal _callback
_callback = callback
mock_scanner = MockBleakScanner()
start_time_monotonic = time.monotonic()
with (
patch(
"habluetooth.scanner.ADAPTER_INIT_TIME",
0,
),
patch_bluetooth_time(
start_time_monotonic,
),
patch(
"habluetooth.scanner.OriginalBleakScanner",
return_value=mock_scanner,
),
patch(
"habluetooth.util.recover_adapter", return_value=True
) as mock_recover_adapter,
):
scanner = HaScanner(BluetoothScanningMode.ACTIVE, "hci0", "AA:BB:CC:DD:EE:FF")
scanner.async_setup()
await scanner.async_start()
assert called_start == 4
assert len(mock_recover_adapter.mock_calls) == 1
assert "Waiting for adapter to initialize" in caplog.text
await scanner.async_stop()
@pytest.mark.asyncio
async def test_restart_takes_longer_than_watchdog_time(
caplog: pytest.LogCaptureFixture,
) -> None:
"""
Test we do not try to recover the adapter again.
If the restart is still in progress.
"""
release_start_event = asyncio.Event()
called_start = 0
class MockBleakScanner:
async def start(self, *args, **kwargs):
"""Mock Start."""
nonlocal called_start
called_start += 1
if called_start == 1:
return
await release_start_event.wait()
async def stop(self, *args, **kwargs):
"""Mock Start."""
@property
def discovered_devices(self):
"""Mock discovered_devices."""
return []
def register_detection_callback(
self, callback: AdvertisementDataCallback
) -> None:
"""Mock Register Detection Callback."""
mock_scanner = MockBleakScanner()
start_time_monotonic = time.monotonic()
with (
patch(
"habluetooth.scanner.ADAPTER_INIT_TIME",
0,
),
patch_bluetooth_time(
start_time_monotonic,
),
patch(
"habluetooth.scanner.OriginalBleakScanner",
return_value=mock_scanner,
),
patch("habluetooth.util.recover_adapter", return_value=True),
):
scanner = HaScanner(BluetoothScanningMode.ACTIVE, "hci0", "AA:BB:CC:DD:EE:FF")
scanner.async_setup()
await scanner.async_start()
assert called_start == 1
# Now force a recover adapter 2x
for _ in range(2):
with patch_bluetooth_time(
start_time_monotonic
+ SCANNER_WATCHDOG_TIMEOUT
+ SCANNER_WATCHDOG_INTERVAL.total_seconds(),
):
async_fire_time_changed(utcnow() + SCANNER_WATCHDOG_INTERVAL)
await asyncio.sleep(0)
# Now release the start event
release_start_event.set()
assert "already restarting" in caplog.text
await scanner.async_stop()
@pytest.mark.asyncio
@pytest.mark.skipif("platform.system() != 'Darwin'")
async def test_setup_and_stop_macos() -> None:
"""Test we enable use_bdaddr on MacOS."""
init_kwargs = None
class MockBleakScanner:
def __init__(self, *args, **kwargs):
"""Init the scanner."""
nonlocal init_kwargs
init_kwargs = kwargs
async def start(self, *args, **kwargs):
"""Start the scanner."""
async def stop(self, *args, **kwargs):
"""Stop the scanner."""
def register_detection_callback(self, *args, **kwargs):
"""Register a callback."""
with patch(
"habluetooth.scanner.OriginalBleakScanner",
MockBleakScanner,
):
scanner = HaScanner(BluetoothScanningMode.ACTIVE, "hci0", "AA:BB:CC:DD:EE:FF")
scanner.async_setup()
await scanner.async_start()
assert init_kwargs == {
"detection_callback": ANY,
"scanning_mode": "active",
"cb": {"use_bdaddr": True},
}
await scanner.async_stop()
@pytest.mark.asyncio
async def test_adapter_init_fails_fallback_to_passive(
caplog: pytest.LogCaptureFixture,
) -> None:
"""Test we fallback to passive when adapter init fails."""
called_start = 0
called_stop = 0
_callback = None
mock_discovered: list[Any] = []
class MockBleakScanner:
async def start(self, *args, **kwargs):
"""Mock Start."""
nonlocal called_start
called_start += 1
if called_start == 1:
raise BleakError("org.freedesktop.DBus.Error.UnknownObject")
if called_start == 2:
raise BleakError("org.bluez.Error.InProgress")
if called_start == 3:
raise BleakError("org.bluez.Error.InProgress")
async def stop(self, *args, **kwargs):
"""Mock Start."""
nonlocal called_stop
called_stop += 1
@property
def discovered_devices(self):
"""Mock discovered_devices."""
nonlocal mock_discovered
return mock_discovered
def register_detection_callback(
self, callback: AdvertisementDataCallback
) -> None:
"""Mock Register Detection Callback."""
nonlocal _callback
_callback = callback
@property
def discovered_devices_and_advertisement_data(self) -> dict[str, Any]:
"""Mock discovered_devices."""
return {}
mock_scanner = MockBleakScanner()
start_time_monotonic = time.monotonic()
with (
patch(
"habluetooth.scanner.IS_LINUX",
True,
),
patch(
"habluetooth.scanner.ADAPTER_INIT_TIME",
0,
),
patch_bluetooth_time(
start_time_monotonic,
),
patch(
"habluetooth.scanner.OriginalBleakScanner",
return_value=mock_scanner,
),
patch(
"habluetooth.util.recover_adapter", return_value=True
) as mock_recover_adapter,
):
scanner = HaScanner(BluetoothScanningMode.ACTIVE, "hci0", "AA:BB:CC:DD:EE:FF")
scanner.async_setup()
await scanner.async_start()
assert called_start == 4
assert len(mock_recover_adapter.mock_calls) == 1
assert "Waiting for adapter to initialize" in caplog.text
assert (
"Successful fall-back to passive scanning mode after active scanning failed"
in caplog.text
)
assert await scanner.async_diagnostics() == {
"adapter": "hci0",
"connectable": True,
"current_mode": BluetoothScanningMode.PASSIVE,
"discovered_devices_and_advertisement_data": [],
"last_detection": ANY,
"monotonic_time": ANY,
"name": "hci0 (AA:BB:CC:DD:EE:FF)",
"requested_mode": BluetoothScanningMode.ACTIVE,
"scanning": True,
"source": "AA:BB:CC:DD:EE:FF",
"start_time": ANY,
"type": "HaScanner",
}
await scanner.async_stop()
assert await scanner.async_diagnostics() == {
"adapter": "hci0",
"connectable": True,
"current_mode": BluetoothScanningMode.PASSIVE,
"discovered_devices_and_advertisement_data": [],
"last_detection": ANY,
"monotonic_time": ANY,
"name": "hci0 (AA:BB:CC:DD:EE:FF)",
"requested_mode": BluetoothScanningMode.ACTIVE,
"scanning": False,
"source": "AA:BB:CC:DD:EE:FF",
"start_time": ANY,
"type": "HaScanner",
}
habluetooth-3.48.2/tests/test_storage.py 0000664 0000000 0000000 00000036415 15005442573 0020363 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"},
rssi=-50,
),
AdvertisementData(
local_name="Test Device",
manufacturer_data={0x004C: b"\x02\x15\xaa\xbb\xcc\xdd\xee\xff"},
tx_power=50,
service_data={
"0000180d-0000-1000-8000-00805f9b34fb": b"\x00\x00\x00\x00"
},
service_uuids=["0000180d-0000-1000-8000-00805f9b34fb"],
platform_data=("Test Device", ""),
rssi=-50,
),
)
},
{"AA:BB:CC:DD:EE:FF": 100000},
)
)
assert result == {
"connectable": True,
"discovered_device_advertisement_datas": {
"AA:BB:CC:DD:EE:FF": {
"advertisement_data": {
"local_name": "Test Device",
"manufacturer_data": {"76": "0215aabbccddeeff"},
"rssi": -50,
"service_data": {
"0000180d-0000-1000-8000-00805f9b34fb": "00000000"
},
"service_uuids": ["0000180d-0000-1000-8000-00805f9b34fb"],
"tx_power": 50,
"platform_data": ["Test Device", ""],
},
"device": {
"address": "AA:BB:CC:DD:EE:FF",
"details": {"details": "test"},
"name": "Test Device",
"rssi": -50,
},
}
},
"discovered_device_timestamps": {"AA:BB:CC:DD:EE:FF": ANY},
"expire_seconds": 100,
"discovered_device_raw": {},
}
def test_discovered_device_advertisement_data_from_dict():
now = time.time()
result = discovered_device_advertisement_data_from_dict(
{
"connectable": True,
"discovered_device_advertisement_datas": {
"AA:BB:CC:DD:EE:FF": {
"advertisement_data": {
"local_name": "Test Device",
"manufacturer_data": {"76": "0215aabbccddeeff"},
"rssi": -50,
"service_data": {
"0000180d-0000-1000-8000-00805f9b34fb": "00000000"
},
"service_uuids": ["0000180d-0000-1000-8000-00805f9b34fb"],
"tx_power": 50,
"platform_data": ["Test Device", ""],
},
"device": {
"address": "AA:BB:CC:DD:EE:FF",
"details": {"details": "test"},
"name": "Test Device",
"rssi": -50,
},
}
},
"discovered_device_timestamps": {"AA:BB:CC:DD:EE:FF": now},
"expire_seconds": 100,
"discovered_device_raw": {
"AA:BB:CC:DD:EE:FF": "0215aabbccddeeff",
},
}
)
expected_ble_device = BLEDevice(
address="AA:BB:CC:DD:EE:FF",
name="Test Device",
details={"details": "test"},
rssi=-50,
)
expected_advertisement_data = AdvertisementData(
local_name="Test Device",
manufacturer_data={0x004C: b"\x02\x15\xaa\xbb\xcc\xdd\xee\xff"},
tx_power=50,
service_data={"0000180d-0000-1000-8000-00805f9b34fb": b"\x00\x00\x00\x00"},
service_uuids=["0000180d-0000-1000-8000-00805f9b34fb"],
platform_data=("Test Device", ""),
rssi=-50,
)
assert result is not None
out_ble_device = result.discovered_device_advertisement_datas["AA:BB:CC:DD:EE:FF"][
0
]
out_advertisement_data = result.discovered_device_advertisement_datas[
"AA:BB:CC:DD:EE:FF"
][1]
assert out_ble_device.address == expected_ble_device.address
assert out_ble_device.name == expected_ble_device.name
assert out_ble_device.details == expected_ble_device.details
assert out_ble_device.rssi == expected_ble_device.rssi
assert out_ble_device.metadata == expected_ble_device.metadata
assert out_advertisement_data == expected_advertisement_data
assert result == DiscoveredDeviceAdvertisementData(
connectable=True,
expire_seconds=100,
discovered_device_advertisement_datas={
"AA:BB:CC:DD:EE:FF": (
ANY,
expected_advertisement_data,
)
},
discovered_device_timestamps={"AA:BB:CC:DD:EE:FF": ANY},
discovered_device_raw={
"AA:BB:CC:DD:EE:FF": b"\x02\x15\xaa\xbb\xcc\xdd\xee\xff"
},
)
def test_expire_stale_scanner_discovered_device_advertisement_data():
"""Test expire_stale_scanner_discovered_device_advertisement_data."""
now = time.time()
data = {
"myscanner": DiscoveredDeviceAdvertisementDataDict(
{
"connectable": True,
"discovered_device_advertisement_datas": {
"AA:BB:CC:DD:EE:FF": {
"advertisement_data": {
"local_name": "Test Device",
"manufacturer_data": {"76": "0215aabbccddeeff"},
"rssi": -50,
"service_data": {
"0000180d-0000-1000-8000-00805f9b34fb": "00000000"
},
"service_uuids": ["0000180d-0000-1000-8000-00805f9b34fb"],
"tx_power": 50,
"platform_data": ["Test Device", ""],
},
"device": {
"address": "AA:BB:CC:DD:EE:FF",
"details": {"details": "test"},
"name": "Test Device",
"rssi": -50,
},
},
"CC:DD:EE:FF:AA:BB": {
"advertisement_data": {
"local_name": "Test Device Expired",
"manufacturer_data": {"76": "0215aabbccddeeff"},
"rssi": -50,
"service_data": {
"0000180d-0000-1000-8000-00805f9b34fb": "00000000"
},
"service_uuids": ["0000180d-0000-1000-8000-00805f9b34fb"],
"tx_power": 50,
"platform_data": ["Test Device", ""],
},
"device": {
"address": "CC:DD:EE:FF:AA:BB",
"details": {"details": "test"},
"name": "Test Device Expired",
"rssi": -50,
},
},
},
"discovered_device_raw": {},
"discovered_device_timestamps": {
"AA:BB:CC:DD:EE:FF": now,
"CC:DD:EE:FF:AA:BB": now - 101,
},
"expire_seconds": 100,
}
),
"all_expired": DiscoveredDeviceAdvertisementDataDict(
{
"connectable": True,
"discovered_device_advertisement_datas": {
"CC:DD:EE:FF:AA:BB": {
"advertisement_data": {
"local_name": "Test Device Expired",
"manufacturer_data": {"76": "0215aabbccddeeff"},
"rssi": -50,
"service_data": {
"0000180d-0000-1000-8000-00805f9b34fb": "00000000"
},
"service_uuids": ["0000180d-0000-1000-8000-00805f9b34fb"],
"tx_power": 50,
"platform_data": ["Test Device", ""],
},
"device": {
"address": "CC:DD:EE:FF:AA:BB",
"details": {"details": "test"},
"name": "Test Device Expired",
"rssi": -50,
},
}
},
"discovered_device_raw": {},
"discovered_device_timestamps": {"CC:DD:EE:FF:AA:BB": now - 101},
"expire_seconds": 100,
}
),
}
expire_stale_scanner_discovered_device_advertisement_data(data)
assert len(data["myscanner"]["discovered_device_advertisement_datas"]) == 1
assert (
"CC:DD:EE:FF:AA:BB"
not in data["myscanner"]["discovered_device_advertisement_datas"]
)
assert "all_expired" not in data
def test_expire_future_discovered_device_advertisement_data(
caplog: pytest.LogCaptureFixture,
) -> None:
"""Test test_expire_future_discovered_device_advertisement_data."""
now = time.time()
data = {
"myscanner": DiscoveredDeviceAdvertisementDataDict(
{
"connectable": True,
"discovered_device_advertisement_datas": {
"AA:BB:CC:DD:EE:FF": {
"advertisement_data": {
"local_name": "Test Device",
"manufacturer_data": {"76": "0215aabbccddeeff"},
"rssi": -50,
"service_data": {
"0000180d-0000-1000-8000-00805f9b34fb": "00000000"
},
"service_uuids": ["0000180d-0000-1000-8000-00805f9b34fb"],
"tx_power": 50,
"platform_data": ["Test Device", ""],
},
"device": {
"address": "AA:BB:CC:DD:EE:FF",
"details": {"details": "test"},
"name": "Test Device",
"rssi": -50,
},
},
"CC:DD:EE:FF:AA:BB": {
"advertisement_data": {
"local_name": "Test Device Expired",
"manufacturer_data": {"76": "0215aabbccddeeff"},
"rssi": -50,
"service_data": {
"0000180d-0000-1000-8000-00805f9b34fb": "00000000"
},
"service_uuids": ["0000180d-0000-1000-8000-00805f9b34fb"],
"tx_power": 50,
"platform_data": ["Test Device", ""],
},
"device": {
"address": "CC:DD:EE:FF:AA:BB",
"details": {"details": "test"},
"name": "Test Device Expired",
"rssi": -50,
},
},
},
"discovered_device_timestamps": {
"AA:BB:CC:DD:EE:FF": now,
"CC:DD:EE:FF:AA:BB": now - 101,
},
"discovered_device_raw": {},
"expire_seconds": 100,
}
),
"all_future": DiscoveredDeviceAdvertisementDataDict(
{
"connectable": True,
"discovered_device_advertisement_datas": {
"CC:DD:EE:FF:AA:BB": {
"advertisement_data": {
"local_name": "Test Device Expired",
"manufacturer_data": {"76": "0215aabbccddeeff"},
"rssi": -50,
"service_data": {
"0000180d-0000-1000-8000-00805f9b34fb": "00000000"
},
"service_uuids": ["0000180d-0000-1000-8000-00805f9b34fb"],
"tx_power": 50,
"platform_data": ["Test Device", ""],
},
"device": {
"address": "CC:DD:EE:FF:AA:BB",
"details": {"details": "test"},
"name": "Test Device Expired",
"rssi": -50,
},
}
},
"discovered_device_timestamps": {"CC:DD:EE:FF:AA:BB": now + 1000000},
"discovered_device_raw": {},
"expire_seconds": 100,
}
),
}
expire_stale_scanner_discovered_device_advertisement_data(data)
assert len(data["myscanner"]["discovered_device_advertisement_datas"]) == 1
assert (
"CC:DD:EE:FF:AA:BB"
not in data["myscanner"]["discovered_device_advertisement_datas"]
)
assert "all_future" not in data
assert (
"for CC:DD:EE:FF:AA:BB on scanner all_future as it is the future" in caplog.text
)
def test_discovered_device_advertisement_data_from_dict_corrupt(caplog):
"""Test discovered_device_advertisement_data_from_dict with corrupt data."""
now = time.time()
result = discovered_device_advertisement_data_from_dict(
{
"connectable": True,
"discovered_device_advertisement_datas": {
"AA:BB:CC:DD:EE:FF": {
"advertisement_data": {
"local_name": "Test Device",
"manufacturer_data": {"76": "0215aabbccddeeff"},
"rssi": -50,
"service_data": {
"0000180d-0000-1000-8000-00805f9b34fb": "00000000"
},
"service_uuids": ["0000180d-0000-1000-8000-00805f9b34fb"],
},
"device": { # type: ignore[typeddict-item]
"address": "AA:BB:CC:DD:EE:FF",
"details": {"details": "test"},
"rssi": -50,
},
}
},
"discovered_device_timestamps": {"AA:BB:CC:DD:EE:FF": now},
"expire_seconds": 100,
}
)
assert result is None
assert "Error deserializing discovered_device_advertisement_data" in caplog.text
habluetooth-3.48.2/tests/test_wrappers.py 0000664 0000000 0000000 00000072603 15005442573 0020561 0 ustar 00root root 0000000 0000000 """Tests for bluetooth wrappers."""
from __future__ import annotations
import asyncio
from contextlib import contextmanager
from typing import Any, Generator
from unittest.mock import patch
import bleak
import pytest
from bleak.backends.device import BLEDevice
from bleak.backends.scanner import AdvertisementData
from bleak.exc import BleakError
from bluetooth_data_tools import monotonic_time_coarse as MONOTONIC_TIME
from habluetooth import BaseHaRemoteScanner, HaBluetoothConnector
from habluetooth import get_manager as _get_manager
from habluetooth.manager import BluetoothManager
from habluetooth.usage import (
install_multiple_bleak_catcher,
uninstall_multiple_bleak_catcher,
)
from habluetooth.wrappers import HaBleakScannerWrapper
from . import (
HCI0_SOURCE_ADDRESS,
generate_advertisement_data,
generate_ble_device,
inject_advertisement,
patch_discovered_devices,
)
@contextmanager
def mock_shutdown(manager: BluetoothManager) -> Generator[None, None, None]:
"""Mock shutdown of the HomeAssistantBluetoothManager."""
manager.shutdown = True
yield
manager.shutdown = False
class FakeScanner(BaseHaRemoteScanner):
"""Fake scanner."""
def __init__(
self,
scanner_id: str,
name: str,
connector: None,
connectable: bool,
) -> None:
"""Initialize the scanner."""
super().__init__(scanner_id, name, connector, connectable)
self._details: dict[str, str | HaBluetoothConnector] = {}
def __repr__(self) -> str:
"""Return the representation."""
return f"FakeScanner({self.name})"
def inject_advertisement(
self, device: BLEDevice, advertisement_data: AdvertisementData
) -> None:
"""Inject an advertisement."""
self._async_on_advertisement(
device.address,
advertisement_data.rssi,
device.name,
advertisement_data.service_uuids,
advertisement_data.service_data,
advertisement_data.manufacturer_data,
advertisement_data.tx_power,
device.details | {"scanner_specific_data": "test"},
MONOTONIC_TIME(),
)
class BaseFakeBleakClient:
"""Base class for fake bleak clients."""
def __init__(self, address_or_ble_device: BLEDevice | str, **kwargs: Any) -> None:
"""Initialize the fake bleak client."""
self._device_path = "/dev/test"
self._device = address_or_ble_device
assert isinstance(address_or_ble_device, BLEDevice)
self._address = address_or_ble_device.address
async def disconnect(self, *args, **kwargs):
"""Disconnect."""
async def get_services(self, *args, **kwargs):
"""Get services."""
return []
class FakeBleakClient(BaseFakeBleakClient):
"""Fake bleak client."""
async def connect(self, *args, **kwargs):
"""Connect."""
return True
class FakeBleakClientFailsToConnect(BaseFakeBleakClient):
"""Fake bleak client that fails to connect."""
async def connect(self, *args, **kwargs):
"""Connect."""
return False
class FakeBleakClientRaisesOnConnect(BaseFakeBleakClient):
"""Fake bleak client that raises on connect."""
async def connect(self, *args, **kwargs):
"""Connect."""
raise ConnectionError("Test exception")
def _generate_ble_device_and_adv_data(
interface: str, mac: str, rssi: int
) -> tuple[BLEDevice, AdvertisementData]:
"""Generate a BLE device with adv data."""
return (
generate_ble_device(
mac,
"any",
delegate="",
details={"path": f"/org/bluez/{interface}/dev_{mac}"},
),
generate_advertisement_data(rssi=rssi),
)
@pytest.fixture(name="install_bleak_catcher")
def install_bleak_catcher_fixture():
"""Fixture that installs the bleak catcher."""
install_multiple_bleak_catcher()
yield
uninstall_multiple_bleak_catcher()
@pytest.fixture(name="mock_platform_client")
def mock_platform_client_fixture():
"""Fixture that mocks the platform client."""
with patch(
"habluetooth.wrappers.get_platform_client_backend_type",
return_value=FakeBleakClient,
):
yield
@pytest.fixture(name="mock_platform_client_that_fails_to_connect")
def mock_platform_client_that_fails_to_connect_fixture():
"""Fixture that mocks the platform client that fails to connect."""
with patch(
"habluetooth.wrappers.get_platform_client_backend_type",
return_value=FakeBleakClientFailsToConnect,
):
yield
@pytest.fixture(name="mock_platform_client_that_raises_on_connect")
def mock_platform_client_that_raises_on_connect_fixture():
"""Fixture that mocks the platform client that fails to connect."""
with patch(
"habluetooth.wrappers.get_platform_client_backend_type",
return_value=FakeBleakClientRaisesOnConnect,
):
yield
def _generate_scanners_with_fake_devices():
"""Generate scanners with fake devices."""
manager = _get_manager()
hci0_device_advs = {}
for i in range(10):
device, adv_data = _generate_ble_device_and_adv_data(
"hci0", f"00:00:00:00:00:{i:02x}", rssi=-60
)
hci0_device_advs[device.address] = (device, adv_data)
hci1_device_advs = {}
for i in range(10):
device, adv_data = _generate_ble_device_and_adv_data(
"hci1", f"00:00:00:00:00:{i:02x}", rssi=-80
)
hci1_device_advs[device.address] = (device, adv_data)
scanner_hci0 = FakeScanner("00:00:00:00:00:01", "hci0", None, True)
scanner_hci1 = FakeScanner("00:00:00:00:00:02", "hci1", None, True)
for device, adv_data in hci0_device_advs.values():
scanner_hci0.inject_advertisement(device, adv_data)
for device, adv_data in hci1_device_advs.values():
scanner_hci1.inject_advertisement(device, adv_data)
cancel_hci0 = manager.async_register_scanner(scanner_hci0, connection_slots=2)
cancel_hci1 = manager.async_register_scanner(scanner_hci1, connection_slots=1)
return hci0_device_advs, cancel_hci0, cancel_hci1
@pytest.mark.asyncio
async def test_test_switch_adapters_when_out_of_slots(
two_adapters: None,
enable_bluetooth: None,
install_bleak_catcher: None,
mock_platform_client: None,
) -> None:
"""Ensure we try another scanner when one runs out of slots."""
manager = _get_manager()
# hci0 has an rssi of -60, hci1 has an rssi of -80
hci0_device_advs, cancel_hci0, cancel_hci1 = _generate_scanners_with_fake_devices()
# hci0 has 2 slots, hci1 has 1 slot
with (
patch.object(manager.slot_manager, "release_slot") as release_slot_mock,
patch.object(
manager.slot_manager, "allocate_slot", return_value=True
) as allocate_slot_mock,
):
ble_device = hci0_device_advs["00:00:00:00:00:01"][0]
client = bleak.BleakClient(ble_device)
assert await client.connect() is True
assert allocate_slot_mock.call_count == 1
assert release_slot_mock.call_count == 0
# All adapters are out of slots
with (
patch.object(manager.slot_manager, "release_slot") as release_slot_mock,
patch.object(
manager.slot_manager, "allocate_slot", return_value=False
) as allocate_slot_mock,
):
ble_device = hci0_device_advs["00:00:00:00:00:02"][0]
client = bleak.BleakClient(ble_device)
with pytest.raises(bleak.exc.BleakError):
await client.connect()
assert allocate_slot_mock.call_count == 2
assert release_slot_mock.call_count == 0
# When hci0 runs out of slots, we should try hci1
def _allocate_slot_mock(ble_device: BLEDevice) -> bool:
if "hci1" in ble_device.details["path"]:
return True
return False
with (
patch.object(manager.slot_manager, "release_slot") as release_slot_mock,
patch.object( # type: ignore
manager.slot_manager, "allocate_slot", _allocate_slot_mock
) as allocate_slot_mock,
):
ble_device = hci0_device_advs["00:00:00:00:00:03"][0]
client = bleak.BleakClient(ble_device)
assert await client.connect() is True
assert release_slot_mock.call_count == 0
cancel_hci0()
cancel_hci1()
@pytest.mark.asyncio
async def test_release_slot_on_connect_failure(
two_adapters: None,
enable_bluetooth: None,
install_bleak_catcher: None,
mock_platform_client_that_fails_to_connect: None,
) -> None:
"""Ensure the slot gets released on connection failure."""
manager = _get_manager()
hci0_device_advs, cancel_hci0, cancel_hci1 = _generate_scanners_with_fake_devices()
# hci0 has 2 slots, hci1 has 1 slot
with (
patch.object(manager.slot_manager, "release_slot") as release_slot_mock,
patch.object(
manager.slot_manager, "allocate_slot", return_value=True
) as allocate_slot_mock,
):
ble_device = hci0_device_advs["00:00:00:00:00:01"][0]
client = bleak.BleakClient(ble_device)
assert await client.connect() is False
assert allocate_slot_mock.call_count == 1
assert release_slot_mock.call_count == 1
cancel_hci0()
cancel_hci1()
@pytest.mark.asyncio
async def test_release_slot_on_connect_exception(
two_adapters: None,
enable_bluetooth: None,
install_bleak_catcher: None,
mock_platform_client_that_raises_on_connect: None,
) -> None:
"""Ensure the slot gets released on connection exception."""
manager = _get_manager()
hci0_device_advs, cancel_hci0, cancel_hci1 = _generate_scanners_with_fake_devices()
# hci0 has 2 slots, hci1 has 1 slot
with (
patch.object(manager.slot_manager, "release_slot") as release_slot_mock,
patch.object(
manager.slot_manager, "allocate_slot", return_value=True
) as allocate_slot_mock,
):
ble_device = hci0_device_advs["00:00:00:00:00:01"][0]
client = bleak.BleakClient(ble_device)
with pytest.raises(ConnectionError) as exc_info:
await client.connect()
assert str(exc_info.value) == "Test exception"
assert allocate_slot_mock.call_count == 1
assert release_slot_mock.call_count == 1
cancel_hci0()
cancel_hci1()
@pytest.mark.asyncio
async def test_switch_adapters_on_failure(
two_adapters: None,
enable_bluetooth: None,
install_bleak_catcher: None,
) -> None:
"""Ensure we try the next best adapter after a failure."""
hci0_device_advs, cancel_hci0, cancel_hci1 = _generate_scanners_with_fake_devices()
ble_device = hci0_device_advs["00:00:00:00:00:01"][0]
client = bleak.BleakClient(ble_device)
class FakeBleakClientFailsHCI0Only(BaseFakeBleakClient):
"""Fake bleak client that fails to connect on hci0."""
async def connect(self, *args: Any, **kwargs: Any) -> bool:
"""Connect."""
assert isinstance(self._device, BLEDevice)
if "/hci0/" in self._device.details["path"]:
return False
return True
class FakeBleakClientFailsHCI1Only(BaseFakeBleakClient):
"""Fake bleak client that fails to connect on hci1."""
async def connect(self, *args: Any, **kwargs: Any) -> bool:
"""Connect."""
assert isinstance(self._device, BLEDevice)
if "/hci1/" in self._device.details["path"]:
return False
return True
with patch(
"habluetooth.wrappers.get_platform_client_backend_type",
return_value=FakeBleakClientFailsHCI0Only,
):
# Should try to connect to hci0 first
assert await client.connect() is False
# Should try to connect with hci0 again
assert await client.connect() is False
# After two tries we should switch to hci1
assert await client.connect() is True
# ..and we remember that hci1 works as long as the client doesn't change
assert await client.connect() is True
# If we replace the client, we should remember hci0 is failing
client = bleak.BleakClient(ble_device)
assert await client.connect() is True
with patch(
"habluetooth.wrappers.get_platform_client_backend_type",
return_value=FakeBleakClientFailsHCI1Only,
):
# Should try to connect to hci1 first
assert await client.connect() is False
# Should try to connect with hci0 next
assert await client.connect() is True
# Next attempt should also use hci0
assert await client.connect() is True
cancel_hci0()
cancel_hci1()
@pytest.mark.asyncio
async def test_switch_adapters_on_connecting(
two_adapters: None,
enable_bluetooth: None,
install_bleak_catcher: None,
) -> None:
"""Ensure we try the next best adapter after a failure."""
# hci0 has an rssi of -60, hci1 has an rssi of -80
hci0_device_advs, cancel_hci0, cancel_hci1 = _generate_scanners_with_fake_devices()
ble_device = hci0_device_advs["00:00:00:00:00:01"][0]
client = bleak.BleakClient(ble_device)
class FakeBleakClientSlowHCI0Connnect(BaseFakeBleakClient):
"""Fake bleak client that connects instantly on hci1 and slow on hci0."""
async def connect(self, *args: Any, **kwargs: Any) -> bool:
"""Connect."""
assert isinstance(self._device, BLEDevice)
if "/hci0/" in self._device.details["path"]:
await asyncio.sleep(0.4)
return True
return True
with patch(
"habluetooth.wrappers.get_platform_client_backend_type",
return_value=FakeBleakClientSlowHCI0Connnect,
):
task = asyncio.create_task(client.connect())
await asyncio.sleep(0.1)
assert not task.done()
task2 = asyncio.create_task(client.connect())
await asyncio.sleep(0.1)
assert task2.done()
assert await task2 is True
task3 = asyncio.create_task(client.connect())
await asyncio.sleep(0.1)
assert task3.done()
assert await task3 is True
assert await task is True
cancel_hci0()
cancel_hci1()
@pytest.mark.asyncio
@pytest.mark.usefixtures("enable_bluetooth", "install_bleak_catcher")
async def test_single_adapter_connection_history(
caplog: pytest.LogCaptureFixture,
) -> None:
"""Test connection history failure count."""
manager = _get_manager()
scanner_hci0 = FakeScanner(HCI0_SOURCE_ADDRESS, "hci0", None, True)
unsub_hci0 = manager.async_register_scanner(scanner_hci0, connection_slots=2)
ble_device, adv_data = _generate_ble_device_and_adv_data(
"hci0", "00:00:00:00:00:11", rssi=-60
)
scanner_hci0.inject_advertisement(ble_device, adv_data)
service_info = manager.async_last_service_info(
ble_device.address, connectable=False
)
assert service_info is not None
assert service_info.source == HCI0_SOURCE_ADDRESS
client = bleak.BleakClient(ble_device)
class FakeBleakClientFastConnect(BaseFakeBleakClient):
"""Fake bleak client that connects instantly on hci1 and slow on hci0."""
async def connect(self, *args: Any, **kwargs: Any) -> bool:
"""Connect."""
assert isinstance(self._device, BLEDevice)
return "/hci0/" in self._device.details["path"]
with patch(
"habluetooth.wrappers.get_platform_client_backend_type",
return_value=FakeBleakClientFastConnect,
):
assert await client.connect() is True
unsub_hci0()
@pytest.mark.asyncio
async def test_passing_subclassed_str_as_address(
two_adapters: None,
enable_bluetooth: None,
install_bleak_catcher: None,
) -> None:
"""Ensure the client wrapper can handle a subclassed str as the address."""
_, cancel_hci0, cancel_hci1 = _generate_scanners_with_fake_devices()
class SubclassedStr(str):
__slots__ = ()
address = SubclassedStr("00:00:00:00:00:01")
client = bleak.BleakClient(address)
class FakeBleakClient(BaseFakeBleakClient):
"""Fake bleak client."""
async def connect(self, *args, **kwargs):
"""Connect."""
return True
with patch(
"habluetooth.wrappers.get_platform_client_backend_type",
return_value=FakeBleakClient,
):
assert await client.connect() is True
cancel_hci0()
cancel_hci1()
@pytest.mark.asyncio
async def test_find_device_by_address(
two_adapters: None,
enable_bluetooth: None,
install_bleak_catcher: None,
) -> None:
"""Ensure the client wrapper can handle a subclassed str as the address."""
_, cancel_hci0, cancel_hci1 = _generate_scanners_with_fake_devices()
device = await bleak.BleakScanner.find_device_by_address("00:00:00:00:00:01")
assert device.address == "00:00:00:00:00:01"
device = await bleak.BleakScanner().find_device_by_address("00:00:00:00:00:01")
assert device.address == "00:00:00:00:00:01"
@pytest.mark.asyncio
async def test_discover(
two_adapters: None,
enable_bluetooth: None,
install_bleak_catcher: None,
) -> None:
"""Ensure the discover is implemented."""
_, cancel_hci0, cancel_hci1 = _generate_scanners_with_fake_devices()
devices = await bleak.BleakScanner.discover()
assert any(device.address == "00:00:00:00:00:01" for device in devices)
devices_adv = await bleak.BleakScanner.discover(return_adv=True)
assert "00:00:00:00:00:01" in devices_adv
@pytest.mark.asyncio
async def test_raise_after_shutdown(
two_adapters: None,
enable_bluetooth: None,
install_bleak_catcher: None,
mock_platform_client_that_raises_on_connect: None,
) -> None:
"""Ensure the slot gets released on connection exception."""
manager = _get_manager()
hci0_device_advs, cancel_hci0, cancel_hci1 = _generate_scanners_with_fake_devices()
# hci0 has 2 slots, hci1 has 1 slot
with mock_shutdown(manager):
ble_device = hci0_device_advs["00:00:00:00:00:01"][0]
client = bleak.BleakClient(ble_device)
with pytest.raises(BleakError, match="shutdown"):
await client.connect()
cancel_hci0()
cancel_hci1()
@pytest.mark.usefixtures("enable_bluetooth")
@pytest.mark.asyncio
async def test_wrapped_instance_with_filter(
register_hci0_scanner: None,
) -> None:
"""Test wrapped instance with a filter as if it was normal BleakScanner."""
detected = []
def _device_detected(
device: BLEDevice, advertisement_data: AdvertisementData
) -> None:
"""Handle a detected device."""
detected.append((device, advertisement_data))
switchbot_device = generate_ble_device("44:44:33:11:23:45", "wohand")
switchbot_adv = generate_advertisement_data(
local_name="wohand",
service_uuids=["cba20d00-224d-11e6-9fb8-0002a5d5c51b"],
manufacturer_data={89: b"\xd8.\xad\xcd\r\x85"},
service_data={"00000d00-0000-1000-8000-00805f9b34fb": b"H\x10c"},
)
switchbot_adv_2 = generate_advertisement_data(
local_name="wohand",
service_uuids=["cba20d00-224d-11e6-9fb8-0002a5d5c51b"],
manufacturer_data={89: b"\xd8.\xad\xcd\r\x84"},
service_data={"00000d00-0000-1000-8000-00805f9b34fb": b"H\x10c"},
)
empty_device = generate_ble_device("11:22:33:44:55:66", "empty")
empty_adv = generate_advertisement_data(local_name="empty")
assert _get_manager() is not None
scanner = HaBleakScannerWrapper(
filters={"UUIDs": ["cba20d00-224d-11e6-9fb8-0002a5d5c51b"]}
)
scanner.register_detection_callback(_device_detected)
inject_advertisement(switchbot_device, switchbot_adv_2)
await asyncio.sleep(0)
discovered = await scanner.discover(timeout=0)
assert len(discovered) == 1
assert discovered == [switchbot_device]
assert len(detected) == 1
scanner.register_detection_callback(_device_detected)
# We should get a reply from the history when we register again
assert len(detected) == 2
scanner.register_detection_callback(_device_detected)
# We should get a reply from the history when we register again
assert len(detected) == 3
with patch_discovered_devices([]):
discovered = await scanner.discover(timeout=0)
assert len(discovered) == 0
assert discovered == []
inject_advertisement(switchbot_device, switchbot_adv)
assert len(detected) == 4
# The filter we created in the wrapped scanner with should be respected
# and we should not get another callback
inject_advertisement(empty_device, empty_adv)
assert len(detected) == 4
@pytest.mark.usefixtures("enable_bluetooth")
@pytest.mark.asyncio
async def test_wrapped_instance_with_service_uuids(
register_hci0_scanner: None,
) -> None:
"""Test wrapped instance with a service_uuids list as normal BleakScanner."""
detected = []
def _device_detected(
device: BLEDevice, advertisement_data: AdvertisementData
) -> None:
"""Handle a detected device."""
detected.append((device, advertisement_data))
switchbot_device = generate_ble_device("44:44:33:11:23:45", "wohand")
switchbot_adv = generate_advertisement_data(
local_name="wohand",
service_uuids=["cba20d00-224d-11e6-9fb8-0002a5d5c51b"],
manufacturer_data={89: b"\xd8.\xad\xcd\r\x85"},
service_data={"00000d00-0000-1000-8000-00805f9b34fb": b"H\x10c"},
)
switchbot_adv_2 = generate_advertisement_data(
local_name="wohand",
service_uuids=["cba20d00-224d-11e6-9fb8-0002a5d5c51b"],
manufacturer_data={89: b"\xd8.\xad\xcd\r\x84"},
service_data={"00000d00-0000-1000-8000-00805f9b34fb": b"H\x10c"},
)
empty_device = generate_ble_device("11:22:33:44:55:66", "empty")
empty_adv = generate_advertisement_data(local_name="empty")
assert _get_manager() is not None
scanner = HaBleakScannerWrapper(
service_uuids=["cba20d00-224d-11e6-9fb8-0002a5d5c51b"]
)
scanner.register_detection_callback(_device_detected)
inject_advertisement(switchbot_device, switchbot_adv)
inject_advertisement(switchbot_device, switchbot_adv_2)
await asyncio.sleep(0)
assert len(detected) == 2
# The UUIDs list we created in the wrapped scanner with should be respected
# and we should not get another callback
inject_advertisement(empty_device, empty_adv)
assert len(detected) == 2
@pytest.mark.usefixtures("enable_bluetooth")
@pytest.mark.asyncio
async def test_wrapped_instance_with_service_uuids_with_coro_callback(
register_hci0_scanner: None,
) -> None:
"""
Test wrapped instance with a service_uuids list as normal BleakScanner.
Verify that coro callbacks are supported.
"""
detected = []
async def _device_detected(
device: BLEDevice, advertisement_data: AdvertisementData
) -> None:
"""Handle a detected device."""
detected.append((device, advertisement_data))
switchbot_device = generate_ble_device("44:44:33:11:23:45", "wohand")
switchbot_adv = generate_advertisement_data(
local_name="wohand",
service_uuids=["cba20d00-224d-11e6-9fb8-0002a5d5c51b"],
manufacturer_data={89: b"\xd8.\xad\xcd\r\x85"},
service_data={"00000d00-0000-1000-8000-00805f9b34fb": b"H\x10c"},
)
switchbot_adv_2 = generate_advertisement_data(
local_name="wohand",
service_uuids=["cba20d00-224d-11e6-9fb8-0002a5d5c51b"],
manufacturer_data={89: b"\xd8.\xad\xcd\r\x84"},
service_data={"00000d00-0000-1000-8000-00805f9b34fb": b"H\x10c"},
)
empty_device = generate_ble_device("11:22:33:44:55:66", "empty")
empty_adv = generate_advertisement_data(local_name="empty")
assert _get_manager() is not None
scanner = HaBleakScannerWrapper(
service_uuids=["cba20d00-224d-11e6-9fb8-0002a5d5c51b"]
)
scanner.register_detection_callback(_device_detected)
inject_advertisement(switchbot_device, switchbot_adv)
inject_advertisement(switchbot_device, switchbot_adv_2)
await asyncio.sleep(0)
assert len(detected) == 2
# The UUIDs list we created in the wrapped scanner with should be respected
# and we should not get another callback
inject_advertisement(empty_device, empty_adv)
assert len(detected) == 2
@pytest.mark.usefixtures("enable_bluetooth")
@pytest.mark.asyncio
async def test_wrapped_instance_with_broken_callbacks(
register_hci0_scanner: None,
) -> None:
"""Test broken callbacks do not cause the scanner to fail."""
detected: list[tuple[BLEDevice, AdvertisementData]] = []
def _device_detected(
device: BLEDevice, advertisement_data: AdvertisementData
) -> None:
"""Handle a detected device."""
if detected:
raise ValueError
detected.append((device, advertisement_data))
switchbot_device = generate_ble_device("44:44:33:11:23:45", "wohand")
switchbot_adv = generate_advertisement_data(
local_name="wohand",
service_uuids=["cba20d00-224d-11e6-9fb8-0002a5d5c51b"],
manufacturer_data={89: b"\xd8.\xad\xcd\r\x85"},
service_data={"00000d00-0000-1000-8000-00805f9b34fb": b"H\x10c"},
)
assert _get_manager() is not None
scanner = HaBleakScannerWrapper(
service_uuids=["cba20d00-224d-11e6-9fb8-0002a5d5c51b"]
)
scanner.register_detection_callback(_device_detected)
inject_advertisement(switchbot_device, switchbot_adv)
await asyncio.sleep(0)
inject_advertisement(switchbot_device, switchbot_adv)
await asyncio.sleep(0)
assert len(detected) == 1
@pytest.mark.usefixtures("enable_bluetooth")
@pytest.mark.asyncio
async def test_wrapped_instance_changes_uuids(
register_hci0_scanner: None,
) -> None:
"""Test consumers can use the wrapped instance can change the uuids later."""
detected = []
def _device_detected(
device: BLEDevice, advertisement_data: AdvertisementData
) -> None:
"""Handle a detected device."""
detected.append((device, advertisement_data))
switchbot_device = generate_ble_device("44:44:33:11:23:45", "wohand")
switchbot_adv = generate_advertisement_data(
local_name="wohand",
service_uuids=["cba20d00-224d-11e6-9fb8-0002a5d5c51b"],
manufacturer_data={89: b"\xd8.\xad\xcd\r\x85"},
service_data={"00000d00-0000-1000-8000-00805f9b34fb": b"H\x10c"},
)
switchbot_adv_2 = generate_advertisement_data(
local_name="wohand",
service_uuids=["cba20d00-224d-11e6-9fb8-0002a5d5c51b"],
manufacturer_data={89: b"\xd8.\xad\xcd\r\x84"},
service_data={"00000d00-0000-1000-8000-00805f9b34fb": b"H\x10c"},
)
empty_device = generate_ble_device("11:22:33:44:55:66", "empty")
empty_adv = generate_advertisement_data(local_name="empty")
assert _get_manager() is not None
scanner = HaBleakScannerWrapper()
scanner.set_scanning_filter(service_uuids=["cba20d00-224d-11e6-9fb8-0002a5d5c51b"])
scanner.register_detection_callback(_device_detected)
inject_advertisement(switchbot_device, switchbot_adv)
inject_advertisement(switchbot_device, switchbot_adv_2)
await asyncio.sleep(0)
assert len(detected) == 2
# The UUIDs list we created in the wrapped scanner with should be respected
# and we should not get another callback
inject_advertisement(empty_device, empty_adv)
assert len(detected) == 2
@pytest.mark.usefixtures("enable_bluetooth")
@pytest.mark.asyncio
async def test_wrapped_instance_changes_filters(
register_hci0_scanner: None,
) -> None:
"""Test consumers can use the wrapped instance can change the filter later."""
detected = []
def _device_detected(
device: BLEDevice, advertisement_data: AdvertisementData
) -> None:
"""Handle a detected device."""
detected.append((device, advertisement_data))
switchbot_device = generate_ble_device("44:44:33:11:23:42", "wohand")
switchbot_adv = generate_advertisement_data(
local_name="wohand",
service_uuids=["cba20d00-224d-11e6-9fb8-0002a5d5c51b"],
manufacturer_data={89: b"\xd8.\xad\xcd\r\x85"},
service_data={"00000d00-0000-1000-8000-00805f9b34fb": b"H\x10c"},
)
switchbot_adv_2 = generate_advertisement_data(
local_name="wohand",
service_uuids=["cba20d00-224d-11e6-9fb8-0002a5d5c51b"],
manufacturer_data={89: b"\xd8.\xad\xcd\r\x84"},
service_data={"00000d00-0000-1000-8000-00805f9b34fb": b"H\x10c"},
)
empty_device = generate_ble_device("11:22:33:44:55:62", "empty")
empty_adv = generate_advertisement_data(local_name="empty")
assert _get_manager() is not None
scanner = HaBleakScannerWrapper()
scanner.set_scanning_filter(
filters={"UUIDs": ["cba20d00-224d-11e6-9fb8-0002a5d5c51b"]}
)
scanner.register_detection_callback(_device_detected)
inject_advertisement(switchbot_device, switchbot_adv)
inject_advertisement(switchbot_device, switchbot_adv_2)
await asyncio.sleep(0)
assert len(detected) == 2
# The UUIDs list we created in the wrapped scanner with should be respected
# and we should not get another callback
inject_advertisement(empty_device, empty_adv)
assert len(detected) == 2
@pytest.mark.usefixtures("enable_bluetooth")
@pytest.mark.asyncio
async def test_wrapped_instance_unsupported_filter(
register_hci0_scanner: None,
caplog: pytest.LogCaptureFixture,
) -> None:
"""Test we want when their filter is ineffective."""
assert _get_manager() is not None
scanner = HaBleakScannerWrapper()
scanner.set_scanning_filter(
filters={
"unsupported": ["cba20d00-224d-11e6-9fb8-0002a5d5c51b"],
"DuplicateData": True,
}
)
assert "Only UUIDs filters are supported" in caplog.text