mirror of
https://github.com/google/bumble.git
synced 2026-05-06 03:38:01 +00:00
Compare commits
246 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
27d02ef18d | ||
|
|
c0725e2a4a | ||
|
|
bf0784dde4 | ||
|
|
444f43f6a3 | ||
|
|
2420c47cf1 | ||
|
|
0a78e7506b | ||
|
|
f7cc6f6657 | ||
|
|
f2824ee6b8 | ||
|
|
7188ef08de | ||
|
|
3ded9014d3 | ||
|
|
b6125bdfb1 | ||
|
|
dc17f4f1ca | ||
|
|
3f65380c20 | ||
|
|
25a0056ecc | ||
|
|
85f6b10983 | ||
|
|
e85f041e9d | ||
|
|
ee09e6f10d | ||
|
|
c3daf4a7e1 | ||
|
|
3af623be7e | ||
|
|
4e76d3057b | ||
|
|
eda7360222 | ||
|
|
a4c15c00de | ||
|
|
cba4df4aef | ||
|
|
ceb8b448e9 | ||
|
|
311b716d5c | ||
|
|
0ba9e5c317 | ||
|
|
3517225b62 | ||
|
|
ad4bb1578b | ||
|
|
4af65b381b | ||
|
|
a5cd3365ae | ||
|
|
2915cb8bb6 | ||
|
|
28e485b7b3 | ||
|
|
1198f2c3f5 | ||
|
|
80aaf6a2b9 | ||
|
|
eb64debb62 | ||
|
|
c158f25b1e | ||
|
|
1330e83517 | ||
|
|
d9c9bea6cb | ||
|
|
3b937631b3 | ||
|
|
f8aa309111 | ||
|
|
673281ed71 | ||
|
|
3ac7af4683 | ||
|
|
5ebfaae74e | ||
|
|
e6175f85fe | ||
|
|
f9ba527508 | ||
|
|
a407c4cabf | ||
|
|
6c2d6dddb5 | ||
|
|
797cd216d4 | ||
|
|
e2e8c90e47 | ||
|
|
3d5648cdc3 | ||
|
|
d810d93aaf | ||
|
|
81d9adb983 | ||
|
|
377fa896f7 | ||
|
|
79e5974946 | ||
|
|
657451474e | ||
|
|
9f730dce6f | ||
|
|
1a6be95a7e | ||
|
|
aea5320d71 | ||
|
|
91cb1b1df3 | ||
|
|
81bdc86e52 | ||
|
|
f23cad34e3 | ||
|
|
30fde2c00b | ||
|
|
256a1a7405 | ||
|
|
116d9b26bb | ||
|
|
aabe2ca063 | ||
|
|
2d17a5f742 | ||
|
|
3894b14467 | ||
|
|
e62f947430 | ||
|
|
dcb8a4b607 | ||
|
|
81985c47a9 | ||
|
|
7118328b07 | ||
|
|
5dc01d792a | ||
|
|
255f357975 | ||
|
|
c86920558b | ||
|
|
8e6efd0b2f | ||
|
|
2a59e19283 | ||
|
|
34f5b81c7d | ||
|
|
d34d6a5c98 | ||
|
|
aedc971653 | ||
|
|
c6815fb820 | ||
|
|
f44d013690 | ||
|
|
e63dc15ede | ||
|
|
c901e15666 | ||
|
|
022323b19c | ||
|
|
a0d24e95e7 | ||
|
|
7efbd303e0 | ||
|
|
49530d8d6d | ||
|
|
85b78b46f8 | ||
|
|
3f9ef5aac2 | ||
|
|
e488ea9783 | ||
|
|
21d937c2f1 | ||
|
|
a8396e6cce | ||
|
|
7e1b1c8f78 | ||
|
|
55719bf6de | ||
|
|
5059920696 | ||
|
|
c577f17c99 | ||
|
|
252f3e49b6 | ||
|
|
f3ecf04479 | ||
|
|
4986f55043 | ||
|
|
7e89c8a7f8 | ||
|
|
085905a7bf | ||
|
|
7523118581 | ||
|
|
c619f1f21b | ||
|
|
d4b0da9265 | ||
|
|
f1058e4d4e | ||
|
|
454d477d7e | ||
|
|
6966228d74 | ||
|
|
f4271a5646 | ||
|
|
534209f0af | ||
|
|
549b82999a | ||
|
|
551f577b2a | ||
|
|
c69c1532cc | ||
|
|
f95b2054c8 | ||
|
|
84a6453dda | ||
|
|
3fdd7ee45e | ||
|
|
591ed61686 | ||
|
|
3d3acbb374 | ||
|
|
671f306a27 | ||
|
|
f7364db992 | ||
|
|
0fb2b3bd66 | ||
|
|
9e270d4d62 | ||
|
|
cf60b5ffbb | ||
|
|
aa4c57d105 | ||
|
|
61a601e6e2 | ||
|
|
05fd4fbfc6 | ||
|
|
2cad743f8c | ||
|
|
6aa9e0bdf7 | ||
|
|
255414f315 | ||
|
|
d2df76f6f4 | ||
|
|
884b1c20e4 | ||
|
|
91a2b4f676 | ||
|
|
5831f79d62 | ||
|
|
36f81b798c | ||
|
|
985183001f | ||
|
|
b153d0fcde | ||
|
|
30d912d66e | ||
|
|
054dc70f3f | ||
|
|
8ac8724cd8 | ||
|
|
4c3746a5b2 | ||
|
|
566ef967f4 | ||
|
|
df697c6513 | ||
|
|
e3e1b7bc5b | ||
|
|
32bb7cdaf3 | ||
|
|
b4261548e8 | ||
|
|
9161cea577 | ||
|
|
3f643de4c1 | ||
|
|
7c7b792cf9 | ||
|
|
8e28f4e159 | ||
|
|
8823cf108f | ||
|
|
4fb501a0ef | ||
|
|
ad0753b959 | ||
|
|
f12cccf6cd | ||
|
|
5bbbe5e40f | ||
|
|
793fcd750c | ||
|
|
ae2c638256 | ||
|
|
9ad0eafe37 | ||
|
|
618e977f20 | ||
|
|
7fdc4f624e | ||
|
|
255ca60d95 | ||
|
|
716f57de46 | ||
|
|
95a987d3a4 | ||
|
|
6858c591aa | ||
|
|
e03b9cb441 | ||
|
|
ade36f8d04 | ||
|
|
48744ee9db | ||
|
|
302e496890 | ||
|
|
6649464cd6 | ||
|
|
c46df21385 | ||
|
|
7a35f5d095 | ||
|
|
73f2853c5e | ||
|
|
de3009e296 | ||
|
|
e47cb5512c | ||
|
|
3171b5a19e | ||
|
|
456cb59b48 | ||
|
|
33ca324e41 | ||
|
|
a84f0279b1 | ||
|
|
b93ba007ed | ||
|
|
d2a4c2a8e4 | ||
|
|
57e05781ad | ||
|
|
bae6c1df97 | ||
|
|
7292c2785e | ||
|
|
42711d3d31 | ||
|
|
67a61ae34d | ||
|
|
a62f981556 | ||
|
|
6b56b10b6e | ||
|
|
e0dee2135f | ||
|
|
bb9aa12a74 | ||
|
|
da64f66bce | ||
|
|
f000a3f30a | ||
|
|
8ad48f92b3 | ||
|
|
a827669f62 | ||
|
|
4bee8d5287 | ||
|
|
5431941fe7 | ||
|
|
d112901a17 | ||
|
|
2d74aef0e9 | ||
|
|
f06e19e1ca | ||
|
|
36aefb280d | ||
|
|
227f5cf62e | ||
|
|
1336cfa42c | ||
|
|
0ca7b8b322 | ||
|
|
eef5304a36 | ||
|
|
1a2141126c | ||
|
|
6ed9a98490 | ||
|
|
19b7660f88 | ||
|
|
1932f14fb6 | ||
|
|
b70b92097f | ||
|
|
b6a800c692 | ||
|
|
d43f5573a6 | ||
|
|
1982168a9f | ||
|
|
5e1794a15b | ||
|
|
578f7f054d | ||
|
|
4b25b3581d | ||
|
|
9601c7f287 | ||
|
|
dae3ec5cba | ||
|
|
95225a1774 | ||
|
|
e54a26393e | ||
|
|
5dc76cf7b4 | ||
|
|
6c68115660 | ||
|
|
88ef65a4e2 | ||
|
|
324b26d8f2 | ||
|
|
a43b403511 | ||
|
|
c657494362 | ||
|
|
11505f08b7 | ||
|
|
9bf9ed5f59 | ||
|
|
0fa517a4f6 | ||
|
|
a11962a487 | ||
|
|
374a1c623f | ||
|
|
82ffc6b23b | ||
|
|
589bbfcf19 | ||
|
|
32d448edf3 | ||
|
|
3d615b13ce | ||
|
|
1ad92dc759 | ||
|
|
aacfd4328c | ||
|
|
6aa1f5211c | ||
|
|
df8e454ee5 | ||
|
|
aec50ac616 | ||
|
|
6a3eaa457f | ||
|
|
6e6b4cd4b2 | ||
|
|
aa1d7933da | ||
|
|
34e0f293c2 | ||
|
|
85215df2c3 | ||
|
|
f8223ca81f | ||
|
|
2b0b1ad726 | ||
|
|
58debcd8bb | ||
|
|
6eba81e3dd | ||
|
|
8a5f6a61d5 |
6
.github/workflows/code-check.yml
vendored
6
.github/workflows/code-check.yml
vendored
@@ -18,18 +18,18 @@ jobs:
|
|||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
strategy:
|
strategy:
|
||||||
matrix:
|
matrix:
|
||||||
python-version: ["3.9", "3.10", "3.11", "3.12", "3.13.0"]
|
python-version: ["3.10", "3.11", "3.12", "3.13.0", "3.14"]
|
||||||
fail-fast: false
|
fail-fast: false
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Check out from Git
|
- name: Check out from Git
|
||||||
uses: actions/checkout@v3
|
uses: actions/checkout@v6
|
||||||
- name: Get history and tags for SCM versioning to work
|
- name: Get history and tags for SCM versioning to work
|
||||||
run: |
|
run: |
|
||||||
git fetch --prune --unshallow
|
git fetch --prune --unshallow
|
||||||
git fetch --depth=1 origin +refs/tags/*:refs/tags/*
|
git fetch --depth=1 origin +refs/tags/*:refs/tags/*
|
||||||
- name: Set up Python
|
- name: Set up Python
|
||||||
uses: actions/setup-python@v3
|
uses: actions/setup-python@v6
|
||||||
with:
|
with:
|
||||||
python-version: ${{ matrix.python-version }}
|
python-version: ${{ matrix.python-version }}
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
|
|||||||
2
.github/workflows/codeql-analysis.yml
vendored
2
.github/workflows/codeql-analysis.yml
vendored
@@ -40,7 +40,7 @@ jobs:
|
|||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@v3
|
uses: actions/checkout@v6
|
||||||
|
|
||||||
# Initializes the CodeQL tools for scanning.
|
# Initializes the CodeQL tools for scanning.
|
||||||
- name: Initialize CodeQL
|
- name: Initialize CodeQL
|
||||||
|
|||||||
4
.github/workflows/gradle-btbench.yml
vendored
4
.github/workflows/gradle-btbench.yml
vendored
@@ -22,10 +22,10 @@ jobs:
|
|||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Check out from Git
|
- name: Check out from Git
|
||||||
uses: actions/checkout@v3
|
uses: actions/checkout@v6
|
||||||
|
|
||||||
- name: Set up JDK
|
- name: Set up JDK
|
||||||
uses: actions/setup-java@v4
|
uses: actions/setup-java@v5
|
||||||
with:
|
with:
|
||||||
distribution: 'zulu'
|
distribution: 'zulu'
|
||||||
java-version: 17
|
java-version: 17
|
||||||
|
|||||||
6
.github/workflows/python-avatar.yml
vendored
6
.github/workflows/python-avatar.yml
vendored
@@ -26,9 +26,9 @@ jobs:
|
|||||||
21/24, 22/24, 23/24, 24/24,
|
21/24, 22/24, 23/24, 24/24,
|
||||||
]
|
]
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v3
|
- uses: actions/checkout@v6
|
||||||
- name: Set Up Python 3.11
|
- name: Set Up Python 3.11
|
||||||
uses: actions/setup-python@v4
|
uses: actions/setup-python@v6
|
||||||
with:
|
with:
|
||||||
python-version: 3.11
|
python-version: 3.11
|
||||||
- name: Install
|
- name: Install
|
||||||
@@ -46,7 +46,7 @@ jobs:
|
|||||||
run: cat rootcanal.log
|
run: cat rootcanal.log
|
||||||
- name: Upload Mobly logs
|
- name: Upload Mobly logs
|
||||||
if: always()
|
if: always()
|
||||||
uses: actions/upload-artifact@v4
|
uses: actions/upload-artifact@v6
|
||||||
with:
|
with:
|
||||||
name: mobly-logs-${{ strategy.job-index }}
|
name: mobly-logs-${{ strategy.job-index }}
|
||||||
path: /tmp/logs/mobly/bumble.bumbles/
|
path: /tmp/logs/mobly/bumble.bumbles/
|
||||||
|
|||||||
17
.github/workflows/python-build-test.yml
vendored
17
.github/workflows/python-build-test.yml
vendored
@@ -18,18 +18,18 @@ jobs:
|
|||||||
strategy:
|
strategy:
|
||||||
matrix:
|
matrix:
|
||||||
os: ['ubuntu-latest', 'macos-latest', 'windows-latest']
|
os: ['ubuntu-latest', 'macos-latest', 'windows-latest']
|
||||||
python-version: ["3.9", "3.10", "3.11", "3.12", "3.13"]
|
python-version: ["3.10", "3.11", "3.12", "3.13", "3.14"]
|
||||||
fail-fast: false
|
fail-fast: false
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Check out from Git
|
- name: Check out from Git
|
||||||
uses: actions/checkout@v3
|
uses: actions/checkout@v6
|
||||||
- name: Get history and tags for SCM versioning to work
|
- name: Get history and tags for SCM versioning to work
|
||||||
run: |
|
run: |
|
||||||
git fetch --prune --unshallow
|
git fetch --prune --unshallow
|
||||||
git fetch --depth=1 origin +refs/tags/*:refs/tags/*
|
git fetch --depth=1 origin +refs/tags/*:refs/tags/*
|
||||||
- name: Set up Python ${{ matrix.python-version }}
|
- name: Set up Python ${{ matrix.python-version }}
|
||||||
uses: actions/setup-python@v4
|
uses: actions/setup-python@v6
|
||||||
with:
|
with:
|
||||||
python-version: ${{ matrix.python-version }}
|
python-version: ${{ matrix.python-version }}
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
@@ -48,14 +48,15 @@ jobs:
|
|||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
strategy:
|
strategy:
|
||||||
matrix:
|
matrix:
|
||||||
python-version: ["3.9", "3.10", "3.11", "3.12", "3.13"]
|
# Rust runtime doesn't support 3.14 yet.
|
||||||
rust-version: [ "1.80.0", "stable" ]
|
python-version: ["3.10", "3.11", "3.12", "3.13"]
|
||||||
|
rust-version: [ "1.80.0", "1.91.0" ]
|
||||||
fail-fast: false
|
fail-fast: false
|
||||||
steps:
|
steps:
|
||||||
- name: Check out from Git
|
- name: Check out from Git
|
||||||
uses: actions/checkout@v3
|
uses: actions/checkout@v6
|
||||||
- name: Set up Python ${{ matrix.python-version }}
|
- name: Set up Python ${{ matrix.python-version }}
|
||||||
uses: actions/setup-python@v4
|
uses: actions/setup-python@v6
|
||||||
with:
|
with:
|
||||||
python-version: ${{ matrix.python-version }}
|
python-version: ${{ matrix.python-version }}
|
||||||
- name: Install Python dependencies
|
- name: Install Python dependencies
|
||||||
@@ -68,7 +69,7 @@ jobs:
|
|||||||
components: clippy,rustfmt
|
components: clippy,rustfmt
|
||||||
toolchain: ${{ matrix.rust-version }}
|
toolchain: ${{ matrix.rust-version }}
|
||||||
- name: Install Rust dependencies
|
- name: Install Rust dependencies
|
||||||
run: cargo install cargo-all-features # allows building/testing combinations of features
|
run: cargo install cargo-all-features --version 1.11.0 --locked # allows building/testing combinations of features
|
||||||
- name: Check License Headers
|
- name: Check License Headers
|
||||||
run: cd rust && cargo run --features dev-tools --bin file-header check-all
|
run: cd rust && cargo run --features dev-tools --bin file-header check-all
|
||||||
- name: Rust Build
|
- name: Rust Build
|
||||||
|
|||||||
6
.github/workflows/python-publish.yml
vendored
6
.github/workflows/python-publish.yml
vendored
@@ -14,13 +14,13 @@ jobs:
|
|||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Check out from Git
|
- name: Check out from Git
|
||||||
uses: actions/checkout@v3
|
uses: actions/checkout@v6
|
||||||
- name: Get history and tags for SCM versioning to work
|
- name: Get history and tags for SCM versioning to work
|
||||||
run: |
|
run: |
|
||||||
git fetch --prune --unshallow
|
git fetch --prune --unshallow
|
||||||
git fetch --depth=1 origin +refs/tags/*:refs/tags/*
|
git fetch --depth=1 origin +refs/tags/*:refs/tags/*
|
||||||
- name: Set up Python
|
- name: Set up Python
|
||||||
uses: actions/setup-python@v3
|
uses: actions/setup-python@v6
|
||||||
with:
|
with:
|
||||||
python-version: '3.10'
|
python-version: '3.10'
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
@@ -31,7 +31,7 @@ jobs:
|
|||||||
run: python -m build
|
run: python -m build
|
||||||
- name: Publish package to PyPI
|
- name: Publish package to PyPI
|
||||||
if: github.event_name == 'release' && startsWith(github.ref, 'refs/tags')
|
if: github.event_name == 'release' && startsWith(github.ref, 'refs/tags')
|
||||||
uses: pypa/gh-action-pypi-publish@release/v1
|
uses: pypa/gh-action-pypi-publish@ed0c53931b1dc9bd32cbe73a98c7f6766f8a527e # release/v1.13
|
||||||
with:
|
with:
|
||||||
user: __token__
|
user: __token__
|
||||||
password: ${{ secrets.PYPI_API_TOKEN }}
|
password: ${{ secrets.PYPI_API_TOKEN }}
|
||||||
|
|||||||
3
.gitignore
vendored
3
.gitignore
vendored
@@ -17,3 +17,6 @@ venv/
|
|||||||
.venv/
|
.venv/
|
||||||
# snoop logs
|
# snoop logs
|
||||||
out/
|
out/
|
||||||
|
# macOS
|
||||||
|
.DS_Store
|
||||||
|
._*
|
||||||
|
|||||||
@@ -50,7 +50,7 @@ Bumble is easiest to use with a dedicated USB dongle.
|
|||||||
This is because internal Bluetooth interfaces tend to be locked down by the operating system.
|
This is because internal Bluetooth interfaces tend to be locked down by the operating system.
|
||||||
You can use the [usb_probe](/docs/mkdocs/src/apps_and_tools/usb_probe.md) tool (all platforms) or `lsusb` (Linux or macOS) to list the available USB devices on your system.
|
You can use the [usb_probe](/docs/mkdocs/src/apps_and_tools/usb_probe.md) tool (all platforms) or `lsusb` (Linux or macOS) to list the available USB devices on your system.
|
||||||
|
|
||||||
See the [USB Transport](/docs/mkdocs/src/transports/usb.md) page for details on how to refer to USB devices. Also, if your are on a mac, see [these instructions](docs/mkdocs/src/platforms/macos.md).
|
See the [USB Transport](/docs/mkdocs/src/transports/usb.md) page for details on how to refer to USB devices. Also, if you are on a mac, see [these instructions](docs/mkdocs/src/platforms/macos.md).
|
||||||
|
|
||||||
## License
|
## License
|
||||||
|
|
||||||
|
|||||||
1260
apps/auracast.py
1260
apps/auracast.py
File diff suppressed because it is too large
Load Diff
@@ -22,7 +22,6 @@ import logging
|
|||||||
import statistics
|
import statistics
|
||||||
import struct
|
import struct
|
||||||
import time
|
import time
|
||||||
from typing import Optional
|
|
||||||
|
|
||||||
import click
|
import click
|
||||||
|
|
||||||
@@ -257,8 +256,8 @@ async def pre_power_on(device: Device, classic: bool) -> None:
|
|||||||
|
|
||||||
async def post_power_on(
|
async def post_power_on(
|
||||||
device: Device,
|
device: Device,
|
||||||
le_scan: Optional[tuple[int, int]],
|
le_scan: tuple[int, int] | None,
|
||||||
le_advertise: Optional[int],
|
le_advertise: int | None,
|
||||||
classic_page_scan: bool,
|
classic_page_scan: bool,
|
||||||
classic_inquiry_scan: bool,
|
classic_inquiry_scan: bool,
|
||||||
) -> None:
|
) -> None:
|
||||||
@@ -1300,7 +1299,7 @@ class IsoClient(StreamedPacketIO):
|
|||||||
super().__init__()
|
super().__init__()
|
||||||
self.device = device
|
self.device = device
|
||||||
self.ready = asyncio.Event()
|
self.ready = asyncio.Event()
|
||||||
self.cis_link: Optional[CisLink] = None
|
self.cis_link: CisLink | None = None
|
||||||
|
|
||||||
async def on_connection(
|
async def on_connection(
|
||||||
self, connection: Connection, cis_link: CisLink, sender: bool
|
self, connection: Connection, cis_link: CisLink, sender: bool
|
||||||
@@ -1341,7 +1340,7 @@ class IsoServer(StreamedPacketIO):
|
|||||||
):
|
):
|
||||||
super().__init__()
|
super().__init__()
|
||||||
self.device = device
|
self.device = device
|
||||||
self.cis_link: Optional[CisLink] = None
|
self.cis_link: CisLink | None = None
|
||||||
self.ready = asyncio.Event()
|
self.ready = asyncio.Event()
|
||||||
|
|
||||||
logging.info(
|
logging.info(
|
||||||
|
|||||||
@@ -24,7 +24,6 @@ import logging
|
|||||||
import os
|
import os
|
||||||
import re
|
import re
|
||||||
from collections import OrderedDict
|
from collections import OrderedDict
|
||||||
from typing import Optional, Union
|
|
||||||
|
|
||||||
import click
|
import click
|
||||||
import humanize
|
import humanize
|
||||||
@@ -126,8 +125,8 @@ def parse_phys(phys):
|
|||||||
# Console App
|
# Console App
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
class ConsoleApp:
|
class ConsoleApp:
|
||||||
connected_peer: Optional[Peer]
|
connected_peer: Peer | None
|
||||||
connection_phy: Optional[ConnectionPHY]
|
connection_phy: ConnectionPHY | None
|
||||||
|
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
self.known_addresses = set()
|
self.known_addresses = set()
|
||||||
@@ -520,7 +519,7 @@ class ConsoleApp:
|
|||||||
|
|
||||||
self.show_attributes(attributes)
|
self.show_attributes(attributes)
|
||||||
|
|
||||||
def find_remote_characteristic(self, param) -> Optional[CharacteristicProxy]:
|
def find_remote_characteristic(self, param) -> CharacteristicProxy | None:
|
||||||
if not self.connected_peer:
|
if not self.connected_peer:
|
||||||
return None
|
return None
|
||||||
parts = param.split('.')
|
parts = param.split('.')
|
||||||
@@ -542,9 +541,7 @@ class ConsoleApp:
|
|||||||
|
|
||||||
return None
|
return None
|
||||||
|
|
||||||
def find_local_attribute(
|
def find_local_attribute(self, param) -> Characteristic | Descriptor | None:
|
||||||
self, param
|
|
||||||
) -> Optional[Union[Characteristic, Descriptor]]:
|
|
||||||
parts = param.split('.')
|
parts = param.split('.')
|
||||||
if len(parts) == 3:
|
if len(parts) == 3:
|
||||||
service_uuid = UUID(parts[0])
|
service_uuid = UUID(parts[0])
|
||||||
@@ -1096,9 +1093,7 @@ class DeviceListener(Device.Listener, Connection.Listener):
|
|||||||
if self.app.connected_peer.connection.is_encrypted
|
if self.app.connected_peer.connection.is_encrypted
|
||||||
else 'not encrypted'
|
else 'not encrypted'
|
||||||
)
|
)
|
||||||
self.app.append_to_output(
|
self.app.append_to_output(f'connection encryption change: {encryption_state}')
|
||||||
'connection encryption change: ' f'{encryption_state}'
|
|
||||||
)
|
|
||||||
|
|
||||||
def on_connection_data_length_change(self):
|
def on_connection_data_length_change(self):
|
||||||
self.app.append_to_output(
|
self.app.append_to_output(
|
||||||
|
|||||||
@@ -27,25 +27,17 @@ from bumble.core import name_or_number
|
|||||||
from bumble.hci import (
|
from bumble.hci import (
|
||||||
HCI_LE_READ_BUFFER_SIZE_COMMAND,
|
HCI_LE_READ_BUFFER_SIZE_COMMAND,
|
||||||
HCI_LE_READ_BUFFER_SIZE_V2_COMMAND,
|
HCI_LE_READ_BUFFER_SIZE_V2_COMMAND,
|
||||||
HCI_LE_READ_MAXIMUM_ADVERTISING_DATA_LENGTH_COMMAND,
|
|
||||||
HCI_LE_READ_MAXIMUM_DATA_LENGTH_COMMAND,
|
HCI_LE_READ_MAXIMUM_DATA_LENGTH_COMMAND,
|
||||||
HCI_LE_READ_NUMBER_OF_SUPPORTED_ADVERTISING_SETS_COMMAND,
|
HCI_LE_READ_MINIMUM_SUPPORTED_CONNECTION_INTERVAL_COMMAND,
|
||||||
HCI_LE_READ_SUGGESTED_DEFAULT_DATA_LENGTH_COMMAND,
|
HCI_LE_READ_SUGGESTED_DEFAULT_DATA_LENGTH_COMMAND,
|
||||||
HCI_READ_BD_ADDR_COMMAND,
|
HCI_READ_BD_ADDR_COMMAND,
|
||||||
HCI_READ_BUFFER_SIZE_COMMAND,
|
HCI_READ_BUFFER_SIZE_COMMAND,
|
||||||
HCI_READ_LOCAL_NAME_COMMAND,
|
HCI_READ_LOCAL_NAME_COMMAND,
|
||||||
HCI_SUCCESS,
|
|
||||||
HCI_VERSION_NAMES,
|
|
||||||
LMP_VERSION_NAMES,
|
|
||||||
CodecID,
|
|
||||||
HCI_Command,
|
HCI_Command,
|
||||||
HCI_Command_Complete_Event,
|
|
||||||
HCI_Command_Status_Event,
|
|
||||||
HCI_LE_Read_Buffer_Size_Command,
|
HCI_LE_Read_Buffer_Size_Command,
|
||||||
HCI_LE_Read_Buffer_Size_V2_Command,
|
HCI_LE_Read_Buffer_Size_V2_Command,
|
||||||
HCI_LE_Read_Maximum_Advertising_Data_Length_Command,
|
|
||||||
HCI_LE_Read_Maximum_Data_Length_Command,
|
HCI_LE_Read_Maximum_Data_Length_Command,
|
||||||
HCI_LE_Read_Number_Of_Supported_Advertising_Sets_Command,
|
HCI_LE_Read_Minimum_Supported_Connection_Interval_Command,
|
||||||
HCI_LE_Read_Suggested_Default_Data_Length_Command,
|
HCI_LE_Read_Suggested_Default_Data_Length_Command,
|
||||||
HCI_Read_BD_ADDR_Command,
|
HCI_Read_BD_ADDR_Command,
|
||||||
HCI_Read_Buffer_Size_Command,
|
HCI_Read_Buffer_Size_Command,
|
||||||
@@ -54,91 +46,88 @@ from bumble.hci import (
|
|||||||
HCI_Read_Local_Supported_Codecs_V2_Command,
|
HCI_Read_Local_Supported_Codecs_V2_Command,
|
||||||
HCI_Read_Local_Version_Information_Command,
|
HCI_Read_Local_Version_Information_Command,
|
||||||
LeFeature,
|
LeFeature,
|
||||||
|
SpecificationVersion,
|
||||||
map_null_terminated_utf8_string,
|
map_null_terminated_utf8_string,
|
||||||
)
|
)
|
||||||
from bumble.host import Host
|
from bumble.host import Host
|
||||||
from bumble.transport import open_transport
|
from bumble.transport import open_transport
|
||||||
|
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
|
||||||
def command_succeeded(response):
|
|
||||||
if isinstance(response, HCI_Command_Status_Event):
|
|
||||||
return response.status == HCI_SUCCESS
|
|
||||||
if isinstance(response, HCI_Command_Complete_Event):
|
|
||||||
return response.return_parameters.status == HCI_SUCCESS
|
|
||||||
return False
|
|
||||||
|
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
async def get_classic_info(host: Host) -> None:
|
async def get_classic_info(host: Host) -> None:
|
||||||
if host.supports_command(HCI_READ_BD_ADDR_COMMAND):
|
if host.supports_command(HCI_READ_BD_ADDR_COMMAND):
|
||||||
response = await host.send_command(HCI_Read_BD_ADDR_Command())
|
response1 = await host.send_sync_command(HCI_Read_BD_ADDR_Command())
|
||||||
if command_succeeded(response):
|
print()
|
||||||
print()
|
print(
|
||||||
print(
|
color('Public Address:', 'yellow'),
|
||||||
color('Public Address:', 'yellow'),
|
response1.bd_addr.to_string(False),
|
||||||
response.return_parameters.bd_addr.to_string(False),
|
)
|
||||||
)
|
|
||||||
|
|
||||||
if host.supports_command(HCI_READ_LOCAL_NAME_COMMAND):
|
if host.supports_command(HCI_READ_LOCAL_NAME_COMMAND):
|
||||||
response = await host.send_command(HCI_Read_Local_Name_Command())
|
response2 = await host.send_sync_command(HCI_Read_Local_Name_Command())
|
||||||
if command_succeeded(response):
|
print()
|
||||||
print()
|
print(
|
||||||
print(
|
color('Local Name:', 'yellow'),
|
||||||
color('Local Name:', 'yellow'),
|
map_null_terminated_utf8_string(response2.local_name),
|
||||||
map_null_terminated_utf8_string(response.return_parameters.local_name),
|
)
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
async def get_le_info(host: Host) -> None:
|
async def get_le_info(host: Host) -> None:
|
||||||
print()
|
print()
|
||||||
|
|
||||||
if host.supports_command(HCI_LE_READ_NUMBER_OF_SUPPORTED_ADVERTISING_SETS_COMMAND):
|
print(
|
||||||
response = await host.send_command(
|
color('LE Number Of Supported Advertising Sets:', 'yellow'),
|
||||||
HCI_LE_Read_Number_Of_Supported_Advertising_Sets_Command()
|
host.number_of_supported_advertising_sets,
|
||||||
)
|
'\n',
|
||||||
if command_succeeded(response):
|
)
|
||||||
print(
|
|
||||||
color('LE Number Of Supported Advertising Sets:', 'yellow'),
|
|
||||||
response.return_parameters.num_supported_advertising_sets,
|
|
||||||
'\n',
|
|
||||||
)
|
|
||||||
|
|
||||||
if host.supports_command(HCI_LE_READ_MAXIMUM_ADVERTISING_DATA_LENGTH_COMMAND):
|
print(
|
||||||
response = await host.send_command(
|
color('LE Maximum Advertising Data Length:', 'yellow'),
|
||||||
HCI_LE_Read_Maximum_Advertising_Data_Length_Command()
|
host.maximum_advertising_data_length,
|
||||||
)
|
'\n',
|
||||||
if command_succeeded(response):
|
)
|
||||||
print(
|
|
||||||
color('LE Maximum Advertising Data Length:', 'yellow'),
|
|
||||||
response.return_parameters.max_advertising_data_length,
|
|
||||||
'\n',
|
|
||||||
)
|
|
||||||
|
|
||||||
if host.supports_command(HCI_LE_READ_MAXIMUM_DATA_LENGTH_COMMAND):
|
if host.supports_command(HCI_LE_READ_MAXIMUM_DATA_LENGTH_COMMAND):
|
||||||
response = await host.send_command(HCI_LE_Read_Maximum_Data_Length_Command())
|
response1 = await host.send_sync_command(
|
||||||
if command_succeeded(response):
|
HCI_LE_Read_Maximum_Data_Length_Command()
|
||||||
print(
|
)
|
||||||
color('Maximum Data Length:', 'yellow'),
|
print(
|
||||||
(
|
color('LE Maximum Data Length:', 'yellow'),
|
||||||
f'tx:{response.return_parameters.supported_max_tx_octets}/'
|
(
|
||||||
f'{response.return_parameters.supported_max_tx_time}, '
|
f'tx:{response1.supported_max_tx_octets}/'
|
||||||
f'rx:{response.return_parameters.supported_max_rx_octets}/'
|
f'{response1.supported_max_tx_time}, '
|
||||||
f'{response.return_parameters.supported_max_rx_time}'
|
f'rx:{response1.supported_max_rx_octets}/'
|
||||||
),
|
f'{response1.supported_max_rx_time}'
|
||||||
'\n',
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
if host.supports_command(HCI_LE_READ_SUGGESTED_DEFAULT_DATA_LENGTH_COMMAND):
|
if host.supports_command(HCI_LE_READ_SUGGESTED_DEFAULT_DATA_LENGTH_COMMAND):
|
||||||
response = await host.send_command(
|
response2 = await host.send_sync_command(
|
||||||
HCI_LE_Read_Suggested_Default_Data_Length_Command()
|
HCI_LE_Read_Suggested_Default_Data_Length_Command()
|
||||||
)
|
)
|
||||||
if command_succeeded(response):
|
print(
|
||||||
|
color('LE Suggested Default Data Length:', 'yellow'),
|
||||||
|
f'{response2.suggested_max_tx_octets}/'
|
||||||
|
f'{response2.suggested_max_tx_time}',
|
||||||
|
'\n',
|
||||||
|
)
|
||||||
|
|
||||||
|
if host.supports_command(HCI_LE_READ_MINIMUM_SUPPORTED_CONNECTION_INTERVAL_COMMAND):
|
||||||
|
response3 = await host.send_sync_command(
|
||||||
|
HCI_LE_Read_Minimum_Supported_Connection_Interval_Command()
|
||||||
|
)
|
||||||
|
print(
|
||||||
|
color('LE Minimum Supported Connection Interval:', 'yellow'),
|
||||||
|
f'{response3.minimum_supported_connection_interval * 125} µs',
|
||||||
|
)
|
||||||
|
for group in range(len(response3.group_min)):
|
||||||
print(
|
print(
|
||||||
color('Suggested Default Data Length:', 'yellow'),
|
f' Group {group}: '
|
||||||
f'{response.return_parameters.suggested_max_tx_octets}/'
|
f'{response3.group_min[group] * 125} µs to '
|
||||||
f'{response.return_parameters.suggested_max_tx_time}',
|
f'{response3.group_max[group] * 125} µs '
|
||||||
|
'by increments of '
|
||||||
|
f'{response3.group_stride[group] * 125} µs',
|
||||||
'\n',
|
'\n',
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -152,37 +141,31 @@ async def get_flow_control_info(host: Host) -> None:
|
|||||||
print()
|
print()
|
||||||
|
|
||||||
if host.supports_command(HCI_READ_BUFFER_SIZE_COMMAND):
|
if host.supports_command(HCI_READ_BUFFER_SIZE_COMMAND):
|
||||||
response = await host.send_command(
|
response1 = await host.send_sync_command(HCI_Read_Buffer_Size_Command())
|
||||||
HCI_Read_Buffer_Size_Command(), check_result=True
|
|
||||||
)
|
|
||||||
print(
|
print(
|
||||||
color('ACL Flow Control:', 'yellow'),
|
color('ACL Flow Control:', 'yellow'),
|
||||||
f'{response.return_parameters.hc_total_num_acl_data_packets} '
|
f'{response1.hc_total_num_acl_data_packets} '
|
||||||
f'packets of size {response.return_parameters.hc_acl_data_packet_length}',
|
f'packets of size {response1.hc_acl_data_packet_length}',
|
||||||
)
|
)
|
||||||
|
|
||||||
if host.supports_command(HCI_LE_READ_BUFFER_SIZE_V2_COMMAND):
|
if host.supports_command(HCI_LE_READ_BUFFER_SIZE_V2_COMMAND):
|
||||||
response = await host.send_command(
|
response2 = await host.send_sync_command(HCI_LE_Read_Buffer_Size_V2_Command())
|
||||||
HCI_LE_Read_Buffer_Size_V2_Command(), check_result=True
|
|
||||||
)
|
|
||||||
print(
|
print(
|
||||||
color('LE ACL Flow Control:', 'yellow'),
|
color('LE ACL Flow Control:', 'yellow'),
|
||||||
f'{response.return_parameters.total_num_le_acl_data_packets} '
|
f'{response2.total_num_le_acl_data_packets} '
|
||||||
f'packets of size {response.return_parameters.le_acl_data_packet_length}',
|
f'packets of size {response2.le_acl_data_packet_length}',
|
||||||
)
|
)
|
||||||
print(
|
print(
|
||||||
color('LE ISO Flow Control:', 'yellow'),
|
color('LE ISO Flow Control:', 'yellow'),
|
||||||
f'{response.return_parameters.total_num_iso_data_packets} '
|
f'{response2.total_num_iso_data_packets} '
|
||||||
f'packets of size {response.return_parameters.iso_data_packet_length}',
|
f'packets of size {response2.iso_data_packet_length}',
|
||||||
)
|
)
|
||||||
elif host.supports_command(HCI_LE_READ_BUFFER_SIZE_COMMAND):
|
elif host.supports_command(HCI_LE_READ_BUFFER_SIZE_COMMAND):
|
||||||
response = await host.send_command(
|
response3 = await host.send_sync_command(HCI_LE_Read_Buffer_Size_Command())
|
||||||
HCI_LE_Read_Buffer_Size_Command(), check_result=True
|
|
||||||
)
|
|
||||||
print(
|
print(
|
||||||
color('LE ACL Flow Control:', 'yellow'),
|
color('LE ACL Flow Control:', 'yellow'),
|
||||||
f'{response.return_parameters.total_num_le_acl_data_packets} '
|
f'{response3.total_num_le_acl_data_packets} '
|
||||||
f'packets of size {response.return_parameters.le_acl_data_packet_length}',
|
f'packets of size {response3.le_acl_data_packet_length}',
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -191,52 +174,44 @@ async def get_codecs_info(host: Host) -> None:
|
|||||||
print()
|
print()
|
||||||
|
|
||||||
if host.supports_command(HCI_Read_Local_Supported_Codecs_V2_Command.op_code):
|
if host.supports_command(HCI_Read_Local_Supported_Codecs_V2_Command.op_code):
|
||||||
response = await host.send_command(
|
response1 = await host.send_sync_command(
|
||||||
HCI_Read_Local_Supported_Codecs_V2_Command(), check_result=True
|
HCI_Read_Local_Supported_Codecs_V2_Command()
|
||||||
)
|
)
|
||||||
print(color('Codecs:', 'yellow'))
|
print(color('Codecs:', 'yellow'))
|
||||||
|
|
||||||
for codec_id, transport in zip(
|
for codec_id, transport in zip(
|
||||||
response.return_parameters.standard_codec_ids,
|
response1.standard_codec_ids,
|
||||||
response.return_parameters.standard_codec_transports,
|
response1.standard_codec_transports,
|
||||||
):
|
):
|
||||||
transport_name = HCI_Read_Local_Supported_Codecs_V2_Command.Transport(
|
print(f' {codec_id.name} - {transport.name}')
|
||||||
transport
|
|
||||||
).name
|
|
||||||
codec_name = CodecID(codec_id).name
|
|
||||||
print(f' {codec_name} - {transport_name}')
|
|
||||||
|
|
||||||
for codec_id, transport in zip(
|
for vendor_codec_id, vendor_transport in zip(
|
||||||
response.return_parameters.vendor_specific_codec_ids,
|
response1.vendor_specific_codec_ids,
|
||||||
response.return_parameters.vendor_specific_codec_transports,
|
response1.vendor_specific_codec_transports,
|
||||||
):
|
):
|
||||||
transport_name = HCI_Read_Local_Supported_Codecs_V2_Command.Transport(
|
company = name_or_number(COMPANY_IDENTIFIERS, vendor_codec_id >> 16)
|
||||||
transport
|
print(f' {company} / {vendor_codec_id & 0xFFFF} - {vendor_transport.name}')
|
||||||
).name
|
|
||||||
company = name_or_number(COMPANY_IDENTIFIERS, codec_id >> 16)
|
|
||||||
print(f' {company} / {codec_id & 0xFFFF} - {transport_name}')
|
|
||||||
|
|
||||||
if not response.return_parameters.standard_codec_ids:
|
if not response1.standard_codec_ids:
|
||||||
print(' No standard codecs')
|
print(' No standard codecs')
|
||||||
if not response.return_parameters.vendor_specific_codec_ids:
|
if not response1.vendor_specific_codec_ids:
|
||||||
print(' No Vendor-specific codecs')
|
print(' No Vendor-specific codecs')
|
||||||
|
|
||||||
if host.supports_command(HCI_Read_Local_Supported_Codecs_Command.op_code):
|
if host.supports_command(HCI_Read_Local_Supported_Codecs_Command.op_code):
|
||||||
response = await host.send_command(
|
response2 = await host.send_sync_command(
|
||||||
HCI_Read_Local_Supported_Codecs_Command(), check_result=True
|
HCI_Read_Local_Supported_Codecs_Command()
|
||||||
)
|
)
|
||||||
print(color('Codecs (BR/EDR):', 'yellow'))
|
print(color('Codecs (BR/EDR):', 'yellow'))
|
||||||
for codec_id in response.return_parameters.standard_codec_ids:
|
for codec_id in response2.standard_codec_ids:
|
||||||
codec_name = CodecID(codec_id).name
|
print(f' {codec_id.name}')
|
||||||
print(f' {codec_name}')
|
|
||||||
|
|
||||||
for codec_id in response.return_parameters.vendor_specific_codec_ids:
|
for vendor_codec_id in response2.vendor_specific_codec_ids:
|
||||||
company = name_or_number(COMPANY_IDENTIFIERS, codec_id >> 16)
|
company = name_or_number(COMPANY_IDENTIFIERS, vendor_codec_id >> 16)
|
||||||
print(f' {company} / {codec_id & 0xFFFF}')
|
print(f' {company} / {vendor_codec_id & 0xFFFF}')
|
||||||
|
|
||||||
if not response.return_parameters.standard_codec_ids:
|
if not response2.standard_codec_ids:
|
||||||
print(' No standard codecs')
|
print(' No standard codecs')
|
||||||
if not response.return_parameters.vendor_specific_codec_ids:
|
if not response2.vendor_specific_codec_ids:
|
||||||
print(' No Vendor-specific codecs')
|
print(' No Vendor-specific codecs')
|
||||||
|
|
||||||
|
|
||||||
@@ -275,7 +250,7 @@ async def async_main(
|
|||||||
(
|
(
|
||||||
f'min={min(latencies):.2f}, '
|
f'min={min(latencies):.2f}, '
|
||||||
f'max={max(latencies):.2f}, '
|
f'max={max(latencies):.2f}, '
|
||||||
f'average={sum(latencies)/len(latencies):.2f},'
|
f'average={sum(latencies) / len(latencies):.2f},'
|
||||||
),
|
),
|
||||||
[f'{latency:.4}' for latency in latencies],
|
[f'{latency:.4}' for latency in latencies],
|
||||||
'\n',
|
'\n',
|
||||||
@@ -289,14 +264,20 @@ async def async_main(
|
|||||||
)
|
)
|
||||||
print(
|
print(
|
||||||
color(' HCI Version: ', 'green'),
|
color(' HCI Version: ', 'green'),
|
||||||
name_or_number(HCI_VERSION_NAMES, host.local_version.hci_version),
|
SpecificationVersion(host.local_version.hci_version).name,
|
||||||
|
)
|
||||||
|
print(
|
||||||
|
color(' HCI Subversion:', 'green'),
|
||||||
|
f'0x{host.local_version.hci_subversion:04x}',
|
||||||
)
|
)
|
||||||
print(color(' HCI Subversion:', 'green'), host.local_version.hci_subversion)
|
|
||||||
print(
|
print(
|
||||||
color(' LMP Version: ', 'green'),
|
color(' LMP Version: ', 'green'),
|
||||||
name_or_number(LMP_VERSION_NAMES, host.local_version.lmp_version),
|
SpecificationVersion(host.local_version.lmp_version).name,
|
||||||
|
)
|
||||||
|
print(
|
||||||
|
color(' LMP Subversion:', 'green'),
|
||||||
|
f'0x{host.local_version.lmp_subversion:04x}',
|
||||||
)
|
)
|
||||||
print(color(' LMP Subversion:', 'green'), host.local_version.lmp_subversion)
|
|
||||||
|
|
||||||
# Get the Classic info
|
# Get the Classic info
|
||||||
await get_classic_info(host)
|
await get_classic_info(host)
|
||||||
|
|||||||
@@ -17,7 +17,6 @@
|
|||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
import asyncio
|
import asyncio
|
||||||
import time
|
import time
|
||||||
from typing import Optional
|
|
||||||
|
|
||||||
import click
|
import click
|
||||||
|
|
||||||
@@ -41,7 +40,7 @@ class Loopback:
|
|||||||
self.transport = transport
|
self.transport = transport
|
||||||
self.packet_size = packet_size
|
self.packet_size = packet_size
|
||||||
self.packet_count = packet_count
|
self.packet_count = packet_count
|
||||||
self.connection_handle: Optional[int] = None
|
self.connection_handle: int | None = None
|
||||||
self.connection_event = asyncio.Event()
|
self.connection_event = asyncio.Event()
|
||||||
self.done = asyncio.Event()
|
self.done = asyncio.Event()
|
||||||
self.expected_cid = 0
|
self.expected_cid = 0
|
||||||
@@ -86,7 +85,7 @@ class Loopback:
|
|||||||
print(color('@@@ Received last packet', 'green'))
|
print(color('@@@ Received last packet', 'green'))
|
||||||
self.done.set()
|
self.done.set()
|
||||||
|
|
||||||
async def run(self):
|
async def run(self) -> None:
|
||||||
"""Run a loopback throughput test"""
|
"""Run a loopback throughput test"""
|
||||||
print(color('>>> Connecting to HCI...', 'green'))
|
print(color('>>> Connecting to HCI...', 'green'))
|
||||||
async with await open_transport(self.transport) as (
|
async with await open_transport(self.transport) as (
|
||||||
@@ -101,11 +100,15 @@ class Loopback:
|
|||||||
# make sure data can fit in one l2cap pdu
|
# make sure data can fit in one l2cap pdu
|
||||||
l2cap_header_size = 4
|
l2cap_header_size = 4
|
||||||
|
|
||||||
max_packet_size = (
|
packet_queue = (
|
||||||
host.acl_packet_queue
|
host.acl_packet_queue
|
||||||
if host.acl_packet_queue
|
if host.acl_packet_queue
|
||||||
else host.le_acl_packet_queue
|
else host.le_acl_packet_queue
|
||||||
).max_packet_size - l2cap_header_size
|
)
|
||||||
|
if packet_queue is None:
|
||||||
|
print(color('!!! No packet queue', 'red'))
|
||||||
|
return
|
||||||
|
max_packet_size = packet_queue.max_packet_size - l2cap_header_size
|
||||||
if self.packet_size > max_packet_size:
|
if self.packet_size > max_packet_size:
|
||||||
print(
|
print(
|
||||||
color(
|
color(
|
||||||
@@ -129,20 +132,18 @@ class Loopback:
|
|||||||
loopback_mode = LoopbackMode.LOCAL
|
loopback_mode = LoopbackMode.LOCAL
|
||||||
|
|
||||||
print(color('### Setting loopback mode', 'blue'))
|
print(color('### Setting loopback mode', 'blue'))
|
||||||
await host.send_command(
|
await host.send_sync_command(
|
||||||
HCI_Write_Loopback_Mode_Command(loopback_mode=LoopbackMode.LOCAL),
|
HCI_Write_Loopback_Mode_Command(loopback_mode=LoopbackMode.LOCAL),
|
||||||
check_result=True,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
print(color('### Checking loopback mode', 'blue'))
|
print(color('### Checking loopback mode', 'blue'))
|
||||||
response = await host.send_command(
|
response = await host.send_sync_command(HCI_Read_Loopback_Mode_Command())
|
||||||
HCI_Read_Loopback_Mode_Command(), check_result=True
|
if response.loopback_mode != loopback_mode:
|
||||||
)
|
|
||||||
if response.return_parameters.loopback_mode != loopback_mode:
|
|
||||||
print(color('!!! Loopback mode mismatch', 'red'))
|
print(color('!!! Loopback mode mismatch', 'red'))
|
||||||
return
|
return
|
||||||
|
|
||||||
await self.connection_event.wait()
|
await self.connection_event.wait()
|
||||||
|
assert self.connection_handle is not None
|
||||||
print(color('### Connected', 'cyan'))
|
print(color('### Connected', 'cyan'))
|
||||||
|
|
||||||
print(color('=== Start sending', 'magenta'))
|
print(color('=== Start sending', 'magenta'))
|
||||||
|
|||||||
@@ -16,7 +16,7 @@
|
|||||||
# Imports
|
# Imports
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
import asyncio
|
import asyncio
|
||||||
from typing import Callable, Iterable, Optional
|
from collections.abc import Callable, Iterable
|
||||||
|
|
||||||
import click
|
import click
|
||||||
|
|
||||||
@@ -174,7 +174,7 @@ async def show_vcs(vcs: VolumeControlServiceProxy) -> None:
|
|||||||
|
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
async def show_device_info(peer, done: Optional[asyncio.Future]) -> None:
|
async def show_device_info(peer, done: asyncio.Future | None) -> None:
|
||||||
try:
|
try:
|
||||||
# Discover all services
|
# Discover all services
|
||||||
print(color('### Discovering Services and Characteristics', 'magenta'))
|
print(color('### Discovering Services and Characteristics', 'magenta'))
|
||||||
@@ -215,7 +215,6 @@ async def show_device_info(peer, done: Optional[asyncio.Future]) -> None:
|
|||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
async def async_main(device_config, encrypt, transport, address_or_name):
|
async def async_main(device_config, encrypt, transport, address_or_name):
|
||||||
async with await open_transport(transport) as (hci_source, hci_sink):
|
async with await open_transport(transport) as (hci_source, hci_sink):
|
||||||
|
|
||||||
# Create a device
|
# Create a device
|
||||||
if device_config:
|
if device_config:
|
||||||
device = Device.from_config_file_with_hci(
|
device = Device.from_config_file_with_hci(
|
||||||
|
|||||||
@@ -61,7 +61,6 @@ async def dump_gatt_db(peer, done):
|
|||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
async def async_main(device_config, encrypt, transport, address_or_name):
|
async def async_main(device_config, encrypt, transport, address_or_name):
|
||||||
async with await open_transport(transport) as (hci_source, hci_sink):
|
async with await open_transport(transport) as (hci_source, hci_sink):
|
||||||
|
|
||||||
# Create a device
|
# Create a device
|
||||||
if device_config:
|
if device_config:
|
||||||
device = Device.from_config_file_with_hci(
|
device = Device.from_config_file_with_hci(
|
||||||
|
|||||||
@@ -352,7 +352,7 @@ async def run(
|
|||||||
await bridge.start()
|
await bridge.start()
|
||||||
|
|
||||||
# Wait until the source terminates
|
# Wait until the source terminates
|
||||||
await hci_source.wait_for_termination()
|
await hci_source.terminated
|
||||||
|
|
||||||
|
|
||||||
@click.command()
|
@click.command()
|
||||||
|
|||||||
@@ -81,7 +81,9 @@ async def async_main():
|
|||||||
response = hci.HCI_Command_Complete_Event(
|
response = hci.HCI_Command_Complete_Event(
|
||||||
num_hci_command_packets=1,
|
num_hci_command_packets=1,
|
||||||
command_opcode=hci_packet.op_code,
|
command_opcode=hci_packet.op_code,
|
||||||
return_parameters=bytes([hci.HCI_SUCCESS]),
|
return_parameters=hci.HCI_StatusReturnParameters(
|
||||||
|
status=hci.HCI_ErrorCode.SUCCESS
|
||||||
|
),
|
||||||
)
|
)
|
||||||
# Return a packet with 'respond to sender' set to True
|
# Return a packet with 'respond to sender' set to True
|
||||||
return (bytes(response), True)
|
return (bytes(response), True)
|
||||||
|
|||||||
@@ -268,7 +268,7 @@ async def run(device_config, hci_transport, bridge):
|
|||||||
await bridge.start(device)
|
await bridge.start(device)
|
||||||
|
|
||||||
# Wait until the transport terminates
|
# Wait until the transport terminates
|
||||||
await hci_source.wait_for_termination()
|
await hci_source.terminated
|
||||||
|
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -268,7 +268,6 @@ class UiServer:
|
|||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
class Speaker:
|
class Speaker:
|
||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
device_config_path: str | None,
|
device_config_path: str | None,
|
||||||
@@ -299,6 +298,7 @@ class Speaker:
|
|||||||
advertising_interval_max=25,
|
advertising_interval_max=25,
|
||||||
address=Address('F1:F2:F3:F4:F5:F6'),
|
address=Address('F1:F2:F3:F4:F5:F6'),
|
||||||
identity_address_type=Address.RANDOM_DEVICE_ADDRESS,
|
identity_address_type=Address.RANDOM_DEVICE_ADDRESS,
|
||||||
|
eatt_enabled=True,
|
||||||
)
|
)
|
||||||
|
|
||||||
device_config.le_enabled = True
|
device_config.le_enabled = True
|
||||||
|
|||||||
108
apps/pair.py
108
apps/pair.py
@@ -15,15 +15,17 @@
|
|||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
# Imports
|
# Imports
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
import struct
|
from typing import ClassVar
|
||||||
|
|
||||||
import click
|
import click
|
||||||
from prompt_toolkit.shortcuts import PromptSession
|
from prompt_toolkit.shortcuts import PromptSession
|
||||||
|
|
||||||
from bumble import data_types
|
from bumble import data_types, smp
|
||||||
from bumble.a2dp import make_audio_sink_service_sdp_records
|
from bumble.a2dp import make_audio_sink_service_sdp_records
|
||||||
from bumble.att import (
|
from bumble.att import (
|
||||||
ATT_INSUFFICIENT_AUTHENTICATION_ERROR,
|
ATT_INSUFFICIENT_AUTHENTICATION_ERROR,
|
||||||
@@ -39,7 +41,7 @@ from bumble.core import (
|
|||||||
PhysicalTransport,
|
PhysicalTransport,
|
||||||
ProtocolError,
|
ProtocolError,
|
||||||
)
|
)
|
||||||
from bumble.device import Device, Peer
|
from bumble.device import Connection, Device, Peer
|
||||||
from bumble.gatt import (
|
from bumble.gatt import (
|
||||||
GATT_DEVICE_NAME_CHARACTERISTIC,
|
GATT_DEVICE_NAME_CHARACTERISTIC,
|
||||||
GATT_GENERIC_ACCESS_SERVICE,
|
GATT_GENERIC_ACCESS_SERVICE,
|
||||||
@@ -52,7 +54,6 @@ from bumble.hci import OwnAddressType
|
|||||||
from bumble.keys import JsonKeyStore
|
from bumble.keys import JsonKeyStore
|
||||||
from bumble.pairing import OobData, PairingConfig, PairingDelegate
|
from bumble.pairing import OobData, PairingConfig, PairingDelegate
|
||||||
from bumble.smp import OobContext, OobLegacyContext
|
from bumble.smp import OobContext, OobLegacyContext
|
||||||
from bumble.smp import error_name as smp_error_name
|
|
||||||
from bumble.transport import open_transport
|
from bumble.transport import open_transport
|
||||||
from bumble.utils import AsyncRunner
|
from bumble.utils import AsyncRunner
|
||||||
|
|
||||||
@@ -64,7 +65,7 @@ POST_PAIRING_DELAY = 1
|
|||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
class Waiter:
|
class Waiter:
|
||||||
instance = None
|
instance: ClassVar[Waiter | None] = None
|
||||||
|
|
||||||
def __init__(self, linger=False):
|
def __init__(self, linger=False):
|
||||||
self.done = asyncio.get_running_loop().create_future()
|
self.done = asyncio.get_running_loop().create_future()
|
||||||
@@ -318,35 +319,36 @@ async def on_classic_pairing(connection):
|
|||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
@AsyncRunner.run_in_task()
|
@AsyncRunner.run_in_task()
|
||||||
async def on_pairing_failure(connection, reason):
|
async def on_pairing_failure(connection: Connection, reason: smp.ErrorCode):
|
||||||
print(color('***-----------------------------------', 'red'))
|
print(color('***-----------------------------------', 'red'))
|
||||||
print(color(f'*** Pairing failed: {smp_error_name(reason)}', 'red'))
|
print(color(f'*** Pairing failed: {reason.name}', 'red'))
|
||||||
print(color('***-----------------------------------', 'red'))
|
print(color('***-----------------------------------', 'red'))
|
||||||
await connection.disconnect()
|
await connection.disconnect()
|
||||||
Waiter.instance.terminate()
|
if Waiter.instance:
|
||||||
|
Waiter.instance.terminate()
|
||||||
|
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
async def pair(
|
async def pair(
|
||||||
mode,
|
mode: str,
|
||||||
sc,
|
sc: bool,
|
||||||
mitm,
|
mitm: bool,
|
||||||
bond,
|
bond: bool,
|
||||||
ctkd,
|
ctkd: bool,
|
||||||
advertising_address,
|
advertising_address: str,
|
||||||
identity_address,
|
identity_address: str,
|
||||||
linger,
|
linger: bool,
|
||||||
io,
|
io: str,
|
||||||
oob,
|
oob: str,
|
||||||
prompt,
|
prompt: bool,
|
||||||
request,
|
request: bool,
|
||||||
print_keys,
|
print_keys: bool,
|
||||||
keystore_file,
|
keystore_file: str,
|
||||||
advertise_service_uuids,
|
advertise_service_uuids: str,
|
||||||
advertise_appearance,
|
advertise_appearance: str,
|
||||||
device_config,
|
device_config: str,
|
||||||
hci_transport,
|
hci_transport: str,
|
||||||
address_or_name,
|
address_or_name: str,
|
||||||
):
|
):
|
||||||
Waiter.instance = Waiter(linger=linger)
|
Waiter.instance = Waiter(linger=linger)
|
||||||
|
|
||||||
@@ -404,6 +406,7 @@ async def pair(
|
|||||||
# Create an OOB context if needed
|
# Create an OOB context if needed
|
||||||
if oob:
|
if oob:
|
||||||
our_oob_context = OobContext()
|
our_oob_context = OobContext()
|
||||||
|
legacy_context: OobLegacyContext | None
|
||||||
if oob == '-':
|
if oob == '-':
|
||||||
shared_data = None
|
shared_data = None
|
||||||
legacy_context = OobLegacyContext()
|
legacy_context = OobLegacyContext()
|
||||||
@@ -528,7 +531,9 @@ async def pair(
|
|||||||
if advertise_appearance:
|
if advertise_appearance:
|
||||||
advertise_appearance = advertise_appearance.upper()
|
advertise_appearance = advertise_appearance.upper()
|
||||||
try:
|
try:
|
||||||
advertise_appearance_int = int(advertise_appearance)
|
appearance = data_types.Appearance.from_int(
|
||||||
|
int(advertise_appearance)
|
||||||
|
)
|
||||||
except ValueError:
|
except ValueError:
|
||||||
category, subcategory = advertise_appearance.split('/')
|
category, subcategory = advertise_appearance.split('/')
|
||||||
try:
|
try:
|
||||||
@@ -546,12 +551,11 @@ async def pair(
|
|||||||
except ValueError:
|
except ValueError:
|
||||||
print(color(f'Invalid subcategory {subcategory}', 'red'))
|
print(color(f'Invalid subcategory {subcategory}', 'red'))
|
||||||
return
|
return
|
||||||
advertise_appearance_int = int(
|
appearance = data_types.Appearance(
|
||||||
Appearance(category_enum, subcategory_enum)
|
category_enum, subcategory_enum
|
||||||
)
|
)
|
||||||
advertising_data_types.append(
|
|
||||||
data_types.Appearance(category_enum, subcategory_enum)
|
advertising_data_types.append(appearance)
|
||||||
)
|
|
||||||
device.advertising_data = bytes(AdvertisingData(advertising_data_types))
|
device.advertising_data = bytes(AdvertisingData(advertising_data_types))
|
||||||
await device.start_advertising(
|
await device.start_advertising(
|
||||||
auto_restart=True,
|
auto_restart=True,
|
||||||
@@ -661,25 +665,25 @@ class LogHandler(logging.Handler):
|
|||||||
@click.argument('hci_transport')
|
@click.argument('hci_transport')
|
||||||
@click.argument('address-or-name', required=False)
|
@click.argument('address-or-name', required=False)
|
||||||
def main(
|
def main(
|
||||||
mode,
|
mode: str,
|
||||||
sc,
|
sc: bool,
|
||||||
mitm,
|
mitm: bool,
|
||||||
bond,
|
bond: bool,
|
||||||
ctkd,
|
ctkd: bool,
|
||||||
advertising_address,
|
advertising_address: str,
|
||||||
identity_address,
|
identity_address: str,
|
||||||
linger,
|
linger: bool,
|
||||||
io,
|
io: str,
|
||||||
oob,
|
oob: str,
|
||||||
prompt,
|
prompt: bool,
|
||||||
request,
|
request: bool,
|
||||||
print_keys,
|
print_keys: bool,
|
||||||
keystore_file,
|
keystore_file: str,
|
||||||
advertise_service_uuid,
|
advertise_service_uuid: str,
|
||||||
advertise_appearance,
|
advertise_appearance: str,
|
||||||
device_config,
|
device_config: str,
|
||||||
hci_transport,
|
hci_transport: str,
|
||||||
address_or_name,
|
address_or_name: str,
|
||||||
):
|
):
|
||||||
# Setup logging
|
# Setup logging
|
||||||
log_handler = LogHandler()
|
log_handler = LogHandler()
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ ROOTCANAL_PORT_CUTTLEFISH = 7300
|
|||||||
@click.option(
|
@click.option(
|
||||||
'--transport',
|
'--transport',
|
||||||
help='HCI transport',
|
help='HCI transport',
|
||||||
default=f'tcp-client:127.0.0.1:<rootcanal-port>',
|
default='tcp-client:127.0.0.1:<rootcanal-port>',
|
||||||
)
|
)
|
||||||
@click.option(
|
@click.option(
|
||||||
'--config',
|
'--config',
|
||||||
@@ -44,7 +44,7 @@ def retrieve_config(config: str) -> dict[str, Any]:
|
|||||||
if not config:
|
if not config:
|
||||||
return {}
|
return {}
|
||||||
|
|
||||||
with open(config, 'r') as f:
|
with open(config) as f:
|
||||||
return json.load(f)
|
return json.load(f)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -19,7 +19,6 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
import logging
|
import logging
|
||||||
from typing import Optional, Union
|
|
||||||
|
|
||||||
import click
|
import click
|
||||||
|
|
||||||
@@ -47,14 +46,13 @@ from bumble.avdtp import (
|
|||||||
AVDTP_DELAY_REPORTING_SERVICE_CATEGORY,
|
AVDTP_DELAY_REPORTING_SERVICE_CATEGORY,
|
||||||
MediaCodecCapabilities,
|
MediaCodecCapabilities,
|
||||||
MediaPacketPump,
|
MediaPacketPump,
|
||||||
|
find_avdtp_service_with_connection,
|
||||||
)
|
)
|
||||||
from bumble.avdtp import Protocol as AvdtpProtocol
|
from bumble.avdtp import Protocol as AvdtpProtocol
|
||||||
from bumble.avdtp import find_avdtp_service_with_connection
|
|
||||||
from bumble.avrcp import Protocol as AvrcpProtocol
|
from bumble.avrcp import Protocol as AvrcpProtocol
|
||||||
from bumble.colors import color
|
from bumble.colors import color
|
||||||
from bumble.core import AdvertisingData
|
from bumble.core import AdvertisingData, DeviceClass, PhysicalTransport
|
||||||
from bumble.core import ConnectionError as BumbleConnectionError
|
from bumble.core import ConnectionError as BumbleConnectionError
|
||||||
from bumble.core import DeviceClass, PhysicalTransport
|
|
||||||
from bumble.device import Connection, Device, DeviceConfiguration
|
from bumble.device import Connection, Device, DeviceConfiguration
|
||||||
from bumble.hci import HCI_CONNECTION_ALREADY_EXISTS_ERROR, Address, HCI_Constant
|
from bumble.hci import HCI_CONNECTION_ALREADY_EXISTS_ERROR, Address, HCI_Constant
|
||||||
from bumble.pairing import PairingConfig
|
from bumble.pairing import PairingConfig
|
||||||
@@ -191,7 +189,7 @@ class Player:
|
|||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
transport: str,
|
transport: str,
|
||||||
device_config: Optional[str],
|
device_config: str | None,
|
||||||
authenticate: bool,
|
authenticate: bool,
|
||||||
encrypt: bool,
|
encrypt: bool,
|
||||||
) -> None:
|
) -> None:
|
||||||
@@ -199,8 +197,8 @@ class Player:
|
|||||||
self.device_config = device_config
|
self.device_config = device_config
|
||||||
self.authenticate = authenticate
|
self.authenticate = authenticate
|
||||||
self.encrypt = encrypt
|
self.encrypt = encrypt
|
||||||
self.avrcp_protocol: Optional[AvrcpProtocol] = None
|
self.avrcp_protocol: AvrcpProtocol | None = None
|
||||||
self.done: Optional[asyncio.Event]
|
self.done: asyncio.Event | None
|
||||||
|
|
||||||
async def run(self, workload) -> None:
|
async def run(self, workload) -> None:
|
||||||
self.done = asyncio.Event()
|
self.done = asyncio.Event()
|
||||||
@@ -315,7 +313,7 @@ class Player:
|
|||||||
codec_type: int,
|
codec_type: int,
|
||||||
vendor_id: int,
|
vendor_id: int,
|
||||||
codec_id: int,
|
codec_id: int,
|
||||||
packet_source: Union[SbcPacketSource, AacPacketSource, OpusPacketSource],
|
packet_source: SbcPacketSource | AacPacketSource | OpusPacketSource,
|
||||||
codec_capabilities: MediaCodecCapabilities,
|
codec_capabilities: MediaCodecCapabilities,
|
||||||
):
|
):
|
||||||
# Discover all endpoints on the remote device
|
# Discover all endpoints on the remote device
|
||||||
@@ -381,11 +379,11 @@ class Player:
|
|||||||
print(f">>> {color(address.to_string(False), 'yellow')}:")
|
print(f">>> {color(address.to_string(False), 'yellow')}:")
|
||||||
print(f" Device Class (raw): {class_of_device:06X}")
|
print(f" Device Class (raw): {class_of_device:06X}")
|
||||||
major_class_name = DeviceClass.major_device_class_name(major_device_class)
|
major_class_name = DeviceClass.major_device_class_name(major_device_class)
|
||||||
print(" Device Major Class: " f"{major_class_name}")
|
print(f" Device Major Class: {major_class_name}")
|
||||||
minor_class_name = DeviceClass.minor_device_class_name(
|
minor_class_name = DeviceClass.minor_device_class_name(
|
||||||
major_device_class, minor_device_class
|
major_device_class, minor_device_class
|
||||||
)
|
)
|
||||||
print(" Device Minor Class: " f"{minor_class_name}")
|
print(f" Device Minor Class: {minor_class_name}")
|
||||||
print(
|
print(
|
||||||
" Device Services: "
|
" Device Services: "
|
||||||
f"{', '.join(DeviceClass.service_class_labels(service_classes))}"
|
f"{', '.join(DeviceClass.service_class_labels(service_classes))}"
|
||||||
@@ -420,7 +418,7 @@ class Player:
|
|||||||
async def play(
|
async def play(
|
||||||
self,
|
self,
|
||||||
device: Device,
|
device: Device,
|
||||||
address: Optional[str],
|
address: str | None,
|
||||||
audio_format: str,
|
audio_format: str,
|
||||||
audio_file: str,
|
audio_file: str,
|
||||||
) -> None:
|
) -> None:
|
||||||
@@ -449,7 +447,7 @@ class Player:
|
|||||||
return input_file.read(byte_count)
|
return input_file.read(byte_count)
|
||||||
|
|
||||||
# Obtain the codec capabilities from the stream
|
# Obtain the codec capabilities from the stream
|
||||||
packet_source: Union[SbcPacketSource, AacPacketSource, OpusPacketSource]
|
packet_source: SbcPacketSource | AacPacketSource | OpusPacketSource
|
||||||
vendor_id = 0
|
vendor_id = 0
|
||||||
codec_id = 0
|
codec_id = 0
|
||||||
if audio_format == "sbc":
|
if audio_format == "sbc":
|
||||||
|
|||||||
@@ -17,7 +17,6 @@
|
|||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
import asyncio
|
import asyncio
|
||||||
import time
|
import time
|
||||||
from typing import Optional
|
|
||||||
|
|
||||||
import click
|
import click
|
||||||
|
|
||||||
@@ -82,14 +81,14 @@ class ServerBridge:
|
|||||||
def __init__(
|
def __init__(
|
||||||
self, channel: int, uuid: str, trace: bool, tcp_host: str, tcp_port: int
|
self, channel: int, uuid: str, trace: bool, tcp_host: str, tcp_port: int
|
||||||
) -> None:
|
) -> None:
|
||||||
self.device: Optional[Device] = None
|
self.device: Device | None = None
|
||||||
self.channel = channel
|
self.channel = channel
|
||||||
self.uuid = uuid
|
self.uuid = uuid
|
||||||
self.tcp_host = tcp_host
|
self.tcp_host = tcp_host
|
||||||
self.tcp_port = tcp_port
|
self.tcp_port = tcp_port
|
||||||
self.rfcomm_channel: Optional[rfcomm.DLC] = None
|
self.rfcomm_channel: rfcomm.DLC | None = None
|
||||||
self.tcp_tracer: Optional[Tracer]
|
self.tcp_tracer: Tracer | None
|
||||||
self.rfcomm_tracer: Optional[Tracer]
|
self.rfcomm_tracer: Tracer | None
|
||||||
|
|
||||||
if trace:
|
if trace:
|
||||||
self.tcp_tracer = Tracer(color("RFCOMM->TCP", "cyan"))
|
self.tcp_tracer = Tracer(color("RFCOMM->TCP", "cyan"))
|
||||||
@@ -242,14 +241,14 @@ class ClientBridge:
|
|||||||
self.tcp_port = tcp_port
|
self.tcp_port = tcp_port
|
||||||
self.authenticate = authenticate
|
self.authenticate = authenticate
|
||||||
self.encrypt = encrypt
|
self.encrypt = encrypt
|
||||||
self.device: Optional[Device] = None
|
self.device: Device | None = None
|
||||||
self.connection: Optional[Connection] = None
|
self.connection: Connection | None = None
|
||||||
self.rfcomm_client: Optional[rfcomm.Client]
|
self.rfcomm_client: rfcomm.Client | None
|
||||||
self.rfcomm_mux: Optional[rfcomm.Multiplexer]
|
self.rfcomm_mux: rfcomm.Multiplexer | None
|
||||||
self.tcp_connected: bool = False
|
self.tcp_connected: bool = False
|
||||||
|
|
||||||
self.tcp_tracer: Optional[Tracer]
|
self.tcp_tracer: Tracer | None
|
||||||
self.rfcomm_tracer: Optional[Tracer]
|
self.rfcomm_tracer: Tracer | None
|
||||||
|
|
||||||
if trace:
|
if trace:
|
||||||
self.tcp_tracer = Tracer(color("RFCOMM->TCP", "cyan"))
|
self.tcp_tracer = Tracer(color("RFCOMM->TCP", "cyan"))
|
||||||
@@ -422,7 +421,7 @@ async def run(device_config, hci_transport, bridge):
|
|||||||
await bridge.start(device)
|
await bridge.start(device)
|
||||||
|
|
||||||
# Wait until the transport terminates
|
# Wait until the transport terminates
|
||||||
await hci_source.wait_for_termination()
|
await hci_source.terminated
|
||||||
except core.ConnectionError as error:
|
except core.ConnectionError as error:
|
||||||
print(color(f"!!! Bluetooth connection failed: {error}", "red"))
|
print(color(f"!!! Bluetooth connection failed: {error}", "red"))
|
||||||
except Exception as error:
|
except Exception as error:
|
||||||
|
|||||||
18
apps/scan.py
18
apps/scan.py
@@ -22,7 +22,7 @@ import click
|
|||||||
import bumble.logging
|
import bumble.logging
|
||||||
from bumble import data_types
|
from bumble import data_types
|
||||||
from bumble.colors import color
|
from bumble.colors import color
|
||||||
from bumble.device import Advertisement, Device
|
from bumble.device import Advertisement, Device, DeviceConfiguration
|
||||||
from bumble.hci import HCI_LE_1M_PHY, HCI_LE_CODED_PHY, Address, HCI_Constant
|
from bumble.hci import HCI_LE_1M_PHY, HCI_LE_CODED_PHY, Address, HCI_Constant
|
||||||
from bumble.keys import JsonKeyStore
|
from bumble.keys import JsonKeyStore
|
||||||
from bumble.smp import AddressResolver
|
from bumble.smp import AddressResolver
|
||||||
@@ -144,8 +144,14 @@ async def scan(
|
|||||||
device_config, hci_source, hci_sink
|
device_config, hci_source, hci_sink
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
device = Device.with_hci(
|
device = Device.from_config_with_hci(
|
||||||
'Bumble', 'F0:F1:F2:F3:F4:F5', hci_source, hci_sink
|
DeviceConfiguration(
|
||||||
|
name='Bumble',
|
||||||
|
address=Address('F0:F1:F2:F3:F4:F5'),
|
||||||
|
keystore='JsonKeyStore',
|
||||||
|
),
|
||||||
|
hci_source,
|
||||||
|
hci_sink,
|
||||||
)
|
)
|
||||||
|
|
||||||
await device.power_on()
|
await device.power_on()
|
||||||
@@ -190,7 +196,7 @@ async def scan(
|
|||||||
scanning_phys=scanning_phys,
|
scanning_phys=scanning_phys,
|
||||||
)
|
)
|
||||||
|
|
||||||
await hci_source.wait_for_termination()
|
await hci_source.terminated
|
||||||
|
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
@@ -217,9 +223,7 @@ async def scan(
|
|||||||
@click.option(
|
@click.option(
|
||||||
'--irk',
|
'--irk',
|
||||||
metavar='<IRK_HEX>:<ADDRESS>',
|
metavar='<IRK_HEX>:<ADDRESS>',
|
||||||
help=(
|
help=('Use this IRK for resolving private addresses (may be used more than once)'),
|
||||||
'Use this IRK for resolving private addresses ' '(may be used more than once)'
|
|
||||||
),
|
|
||||||
multiple=True,
|
multiple=True,
|
||||||
)
|
)
|
||||||
@click.option(
|
@click.option(
|
||||||
|
|||||||
@@ -26,7 +26,6 @@ import pathlib
|
|||||||
import subprocess
|
import subprocess
|
||||||
import weakref
|
import weakref
|
||||||
from importlib import resources
|
from importlib import resources
|
||||||
from typing import Optional
|
|
||||||
|
|
||||||
import aiohttp
|
import aiohttp
|
||||||
import click
|
import click
|
||||||
@@ -156,7 +155,7 @@ class QueuedOutput(Output):
|
|||||||
|
|
||||||
packets: asyncio.Queue
|
packets: asyncio.Queue
|
||||||
extractor: AudioExtractor
|
extractor: AudioExtractor
|
||||||
packet_pump_task: Optional[asyncio.Task]
|
packet_pump_task: asyncio.Task | None
|
||||||
started: bool
|
started: bool
|
||||||
|
|
||||||
def __init__(self, extractor):
|
def __init__(self, extractor):
|
||||||
@@ -230,8 +229,8 @@ class WebSocketOutput(QueuedOutput):
|
|||||||
class FfplayOutput(QueuedOutput):
|
class FfplayOutput(QueuedOutput):
|
||||||
MAX_QUEUE_SIZE = 32768
|
MAX_QUEUE_SIZE = 32768
|
||||||
|
|
||||||
subprocess: Optional[asyncio.subprocess.Process]
|
subprocess: asyncio.subprocess.Process | None
|
||||||
ffplay_task: Optional[asyncio.Task]
|
ffplay_task: asyncio.Task | None
|
||||||
|
|
||||||
def __init__(self, codec: str) -> None:
|
def __init__(self, codec: str) -> None:
|
||||||
super().__init__(AudioExtractor.create(codec))
|
super().__init__(AudioExtractor.create(codec))
|
||||||
@@ -727,7 +726,7 @@ class Speaker:
|
|||||||
print("Waiting for connection...")
|
print("Waiting for connection...")
|
||||||
await self.advertise()
|
await self.advertise()
|
||||||
|
|
||||||
await hci_source.wait_for_termination()
|
await hci_source.terminated
|
||||||
|
|
||||||
for output in self.outputs:
|
for output in self.outputs:
|
||||||
await output.stop()
|
await output.stop()
|
||||||
|
|||||||
@@ -26,6 +26,8 @@
|
|||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
# Imports
|
# Imports
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
import click
|
import click
|
||||||
import usb1
|
import usb1
|
||||||
|
|
||||||
@@ -166,13 +168,16 @@ def is_bluetooth_hci(device):
|
|||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
@click.command()
|
@click.command()
|
||||||
@click.option('--verbose', is_flag=True, default=False, help='Print more details')
|
@click.option('--verbose', is_flag=True, default=False, help='Print more details')
|
||||||
def main(verbose):
|
@click.option('--hci-only', is_flag=True, default=False, help='only show HCI device')
|
||||||
|
@click.option('--manufacturer', help='filter by manufacturer')
|
||||||
|
@click.option('--product', help='filter by product')
|
||||||
|
def main(verbose: bool, manufacturer: str, product: str, hci_only: bool):
|
||||||
bumble.logging.setup_basic_logging('WARNING')
|
bumble.logging.setup_basic_logging('WARNING')
|
||||||
|
|
||||||
load_libusb()
|
load_libusb()
|
||||||
with usb1.USBContext() as context:
|
with usb1.USBContext() as context:
|
||||||
bluetooth_device_count = 0
|
bluetooth_device_count = 0
|
||||||
devices = {}
|
devices: dict[tuple[Any, Any], list[str | None]] = {}
|
||||||
|
|
||||||
for device in context.getDeviceIterator(skip_on_error=True):
|
for device in context.getDeviceIterator(skip_on_error=True):
|
||||||
device_class = device.getDeviceClass()
|
device_class = device.getDeviceClass()
|
||||||
@@ -234,6 +239,14 @@ def main(verbose):
|
|||||||
f'{basic_transport_name}/{device_serial_number}'
|
f'{basic_transport_name}/{device_serial_number}'
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Filter
|
||||||
|
if product and device_product != product:
|
||||||
|
continue
|
||||||
|
if manufacturer and device_manufacturer != manufacturer:
|
||||||
|
continue
|
||||||
|
if not is_bluetooth_hci(device) and hci_only:
|
||||||
|
continue
|
||||||
|
|
||||||
# Print the results
|
# Print the results
|
||||||
print(
|
print(
|
||||||
color(
|
color(
|
||||||
|
|||||||
@@ -21,11 +21,12 @@ import dataclasses
|
|||||||
import enum
|
import enum
|
||||||
import logging
|
import logging
|
||||||
import struct
|
import struct
|
||||||
from collections.abc import AsyncGenerator
|
from collections.abc import AsyncGenerator, Awaitable, Callable
|
||||||
from typing import Awaitable, Callable
|
from typing import ClassVar
|
||||||
|
|
||||||
from typing_extensions import ClassVar, Self
|
from typing_extensions import Self
|
||||||
|
|
||||||
|
from bumble import utils
|
||||||
from bumble.codecs import AacAudioRtpPacket
|
from bumble.codecs import AacAudioRtpPacket
|
||||||
from bumble.company_ids import COMPANY_IDENTIFIERS
|
from bumble.company_ids import COMPANY_IDENTIFIERS
|
||||||
from bumble.core import (
|
from bumble.core import (
|
||||||
@@ -59,19 +60,18 @@ logger = logging.getLogger(__name__)
|
|||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
# fmt: off
|
# fmt: off
|
||||||
|
|
||||||
A2DP_SBC_CODEC_TYPE = 0x00
|
class CodecType(utils.OpenIntEnum):
|
||||||
A2DP_MPEG_1_2_AUDIO_CODEC_TYPE = 0x01
|
SBC = 0x00
|
||||||
A2DP_MPEG_2_4_AAC_CODEC_TYPE = 0x02
|
MPEG_1_2_AUDIO = 0x01
|
||||||
A2DP_ATRAC_FAMILY_CODEC_TYPE = 0x03
|
MPEG_2_4_AAC = 0x02
|
||||||
A2DP_NON_A2DP_CODEC_TYPE = 0xFF
|
ATRAC_FAMILY = 0x03
|
||||||
|
NON_A2DP = 0xFF
|
||||||
|
|
||||||
A2DP_CODEC_TYPE_NAMES = {
|
A2DP_SBC_CODEC_TYPE = CodecType.SBC
|
||||||
A2DP_SBC_CODEC_TYPE: 'A2DP_SBC_CODEC_TYPE',
|
A2DP_MPEG_1_2_AUDIO_CODEC_TYPE = CodecType.MPEG_1_2_AUDIO
|
||||||
A2DP_MPEG_1_2_AUDIO_CODEC_TYPE: 'A2DP_MPEG_1_2_AUDIO_CODEC_TYPE',
|
A2DP_MPEG_2_4_AAC_CODEC_TYPE = CodecType.MPEG_2_4_AAC
|
||||||
A2DP_MPEG_2_4_AAC_CODEC_TYPE: 'A2DP_MPEG_2_4_AAC_CODEC_TYPE',
|
A2DP_ATRAC_FAMILY_CODEC_TYPE = CodecType.ATRAC_FAMILY
|
||||||
A2DP_ATRAC_FAMILY_CODEC_TYPE: 'A2DP_ATRAC_FAMILY_CODEC_TYPE',
|
A2DP_NON_A2DP_CODEC_TYPE = CodecType.NON_A2DP
|
||||||
A2DP_NON_A2DP_CODEC_TYPE: 'A2DP_NON_A2DP_CODEC_TYPE'
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
SBC_SYNC_WORD = 0x9C
|
SBC_SYNC_WORD = 0x9C
|
||||||
@@ -88,13 +88,6 @@ SBC_DUAL_CHANNEL_MODE = 0x01
|
|||||||
SBC_STEREO_CHANNEL_MODE = 0x02
|
SBC_STEREO_CHANNEL_MODE = 0x02
|
||||||
SBC_JOINT_STEREO_CHANNEL_MODE = 0x03
|
SBC_JOINT_STEREO_CHANNEL_MODE = 0x03
|
||||||
|
|
||||||
SBC_CHANNEL_MODE_NAMES = {
|
|
||||||
SBC_MONO_CHANNEL_MODE: 'SBC_MONO_CHANNEL_MODE',
|
|
||||||
SBC_DUAL_CHANNEL_MODE: 'SBC_DUAL_CHANNEL_MODE',
|
|
||||||
SBC_STEREO_CHANNEL_MODE: 'SBC_STEREO_CHANNEL_MODE',
|
|
||||||
SBC_JOINT_STEREO_CHANNEL_MODE: 'SBC_JOINT_STEREO_CHANNEL_MODE'
|
|
||||||
}
|
|
||||||
|
|
||||||
SBC_BLOCK_LENGTHS = [4, 8, 12, 16]
|
SBC_BLOCK_LENGTHS = [4, 8, 12, 16]
|
||||||
|
|
||||||
SBC_SUBBANDS = [4, 8]
|
SBC_SUBBANDS = [4, 8]
|
||||||
@@ -102,11 +95,6 @@ SBC_SUBBANDS = [4, 8]
|
|||||||
SBC_SNR_ALLOCATION_METHOD = 0x00
|
SBC_SNR_ALLOCATION_METHOD = 0x00
|
||||||
SBC_LOUDNESS_ALLOCATION_METHOD = 0x01
|
SBC_LOUDNESS_ALLOCATION_METHOD = 0x01
|
||||||
|
|
||||||
SBC_ALLOCATION_METHOD_NAMES = {
|
|
||||||
SBC_SNR_ALLOCATION_METHOD: 'SBC_SNR_ALLOCATION_METHOD',
|
|
||||||
SBC_LOUDNESS_ALLOCATION_METHOD: 'SBC_LOUDNESS_ALLOCATION_METHOD'
|
|
||||||
}
|
|
||||||
|
|
||||||
SBC_MAX_FRAMES_IN_RTP_PAYLOAD = 15
|
SBC_MAX_FRAMES_IN_RTP_PAYLOAD = 15
|
||||||
|
|
||||||
MPEG_2_4_AAC_SAMPLING_FREQUENCIES = [
|
MPEG_2_4_AAC_SAMPLING_FREQUENCIES = [
|
||||||
@@ -129,13 +117,6 @@ MPEG_4_AAC_LC_OBJECT_TYPE = 0x01
|
|||||||
MPEG_4_AAC_LTP_OBJECT_TYPE = 0x02
|
MPEG_4_AAC_LTP_OBJECT_TYPE = 0x02
|
||||||
MPEG_4_AAC_SCALABLE_OBJECT_TYPE = 0x03
|
MPEG_4_AAC_SCALABLE_OBJECT_TYPE = 0x03
|
||||||
|
|
||||||
MPEG_2_4_OBJECT_TYPE_NAMES = {
|
|
||||||
MPEG_2_AAC_LC_OBJECT_TYPE: 'MPEG_2_AAC_LC_OBJECT_TYPE',
|
|
||||||
MPEG_4_AAC_LC_OBJECT_TYPE: 'MPEG_4_AAC_LC_OBJECT_TYPE',
|
|
||||||
MPEG_4_AAC_LTP_OBJECT_TYPE: 'MPEG_4_AAC_LTP_OBJECT_TYPE',
|
|
||||||
MPEG_4_AAC_SCALABLE_OBJECT_TYPE: 'MPEG_4_AAC_SCALABLE_OBJECT_TYPE'
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
OPUS_MAX_FRAMES_IN_RTP_PAYLOAD = 15
|
OPUS_MAX_FRAMES_IN_RTP_PAYLOAD = 15
|
||||||
|
|
||||||
@@ -259,9 +240,49 @@ def make_audio_sink_service_sdp_records(service_record_handle, version=(1, 3)):
|
|||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
class MediaCodecInformation:
|
||||||
|
'''Base Media Codec Information.'''
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def create(
|
||||||
|
cls, media_codec_type: int, data: bytes
|
||||||
|
) -> MediaCodecInformation | bytes:
|
||||||
|
match media_codec_type:
|
||||||
|
case CodecType.SBC:
|
||||||
|
return SbcMediaCodecInformation.from_bytes(data)
|
||||||
|
case CodecType.MPEG_2_4_AAC:
|
||||||
|
return AacMediaCodecInformation.from_bytes(data)
|
||||||
|
case CodecType.NON_A2DP:
|
||||||
|
vendor_media_codec_information = (
|
||||||
|
VendorSpecificMediaCodecInformation.from_bytes(data)
|
||||||
|
)
|
||||||
|
if (
|
||||||
|
vendor_class_map := A2DP_VENDOR_MEDIA_CODEC_INFORMATION_CLASSES.get(
|
||||||
|
vendor_media_codec_information.vendor_id
|
||||||
|
)
|
||||||
|
) and (
|
||||||
|
media_codec_information_class := vendor_class_map.get(
|
||||||
|
vendor_media_codec_information.codec_id
|
||||||
|
)
|
||||||
|
):
|
||||||
|
return media_codec_information_class.from_bytes(
|
||||||
|
vendor_media_codec_information.value
|
||||||
|
)
|
||||||
|
return vendor_media_codec_information
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_bytes(cls, data: bytes) -> Self:
|
||||||
|
del data # Unused.
|
||||||
|
raise NotImplementedError
|
||||||
|
|
||||||
|
def __bytes__(self) -> bytes:
|
||||||
|
raise NotImplementedError
|
||||||
|
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
@dataclasses.dataclass
|
@dataclasses.dataclass
|
||||||
class SbcMediaCodecInformation:
|
class SbcMediaCodecInformation(MediaCodecInformation):
|
||||||
'''
|
'''
|
||||||
A2DP spec - 4.3.2 Codec Specific Information Elements
|
A2DP spec - 4.3.2 Codec Specific Information Elements
|
||||||
'''
|
'''
|
||||||
@@ -345,7 +366,7 @@ class SbcMediaCodecInformation:
|
|||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
@dataclasses.dataclass
|
@dataclasses.dataclass
|
||||||
class AacMediaCodecInformation:
|
class AacMediaCodecInformation(MediaCodecInformation):
|
||||||
'''
|
'''
|
||||||
A2DP spec - 4.5.2 Codec Specific Information Elements
|
A2DP spec - 4.5.2 Codec Specific Information Elements
|
||||||
'''
|
'''
|
||||||
@@ -427,7 +448,7 @@ class AacMediaCodecInformation:
|
|||||||
|
|
||||||
@dataclasses.dataclass
|
@dataclasses.dataclass
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
class VendorSpecificMediaCodecInformation:
|
class VendorSpecificMediaCodecInformation(MediaCodecInformation):
|
||||||
'''
|
'''
|
||||||
A2DP spec - 4.7.2 Codec Specific Information Elements
|
A2DP spec - 4.7.2 Codec Specific Information Elements
|
||||||
'''
|
'''
|
||||||
@@ -451,7 +472,7 @@ class VendorSpecificMediaCodecInformation:
|
|||||||
'VendorSpecificMediaCodecInformation(',
|
'VendorSpecificMediaCodecInformation(',
|
||||||
f' vendor_id: {self.vendor_id:08X} ({name_or_number(COMPANY_IDENTIFIERS, self.vendor_id & 0xFFFF)})',
|
f' vendor_id: {self.vendor_id:08X} ({name_or_number(COMPANY_IDENTIFIERS, self.vendor_id & 0xFFFF)})',
|
||||||
f' codec_id: {self.codec_id:04X}',
|
f' codec_id: {self.codec_id:04X}',
|
||||||
f' value: {self.value.hex()}' ')',
|
f' value: {self.value.hex()})',
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -647,7 +668,7 @@ class SbcPacketSource:
|
|||||||
# Prepare for next packets
|
# Prepare for next packets
|
||||||
sequence_number += 1
|
sequence_number += 1
|
||||||
sequence_number &= 0xFFFF
|
sequence_number &= 0xFFFF
|
||||||
sample_count += sum((frame.sample_count for frame in frames))
|
sample_count += sum(frame.sample_count for frame in frames)
|
||||||
frames = [frame]
|
frames = [frame]
|
||||||
frames_size = len(frame.payload)
|
frames_size = len(frame.payload)
|
||||||
else:
|
else:
|
||||||
|
|||||||
69
bumble/at.py
69
bumble/at.py
@@ -12,7 +12,6 @@
|
|||||||
# See the License for the specific language governing permissions and
|
# See the License for the specific language governing permissions and
|
||||||
# limitations under the License.
|
# limitations under the License.
|
||||||
|
|
||||||
from typing import Union
|
|
||||||
|
|
||||||
from bumble import core
|
from bumble import core
|
||||||
|
|
||||||
@@ -28,7 +27,7 @@ def tokenize_parameters(buffer: bytes) -> list[bytes]:
|
|||||||
are ignored [..], unless they are embedded in numeric or string constants"
|
are ignored [..], unless they are embedded in numeric or string constants"
|
||||||
Raises AtParsingError in case of invalid input string."""
|
Raises AtParsingError in case of invalid input string."""
|
||||||
|
|
||||||
tokens = []
|
tokens: list[bytearray] = []
|
||||||
in_quotes = False
|
in_quotes = False
|
||||||
token = bytearray()
|
token = bytearray()
|
||||||
for b in buffer:
|
for b in buffer:
|
||||||
@@ -36,54 +35,56 @@ def tokenize_parameters(buffer: bytes) -> list[bytes]:
|
|||||||
|
|
||||||
if in_quotes:
|
if in_quotes:
|
||||||
token.extend(char)
|
token.extend(char)
|
||||||
if char == b'\"':
|
if char == b'"':
|
||||||
in_quotes = False
|
in_quotes = False
|
||||||
tokens.append(token[1:-1])
|
tokens.append(token[1:-1])
|
||||||
token = bytearray()
|
token = bytearray()
|
||||||
else:
|
else:
|
||||||
if char == b' ':
|
match char:
|
||||||
pass
|
case b' ':
|
||||||
elif char == b',' or char == b')':
|
pass
|
||||||
tokens.append(token)
|
case b',' | b')':
|
||||||
tokens.append(char)
|
tokens.append(token)
|
||||||
token = bytearray()
|
tokens.append(char)
|
||||||
elif char == b'(':
|
token = bytearray()
|
||||||
if len(token) > 0:
|
case b'(':
|
||||||
raise AtParsingError("open_paren following regular character")
|
if len(token) > 0:
|
||||||
tokens.append(char)
|
raise AtParsingError("open_paren following regular character")
|
||||||
elif char == b'"':
|
tokens.append(char)
|
||||||
if len(token) > 0:
|
case b'"':
|
||||||
raise AtParsingError("quote following regular character")
|
if len(token) > 0:
|
||||||
in_quotes = True
|
raise AtParsingError("quote following regular character")
|
||||||
token.extend(char)
|
in_quotes = True
|
||||||
else:
|
token.extend(char)
|
||||||
token.extend(char)
|
case _:
|
||||||
|
token.extend(char)
|
||||||
|
|
||||||
tokens.append(token)
|
tokens.append(token)
|
||||||
return [bytes(token) for token in tokens if len(token) > 0]
|
return [bytes(token) for token in tokens if len(token) > 0]
|
||||||
|
|
||||||
|
|
||||||
def parse_parameters(buffer: bytes) -> list[Union[bytes, list]]:
|
def parse_parameters(buffer: bytes) -> list[bytes | list]:
|
||||||
"""Parse the parameters using the comma and parenthesis separators.
|
"""Parse the parameters using the comma and parenthesis separators.
|
||||||
Raises AtParsingError in case of invalid input string."""
|
Raises AtParsingError in case of invalid input string."""
|
||||||
|
|
||||||
tokens = tokenize_parameters(buffer)
|
tokens = tokenize_parameters(buffer)
|
||||||
accumulator: list[list] = [[]]
|
accumulator: list[list] = [[]]
|
||||||
current: Union[bytes, list] = bytes()
|
current: bytes | list = b''
|
||||||
|
|
||||||
for token in tokens:
|
for token in tokens:
|
||||||
if token == b',':
|
match token:
|
||||||
accumulator[-1].append(current)
|
case b',':
|
||||||
current = bytes()
|
accumulator[-1].append(current)
|
||||||
elif token == b'(':
|
current = b''
|
||||||
accumulator.append([])
|
case b'(':
|
||||||
elif token == b')':
|
accumulator.append([])
|
||||||
if len(accumulator) < 2:
|
case b')':
|
||||||
raise AtParsingError("close_paren without matching open_paren")
|
if len(accumulator) < 2:
|
||||||
accumulator[-1].append(current)
|
raise AtParsingError("close_paren without matching open_paren")
|
||||||
current = accumulator.pop()
|
accumulator[-1].append(current)
|
||||||
else:
|
current = accumulator.pop()
|
||||||
current = token
|
case _:
|
||||||
|
current = token
|
||||||
|
|
||||||
accumulator[-1].append(current)
|
accumulator[-1].append(current)
|
||||||
if len(accumulator) > 1:
|
if len(accumulator) > 1:
|
||||||
|
|||||||
310
bumble/att.py
310
bumble/att.py
@@ -29,20 +29,20 @@ import enum
|
|||||||
import functools
|
import functools
|
||||||
import inspect
|
import inspect
|
||||||
import struct
|
import struct
|
||||||
|
from collections.abc import Awaitable, Callable, Sequence
|
||||||
from typing import (
|
from typing import (
|
||||||
TYPE_CHECKING,
|
TYPE_CHECKING,
|
||||||
Awaitable,
|
|
||||||
Callable,
|
|
||||||
ClassVar,
|
ClassVar,
|
||||||
Generic,
|
Generic,
|
||||||
Optional,
|
TypeAlias,
|
||||||
TypeVar,
|
TypeVar,
|
||||||
Union,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
from bumble import hci, utils
|
from typing_extensions import TypeIs
|
||||||
|
|
||||||
|
from bumble import hci, l2cap, utils
|
||||||
from bumble.colors import color
|
from bumble.colors import color
|
||||||
from bumble.core import UUID, InvalidOperationError, ProtocolError
|
from bumble.core import UUID, InvalidOperationError, InvalidPacketError, ProtocolError
|
||||||
from bumble.hci import HCI_Object
|
from bumble.hci import HCI_Object
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
@@ -53,6 +53,14 @@ if TYPE_CHECKING:
|
|||||||
|
|
||||||
_T = TypeVar('_T')
|
_T = TypeVar('_T')
|
||||||
|
|
||||||
|
Bearer: TypeAlias = "Connection | l2cap.LeCreditBasedChannel"
|
||||||
|
EnhancedBearer: TypeAlias = l2cap.LeCreditBasedChannel
|
||||||
|
|
||||||
|
|
||||||
|
def is_enhanced_bearer(bearer: Bearer) -> TypeIs[EnhancedBearer]:
|
||||||
|
return isinstance(bearer, EnhancedBearer)
|
||||||
|
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
# Constants
|
# Constants
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
@@ -61,36 +69,39 @@ _T = TypeVar('_T')
|
|||||||
|
|
||||||
ATT_CID = 0x04
|
ATT_CID = 0x04
|
||||||
ATT_PSM = 0x001F
|
ATT_PSM = 0x001F
|
||||||
|
EATT_PSM = 0x0027
|
||||||
|
|
||||||
class Opcode(hci.SpecableEnum):
|
class Opcode(hci.SpecableEnum):
|
||||||
ATT_ERROR_RESPONSE = 0x01
|
ATT_ERROR_RESPONSE = 0x01
|
||||||
ATT_EXCHANGE_MTU_REQUEST = 0x02
|
ATT_EXCHANGE_MTU_REQUEST = 0x02
|
||||||
ATT_EXCHANGE_MTU_RESPONSE = 0x03
|
ATT_EXCHANGE_MTU_RESPONSE = 0x03
|
||||||
ATT_FIND_INFORMATION_REQUEST = 0x04
|
ATT_FIND_INFORMATION_REQUEST = 0x04
|
||||||
ATT_FIND_INFORMATION_RESPONSE = 0x05
|
ATT_FIND_INFORMATION_RESPONSE = 0x05
|
||||||
ATT_FIND_BY_TYPE_VALUE_REQUEST = 0x06
|
ATT_FIND_BY_TYPE_VALUE_REQUEST = 0x06
|
||||||
ATT_FIND_BY_TYPE_VALUE_RESPONSE = 0x07
|
ATT_FIND_BY_TYPE_VALUE_RESPONSE = 0x07
|
||||||
ATT_READ_BY_TYPE_REQUEST = 0x08
|
ATT_READ_BY_TYPE_REQUEST = 0x08
|
||||||
ATT_READ_BY_TYPE_RESPONSE = 0x09
|
ATT_READ_BY_TYPE_RESPONSE = 0x09
|
||||||
ATT_READ_REQUEST = 0x0A
|
ATT_READ_REQUEST = 0x0A
|
||||||
ATT_READ_RESPONSE = 0x0B
|
ATT_READ_RESPONSE = 0x0B
|
||||||
ATT_READ_BLOB_REQUEST = 0x0C
|
ATT_READ_BLOB_REQUEST = 0x0C
|
||||||
ATT_READ_BLOB_RESPONSE = 0x0D
|
ATT_READ_BLOB_RESPONSE = 0x0D
|
||||||
ATT_READ_MULTIPLE_REQUEST = 0x0E
|
ATT_READ_MULTIPLE_REQUEST = 0x0E
|
||||||
ATT_READ_MULTIPLE_RESPONSE = 0x0F
|
ATT_READ_MULTIPLE_RESPONSE = 0x0F
|
||||||
ATT_READ_BY_GROUP_TYPE_REQUEST = 0x10
|
ATT_READ_BY_GROUP_TYPE_REQUEST = 0x10
|
||||||
ATT_READ_BY_GROUP_TYPE_RESPONSE = 0x11
|
ATT_READ_BY_GROUP_TYPE_RESPONSE = 0x11
|
||||||
ATT_WRITE_REQUEST = 0x12
|
ATT_READ_MULTIPLE_VARIABLE_REQUEST = 0x20
|
||||||
ATT_WRITE_RESPONSE = 0x13
|
ATT_READ_MULTIPLE_VARIABLE_RESPONSE = 0x21
|
||||||
ATT_WRITE_COMMAND = 0x52
|
ATT_WRITE_REQUEST = 0x12
|
||||||
ATT_SIGNED_WRITE_COMMAND = 0xD2
|
ATT_WRITE_RESPONSE = 0x13
|
||||||
ATT_PREPARE_WRITE_REQUEST = 0x16
|
ATT_WRITE_COMMAND = 0x52
|
||||||
ATT_PREPARE_WRITE_RESPONSE = 0x17
|
ATT_SIGNED_WRITE_COMMAND = 0xD2
|
||||||
ATT_EXECUTE_WRITE_REQUEST = 0x18
|
ATT_PREPARE_WRITE_REQUEST = 0x16
|
||||||
ATT_EXECUTE_WRITE_RESPONSE = 0x19
|
ATT_PREPARE_WRITE_RESPONSE = 0x17
|
||||||
ATT_HANDLE_VALUE_NOTIFICATION = 0x1B
|
ATT_EXECUTE_WRITE_REQUEST = 0x18
|
||||||
ATT_HANDLE_VALUE_INDICATION = 0x1D
|
ATT_EXECUTE_WRITE_RESPONSE = 0x19
|
||||||
ATT_HANDLE_VALUE_CONFIRMATION = 0x1E
|
ATT_HANDLE_VALUE_NOTIFICATION = 0x1B
|
||||||
|
ATT_HANDLE_VALUE_INDICATION = 0x1D
|
||||||
|
ATT_HANDLE_VALUE_CONFIRMATION = 0x1E
|
||||||
|
|
||||||
ATT_REQUESTS = [
|
ATT_REQUESTS = [
|
||||||
Opcode.ATT_EXCHANGE_MTU_REQUEST,
|
Opcode.ATT_EXCHANGE_MTU_REQUEST,
|
||||||
@@ -101,9 +112,10 @@ ATT_REQUESTS = [
|
|||||||
Opcode.ATT_READ_BLOB_REQUEST,
|
Opcode.ATT_READ_BLOB_REQUEST,
|
||||||
Opcode.ATT_READ_MULTIPLE_REQUEST,
|
Opcode.ATT_READ_MULTIPLE_REQUEST,
|
||||||
Opcode.ATT_READ_BY_GROUP_TYPE_REQUEST,
|
Opcode.ATT_READ_BY_GROUP_TYPE_REQUEST,
|
||||||
|
Opcode.ATT_READ_MULTIPLE_VARIABLE_REQUEST,
|
||||||
Opcode.ATT_WRITE_REQUEST,
|
Opcode.ATT_WRITE_REQUEST,
|
||||||
Opcode.ATT_PREPARE_WRITE_REQUEST,
|
Opcode.ATT_PREPARE_WRITE_REQUEST,
|
||||||
Opcode.ATT_EXECUTE_WRITE_REQUEST
|
Opcode.ATT_EXECUTE_WRITE_REQUEST,
|
||||||
]
|
]
|
||||||
|
|
||||||
ATT_RESPONSES = [
|
ATT_RESPONSES = [
|
||||||
@@ -116,9 +128,10 @@ ATT_RESPONSES = [
|
|||||||
Opcode.ATT_READ_BLOB_RESPONSE,
|
Opcode.ATT_READ_BLOB_RESPONSE,
|
||||||
Opcode.ATT_READ_MULTIPLE_RESPONSE,
|
Opcode.ATT_READ_MULTIPLE_RESPONSE,
|
||||||
Opcode.ATT_READ_BY_GROUP_TYPE_RESPONSE,
|
Opcode.ATT_READ_BY_GROUP_TYPE_RESPONSE,
|
||||||
|
Opcode.ATT_READ_MULTIPLE_VARIABLE_RESPONSE,
|
||||||
Opcode.ATT_WRITE_RESPONSE,
|
Opcode.ATT_WRITE_RESPONSE,
|
||||||
Opcode.ATT_PREPARE_WRITE_RESPONSE,
|
Opcode.ATT_PREPARE_WRITE_RESPONSE,
|
||||||
Opcode.ATT_EXECUTE_WRITE_RESPONSE
|
Opcode.ATT_EXECUTE_WRITE_RESPONSE,
|
||||||
]
|
]
|
||||||
|
|
||||||
class ErrorCode(hci.SpecableEnum):
|
class ErrorCode(hci.SpecableEnum):
|
||||||
@@ -176,6 +189,18 @@ ATT_INSUFFICIENT_RESOURCES_ERROR = ErrorCode.INSUFFICIENT_RESOURCES
|
|||||||
ATT_DEFAULT_MTU = 23
|
ATT_DEFAULT_MTU = 23
|
||||||
|
|
||||||
HANDLE_FIELD_SPEC = {'size': 2, 'mapper': lambda x: f'0x{x:04X}'}
|
HANDLE_FIELD_SPEC = {'size': 2, 'mapper': lambda x: f'0x{x:04X}'}
|
||||||
|
_SET_OF_HANDLES_METADATA = hci.metadata({
|
||||||
|
'parser': lambda data, offset: (
|
||||||
|
len(data),
|
||||||
|
[
|
||||||
|
struct.unpack_from('<H', data, i)[0]
|
||||||
|
for i in range(offset, len(data), 2)
|
||||||
|
],
|
||||||
|
),
|
||||||
|
'serializer': lambda handles: b''.join(
|
||||||
|
[struct.pack('<H', handle) for handle in handles]
|
||||||
|
),
|
||||||
|
})
|
||||||
|
|
||||||
# fmt: on
|
# fmt: on
|
||||||
# pylint: enable=line-too-long
|
# pylint: enable=line-too-long
|
||||||
@@ -220,10 +245,12 @@ class ATT_PDU:
|
|||||||
fields: ClassVar[hci.Fields] = ()
|
fields: ClassVar[hci.Fields] = ()
|
||||||
op_code: int = dataclasses.field(init=False)
|
op_code: int = dataclasses.field(init=False)
|
||||||
name: str = dataclasses.field(init=False)
|
name: str = dataclasses.field(init=False)
|
||||||
_payload: Optional[bytes] = dataclasses.field(default=None, init=False)
|
_payload: bytes | None = dataclasses.field(default=None, init=False)
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def from_bytes(cls, pdu: bytes) -> ATT_PDU:
|
def from_bytes(cls, pdu: bytes) -> ATT_PDU:
|
||||||
|
if not pdu:
|
||||||
|
raise InvalidPacketError("Empty ATT PDU")
|
||||||
op_code = pdu[0]
|
op_code = pdu[0]
|
||||||
|
|
||||||
subclass = ATT_PDU.pdu_classes.get(op_code)
|
subclass = ATT_PDU.pdu_classes.get(op_code)
|
||||||
@@ -545,7 +572,7 @@ class ATT_Read_Multiple_Request(ATT_PDU):
|
|||||||
See Bluetooth spec @ Vol 3, Part F - 3.4.4.7 Read Multiple Request
|
See Bluetooth spec @ Vol 3, Part F - 3.4.4.7 Read Multiple Request
|
||||||
'''
|
'''
|
||||||
|
|
||||||
set_of_handles: bytes = dataclasses.field(metadata=hci.metadata("*"))
|
set_of_handles: Sequence[int] = dataclasses.field(metadata=_SET_OF_HANDLES_METADATA)
|
||||||
|
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
@@ -626,6 +653,55 @@ class ATT_Read_By_Group_Type_Response(ATT_PDU):
|
|||||||
return result
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
@ATT_PDU.subclass
|
||||||
|
@dataclasses.dataclass
|
||||||
|
class ATT_Read_Multiple_Variable_Request(ATT_PDU):
|
||||||
|
'''
|
||||||
|
See Bluetooth spec @ Vol 3, Part F - 3.4.4.11 Read Multiple Variable Request
|
||||||
|
'''
|
||||||
|
|
||||||
|
set_of_handles: Sequence[int] = dataclasses.field(metadata=_SET_OF_HANDLES_METADATA)
|
||||||
|
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
@ATT_PDU.subclass
|
||||||
|
@dataclasses.dataclass
|
||||||
|
class ATT_Read_Multiple_Variable_Response(ATT_PDU):
|
||||||
|
'''
|
||||||
|
See Bluetooth spec @ Vol 3, Part F - 3.4.4.12 Read Multiple Variable Response
|
||||||
|
'''
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def _parse_length_value_tuples(
|
||||||
|
cls, data: bytes, offset: int
|
||||||
|
) -> tuple[int, list[tuple[int, bytes]]]:
|
||||||
|
length_value_tuple_list: list[tuple[int, bytes]] = []
|
||||||
|
while offset < len(data):
|
||||||
|
length = struct.unpack_from('<H', data, offset)[0]
|
||||||
|
length_value_tuple_list.append(
|
||||||
|
(length, data[offset + 2 : offset + 2 + length])
|
||||||
|
)
|
||||||
|
offset += 2 + length
|
||||||
|
return (len(data), length_value_tuple_list)
|
||||||
|
|
||||||
|
length_value_tuple_list: Sequence[tuple[int, bytes]] = dataclasses.field(
|
||||||
|
metadata=hci.metadata(
|
||||||
|
{
|
||||||
|
'parser': lambda data, offset: ATT_Read_Multiple_Variable_Response._parse_length_value_tuples(
|
||||||
|
data, offset
|
||||||
|
),
|
||||||
|
'serializer': lambda length_value_tuple_list: b''.join(
|
||||||
|
[
|
||||||
|
struct.pack('<H', length) + value
|
||||||
|
for length, value in length_value_tuple_list
|
||||||
|
]
|
||||||
|
),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
@ATT_PDU.subclass
|
@ATT_PDU.subclass
|
||||||
@dataclasses.dataclass
|
@dataclasses.dataclass
|
||||||
@@ -760,31 +836,66 @@ class AttributeValue(Generic[_T]):
|
|||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
read: Union[
|
read: (
|
||||||
Callable[[Connection], _T],
|
Callable[[Connection], _T] | Callable[[Connection], Awaitable[_T]] | None
|
||||||
Callable[[Connection], Awaitable[_T]],
|
) = None,
|
||||||
None,
|
write: (
|
||||||
] = None,
|
Callable[[Connection, _T], None]
|
||||||
write: Union[
|
| Callable[[Connection, _T], Awaitable[None]]
|
||||||
Callable[[Connection, _T], None],
|
| None
|
||||||
Callable[[Connection, _T], Awaitable[None]],
|
) = None,
|
||||||
None,
|
|
||||||
] = None,
|
|
||||||
):
|
):
|
||||||
self._read = read
|
self._read = read
|
||||||
self._write = write
|
self._write = write
|
||||||
|
|
||||||
def read(self, connection: Connection) -> Union[_T, Awaitable[_T]]:
|
def read(self, connection: Connection) -> _T | Awaitable[_T]:
|
||||||
if self._read is None:
|
if self._read is None:
|
||||||
raise InvalidOperationError('AttributeValue has no read function')
|
raise InvalidOperationError('AttributeValue has no read function')
|
||||||
return self._read(connection)
|
return self._read(connection)
|
||||||
|
|
||||||
def write(self, connection: Connection, value: _T) -> Union[Awaitable[None], None]:
|
def write(self, connection: Connection, value: _T) -> Awaitable[None] | None:
|
||||||
if self._write is None:
|
if self._write is None:
|
||||||
raise InvalidOperationError('AttributeValue has no write function')
|
raise InvalidOperationError('AttributeValue has no write function')
|
||||||
return self._write(connection, value)
|
return self._write(connection, value)
|
||||||
|
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
class AttributeValueV2(Generic[_T]):
|
||||||
|
'''
|
||||||
|
Attribute value compatible with enhanced bearers.
|
||||||
|
|
||||||
|
The only difference between AttributeValue and AttributeValueV2 is that the actual
|
||||||
|
bearer (ACL connection for un-enhanced bearer, L2CAP channel for enhanced bearer)
|
||||||
|
will be passed into read and write callbacks in V2, while in V1 it is always
|
||||||
|
the base ACL connection.
|
||||||
|
|
||||||
|
This is only required when attributes must distinguish bearers, otherwise normal
|
||||||
|
`AttributeValue` objects are also applicable in enhanced bearers.
|
||||||
|
'''
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
read: Callable[[Bearer], Awaitable[_T]] | Callable[[Bearer], _T] | None = None,
|
||||||
|
write: (
|
||||||
|
Callable[[Bearer, _T], Awaitable[None]]
|
||||||
|
| Callable[[Bearer, _T], None]
|
||||||
|
| None
|
||||||
|
) = None,
|
||||||
|
):
|
||||||
|
self._read = read
|
||||||
|
self._write = write
|
||||||
|
|
||||||
|
def read(self, bearer: Bearer) -> _T | Awaitable[_T]:
|
||||||
|
if self._read is None:
|
||||||
|
raise InvalidOperationError('AttributeValue has no read function')
|
||||||
|
return self._read(bearer)
|
||||||
|
|
||||||
|
def write(self, bearer: Bearer, value: _T) -> Awaitable[None] | None:
|
||||||
|
if self._write is None:
|
||||||
|
raise InvalidOperationError('AttributeValue has no write function')
|
||||||
|
return self._write(bearer, value)
|
||||||
|
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
class Attribute(utils.EventEmitter, Generic[_T]):
|
class Attribute(utils.EventEmitter, Generic[_T]):
|
||||||
class Permissions(enum.IntFlag):
|
class Permissions(enum.IntFlag):
|
||||||
@@ -828,13 +939,13 @@ class Attribute(utils.EventEmitter, Generic[_T]):
|
|||||||
EVENT_READ = "read"
|
EVENT_READ = "read"
|
||||||
EVENT_WRITE = "write"
|
EVENT_WRITE = "write"
|
||||||
|
|
||||||
value: Union[AttributeValue[_T], _T, None]
|
value: AttributeValue[_T] | _T | None
|
||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
attribute_type: Union[str, bytes, UUID],
|
attribute_type: str | bytes | UUID,
|
||||||
permissions: Union[str, Attribute.Permissions],
|
permissions: str | Attribute.Permissions,
|
||||||
value: Union[AttributeValue[_T], _T, None] = None,
|
value: AttributeValue[_T] | _T | None = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
utils.EventEmitter.__init__(self)
|
utils.EventEmitter.__init__(self)
|
||||||
self.handle = 0
|
self.handle = 0
|
||||||
@@ -845,12 +956,13 @@ class Attribute(utils.EventEmitter, Generic[_T]):
|
|||||||
self.permissions = permissions
|
self.permissions = permissions
|
||||||
|
|
||||||
# Convert the type to a UUID object if it isn't already
|
# Convert the type to a UUID object if it isn't already
|
||||||
if isinstance(attribute_type, str):
|
match attribute_type:
|
||||||
self.type = UUID(attribute_type)
|
case str():
|
||||||
elif isinstance(attribute_type, bytes):
|
self.type = UUID(attribute_type)
|
||||||
self.type = UUID.from_bytes(attribute_type)
|
case bytes():
|
||||||
else:
|
self.type = UUID.from_bytes(attribute_type)
|
||||||
self.type = attribute_type
|
case _:
|
||||||
|
self.type = attribute_type
|
||||||
|
|
||||||
self.value = value
|
self.value = value
|
||||||
|
|
||||||
@@ -860,7 +972,8 @@ class Attribute(utils.EventEmitter, Generic[_T]):
|
|||||||
def decode_value(self, value: bytes) -> _T:
|
def decode_value(self, value: bytes) -> _T:
|
||||||
return value # type: ignore
|
return value # type: ignore
|
||||||
|
|
||||||
async def read_value(self, connection: Connection) -> bytes:
|
async def read_value(self, bearer: Bearer) -> bytes:
|
||||||
|
connection = bearer.connection if is_enhanced_bearer(bearer) else bearer
|
||||||
if (
|
if (
|
||||||
(self.permissions & self.READ_REQUIRES_ENCRYPTION)
|
(self.permissions & self.READ_REQUIRES_ENCRYPTION)
|
||||||
and connection is not None
|
and connection is not None
|
||||||
@@ -883,26 +996,39 @@ class Attribute(utils.EventEmitter, Generic[_T]):
|
|||||||
error_code=ATT_INSUFFICIENT_AUTHORIZATION_ERROR, att_handle=self.handle
|
error_code=ATT_INSUFFICIENT_AUTHORIZATION_ERROR, att_handle=self.handle
|
||||||
)
|
)
|
||||||
|
|
||||||
value: Union[_T, None]
|
value: _T | None
|
||||||
if isinstance(self.value, AttributeValue):
|
match self.value:
|
||||||
try:
|
case AttributeValue():
|
||||||
read_value = self.value.read(connection)
|
try:
|
||||||
if inspect.isawaitable(read_value):
|
read_value = self.value.read(connection)
|
||||||
value = await read_value
|
if inspect.isawaitable(read_value):
|
||||||
else:
|
value = await read_value
|
||||||
value = read_value
|
else:
|
||||||
except ATT_Error as error:
|
value = read_value
|
||||||
raise ATT_Error(
|
except ATT_Error as error:
|
||||||
error_code=error.error_code, att_handle=self.handle
|
raise ATT_Error(
|
||||||
) from error
|
error_code=error.error_code, att_handle=self.handle
|
||||||
else:
|
) from error
|
||||||
value = self.value
|
case AttributeValueV2():
|
||||||
|
try:
|
||||||
|
read_value = self.value.read(bearer)
|
||||||
|
if inspect.isawaitable(read_value):
|
||||||
|
value = await read_value
|
||||||
|
else:
|
||||||
|
value = read_value
|
||||||
|
except ATT_Error as error:
|
||||||
|
raise ATT_Error(
|
||||||
|
error_code=error.error_code, att_handle=self.handle
|
||||||
|
) from error
|
||||||
|
case _:
|
||||||
|
value = self.value
|
||||||
|
|
||||||
self.emit(self.EVENT_READ, connection, b'' if value is None else value)
|
self.emit(self.EVENT_READ, connection, b'' if value is None else value)
|
||||||
|
|
||||||
return b'' if value is None else self.encode_value(value)
|
return b'' if value is None else self.encode_value(value)
|
||||||
|
|
||||||
async def write_value(self, connection: Connection, value: bytes) -> None:
|
async def write_value(self, bearer: Bearer, value: bytes) -> None:
|
||||||
|
connection = bearer.connection if is_enhanced_bearer(bearer) else bearer
|
||||||
if (
|
if (
|
||||||
(self.permissions & self.WRITE_REQUIRES_ENCRYPTION)
|
(self.permissions & self.WRITE_REQUIRES_ENCRYPTION)
|
||||||
and connection is not None
|
and connection is not None
|
||||||
@@ -927,17 +1053,27 @@ class Attribute(utils.EventEmitter, Generic[_T]):
|
|||||||
|
|
||||||
decoded_value = self.decode_value(value)
|
decoded_value = self.decode_value(value)
|
||||||
|
|
||||||
if isinstance(self.value, AttributeValue):
|
match self.value:
|
||||||
try:
|
case AttributeValue():
|
||||||
result = self.value.write(connection, decoded_value)
|
try:
|
||||||
if inspect.isawaitable(result):
|
result = self.value.write(connection, decoded_value)
|
||||||
await result
|
if inspect.isawaitable(result):
|
||||||
except ATT_Error as error:
|
await result
|
||||||
raise ATT_Error(
|
except ATT_Error as error:
|
||||||
error_code=error.error_code, att_handle=self.handle
|
raise ATT_Error(
|
||||||
) from error
|
error_code=error.error_code, att_handle=self.handle
|
||||||
else:
|
) from error
|
||||||
self.value = decoded_value
|
case AttributeValueV2():
|
||||||
|
try:
|
||||||
|
result = self.value.write(bearer, decoded_value)
|
||||||
|
if inspect.isawaitable(result):
|
||||||
|
await result
|
||||||
|
except ATT_Error as error:
|
||||||
|
raise ATT_Error(
|
||||||
|
error_code=error.error_code, att_handle=self.handle
|
||||||
|
) from error
|
||||||
|
case _:
|
||||||
|
self.value = decoded_value
|
||||||
|
|
||||||
self.emit(self.EVENT_WRITE, connection, decoded_value)
|
self.emit(self.EVENT_WRITE, connection, decoded_value)
|
||||||
|
|
||||||
|
|||||||
@@ -19,14 +19,15 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import abc
|
import abc
|
||||||
import asyncio
|
import asyncio
|
||||||
|
import concurrent.futures
|
||||||
import dataclasses
|
import dataclasses
|
||||||
import enum
|
import enum
|
||||||
import logging
|
import logging
|
||||||
import pathlib
|
import pathlib
|
||||||
import sys
|
import sys
|
||||||
import wave
|
import wave
|
||||||
from concurrent.futures import ThreadPoolExecutor
|
from collections.abc import AsyncGenerator
|
||||||
from typing import TYPE_CHECKING, AsyncGenerator, BinaryIO
|
from typing import TYPE_CHECKING, BinaryIO
|
||||||
|
|
||||||
from bumble.colors import color
|
from bumble.colors import color
|
||||||
|
|
||||||
@@ -176,7 +177,7 @@ class ThreadedAudioOutput(AudioOutput):
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(self) -> None:
|
def __init__(self) -> None:
|
||||||
self._thread_pool = ThreadPoolExecutor(1)
|
self._thread_pool = concurrent.futures.ThreadPoolExecutor(1)
|
||||||
self._pcm_samples: asyncio.Queue[bytes] = asyncio.Queue()
|
self._pcm_samples: asyncio.Queue[bytes] = asyncio.Queue()
|
||||||
self._write_task = asyncio.create_task(self._write_loop())
|
self._write_task = asyncio.create_task(self._write_loop())
|
||||||
|
|
||||||
@@ -405,7 +406,7 @@ class ThreadedAudioInput(AudioInput):
|
|||||||
"""Base class for AudioInput implementation where reading samples may block."""
|
"""Base class for AudioInput implementation where reading samples may block."""
|
||||||
|
|
||||||
def __init__(self) -> None:
|
def __init__(self) -> None:
|
||||||
self._thread_pool = ThreadPoolExecutor(1)
|
self._thread_pool = concurrent.futures.ThreadPoolExecutor(1)
|
||||||
self._pcm_samples: asyncio.Queue[bytes] = asyncio.Queue()
|
self._pcm_samples: asyncio.Queue[bytes] = asyncio.Queue()
|
||||||
|
|
||||||
@abc.abstractmethod
|
@abc.abstractmethod
|
||||||
@@ -545,5 +546,6 @@ class SoundDeviceAudioInput(ThreadedAudioInput):
|
|||||||
return bytes(pcm_buffer)
|
return bytes(pcm_buffer)
|
||||||
|
|
||||||
def _close(self):
|
def _close(self):
|
||||||
self._stream.stop()
|
if self._stream:
|
||||||
self._stream = None
|
self._stream.stop()
|
||||||
|
self._stream = None
|
||||||
|
|||||||
@@ -19,7 +19,6 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import enum
|
import enum
|
||||||
import struct
|
import struct
|
||||||
from typing import Union
|
|
||||||
|
|
||||||
from bumble import core, utils
|
from bumble import core, utils
|
||||||
|
|
||||||
@@ -166,7 +165,7 @@ class Frame:
|
|||||||
|
|
||||||
def to_bytes(
|
def to_bytes(
|
||||||
self,
|
self,
|
||||||
ctype_or_response: Union[CommandFrame.CommandType, ResponseFrame.ResponseCode],
|
ctype_or_response: CommandFrame.CommandType | ResponseFrame.ResponseCode,
|
||||||
) -> bytes:
|
) -> bytes:
|
||||||
# TODO: support extended subunit types and ids.
|
# TODO: support extended subunit types and ids.
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -19,10 +19,10 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import logging
|
import logging
|
||||||
import struct
|
import struct
|
||||||
|
from collections.abc import Callable
|
||||||
from enum import IntEnum
|
from enum import IntEnum
|
||||||
from typing import Callable, Optional, cast
|
|
||||||
|
|
||||||
from bumble import avc, core, l2cap
|
from bumble import core, l2cap
|
||||||
from bumble.colors import color
|
from bumble.colors import color
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
@@ -144,9 +144,9 @@ class MessageAssembler:
|
|||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
class Protocol:
|
class Protocol:
|
||||||
CommandHandler = Callable[[int, avc.CommandFrame], None]
|
CommandHandler = Callable[[int, bytes], None]
|
||||||
command_handlers: dict[int, CommandHandler] # Command handlers, by PID
|
command_handlers: dict[int, CommandHandler] # Command handlers, by PID
|
||||||
ResponseHandler = Callable[[int, Optional[avc.ResponseFrame]], None]
|
ResponseHandler = Callable[[int, bytes | None], None]
|
||||||
response_handlers: dict[int, ResponseHandler] # Response handlers, by PID
|
response_handlers: dict[int, ResponseHandler] # Response handlers, by PID
|
||||||
next_transaction_label: int
|
next_transaction_label: int
|
||||||
message_assembler: MessageAssembler
|
message_assembler: MessageAssembler
|
||||||
@@ -204,20 +204,15 @@ class Protocol:
|
|||||||
self.send_ipid(transaction_label, pid)
|
self.send_ipid(transaction_label, pid)
|
||||||
return
|
return
|
||||||
|
|
||||||
command_frame = cast(avc.CommandFrame, avc.Frame.from_bytes(payload))
|
self.command_handlers[pid](transaction_label, payload)
|
||||||
self.command_handlers[pid](transaction_label, command_frame)
|
|
||||||
else:
|
else:
|
||||||
if pid not in self.response_handlers:
|
if pid not in self.response_handlers:
|
||||||
logger.warning(f"no response handler for PID {pid}")
|
logger.warning(f"no response handler for PID {pid}")
|
||||||
return
|
return
|
||||||
|
|
||||||
# By convention, for an ipid, send a None payload to the response handler.
|
# By convention, for an ipid, send a None payload to the response handler.
|
||||||
if ipid:
|
response_payload = None if ipid else payload
|
||||||
response_frame = None
|
self.response_handlers[pid](transaction_label, response_payload)
|
||||||
else:
|
|
||||||
response_frame = cast(avc.ResponseFrame, avc.Frame.from_bytes(payload))
|
|
||||||
|
|
||||||
self.response_handlers[pid](transaction_label, response_frame)
|
|
||||||
|
|
||||||
def send_message(
|
def send_message(
|
||||||
self,
|
self,
|
||||||
@@ -240,7 +235,7 @@ class Protocol:
|
|||||||
)
|
)
|
||||||
+ payload
|
+ payload
|
||||||
)
|
)
|
||||||
self.l2cap_channel.send_pdu(pdu)
|
self.l2cap_channel.write(pdu)
|
||||||
|
|
||||||
def send_command(self, transaction_label: int, pid: int, payload: bytes) -> None:
|
def send_command(self, transaction_label: int, pid: int, payload: bytes) -> None:
|
||||||
logger.debug(
|
logger.debug(
|
||||||
@@ -262,7 +257,7 @@ class Protocol:
|
|||||||
|
|
||||||
def send_ipid(self, transaction_label: int, pid: int) -> None:
|
def send_ipid(self, transaction_label: int, pid: int) -> None:
|
||||||
logger.debug(
|
logger.debug(
|
||||||
">>> AVCTP ipid: " f"transaction_label={transaction_label}, " f"pid={pid}"
|
f">>> AVCTP ipid: transaction_label={transaction_label}, pid={pid}"
|
||||||
)
|
)
|
||||||
self.send_message(transaction_label, False, True, pid, b'')
|
self.send_message(transaction_label, False, True, pid, b'')
|
||||||
|
|
||||||
|
|||||||
1179
bumble/avdtp.py
1179
bumble/avdtp.py
File diff suppressed because it is too large
Load Diff
1023
bumble/avrcp.py
1023
bumble/avrcp.py
File diff suppressed because it is too large
Load Diff
@@ -37,7 +37,12 @@ class HCI_Bridge:
|
|||||||
|
|
||||||
def on_packet(self, packet):
|
def on_packet(self, packet):
|
||||||
# Convert the packet bytes to an object
|
# Convert the packet bytes to an object
|
||||||
hci_packet = HCI_Packet.from_bytes(packet)
|
try:
|
||||||
|
hci_packet = HCI_Packet.from_bytes(packet)
|
||||||
|
except Exception:
|
||||||
|
logger.warning('forwarding unparsed packet as-is')
|
||||||
|
self.hci_sink.on_packet(packet)
|
||||||
|
return
|
||||||
|
|
||||||
# Filter the packet
|
# Filter the packet
|
||||||
if self.packet_filter is not None:
|
if self.packet_filter is not None:
|
||||||
@@ -50,7 +55,10 @@ class HCI_Bridge:
|
|||||||
return
|
return
|
||||||
|
|
||||||
# Analyze the packet
|
# Analyze the packet
|
||||||
self.trace(hci_packet)
|
try:
|
||||||
|
self.trace(hci_packet)
|
||||||
|
except Exception:
|
||||||
|
logger.exception('Exception while tracing packet')
|
||||||
|
|
||||||
# Bridge the packet
|
# Bridge the packet
|
||||||
self.hci_sink.on_packet(packet)
|
self.hci_sink.on_packet(packet)
|
||||||
|
|||||||
@@ -163,23 +163,23 @@ class AacAudioRtpPacket:
|
|||||||
cls, reader: BitReader, channel_configuration: int, audio_object_type: int
|
cls, reader: BitReader, channel_configuration: int, audio_object_type: int
|
||||||
) -> Self:
|
) -> Self:
|
||||||
# GASpecificConfig - ISO/EIC 14496-3 Table 4.1
|
# GASpecificConfig - ISO/EIC 14496-3 Table 4.1
|
||||||
frame_length_flag = reader.read(1)
|
reader.read(1) # frame_length_flag
|
||||||
depends_on_core_coder = reader.read(1)
|
depends_on_core_coder = reader.read(1)
|
||||||
if depends_on_core_coder:
|
if depends_on_core_coder:
|
||||||
core_coder_delay = reader.read(14)
|
reader.read(14) # core_coder_delay
|
||||||
extension_flag = reader.read(1)
|
extension_flag = reader.read(1)
|
||||||
if not channel_configuration:
|
if not channel_configuration:
|
||||||
raise core.InvalidPacketError('program_config_element not supported')
|
raise core.InvalidPacketError('program_config_element not supported')
|
||||||
if audio_object_type in (6, 20):
|
if audio_object_type in (6, 20):
|
||||||
layer_nr = reader.read(3)
|
reader.read(3) # layer_nr
|
||||||
if extension_flag:
|
if extension_flag:
|
||||||
if audio_object_type == 22:
|
if audio_object_type == 22:
|
||||||
num_of_sub_frame = reader.read(5)
|
reader.read(5) # num_of_sub_frame
|
||||||
layer_length = reader.read(11)
|
reader.read(11) # layer_length
|
||||||
if audio_object_type in (17, 19, 20, 23):
|
if audio_object_type in (17, 19, 20, 23):
|
||||||
aac_section_data_resilience_flags = reader.read(1)
|
reader.read(1) # aac_section_data_resilience_flags
|
||||||
aac_scale_factor_data_resilience_flags = reader.read(1)
|
reader.read(1) # aac_scale_factor_data_resilience_flags
|
||||||
aac_spectral_data_resilience_flags = reader.read(1)
|
reader.read(1) # aac_spectral_data_resilience_flags
|
||||||
extension_flag_3 = reader.read(1)
|
extension_flag_3 = reader.read(1)
|
||||||
if extension_flag_3 == 1:
|
if extension_flag_3 == 1:
|
||||||
raise core.InvalidPacketError('extensionFlag3 == 1 not supported')
|
raise core.InvalidPacketError('extensionFlag3 == 1 not supported')
|
||||||
@@ -364,10 +364,10 @@ class AacAudioRtpPacket:
|
|||||||
if audio_mux_version_a != 0:
|
if audio_mux_version_a != 0:
|
||||||
raise core.InvalidPacketError('audioMuxVersionA != 0 not supported')
|
raise core.InvalidPacketError('audioMuxVersionA != 0 not supported')
|
||||||
if audio_mux_version == 1:
|
if audio_mux_version == 1:
|
||||||
tara_buffer_fullness = AacAudioRtpPacket.read_latm_value(reader)
|
AacAudioRtpPacket.read_latm_value(reader) # tara_buffer_fullness
|
||||||
stream_cnt = 0
|
# stream_cnt = 0
|
||||||
all_streams_same_time_framing = reader.read(1)
|
reader.read(1) # all_streams_same_time_framing
|
||||||
num_sub_frames = reader.read(6)
|
reader.read(6) # num_sub_frames
|
||||||
num_program = reader.read(4)
|
num_program = reader.read(4)
|
||||||
if num_program != 0:
|
if num_program != 0:
|
||||||
raise core.InvalidPacketError('num_program != 0 not supported')
|
raise core.InvalidPacketError('num_program != 0 not supported')
|
||||||
@@ -391,9 +391,9 @@ class AacAudioRtpPacket:
|
|||||||
reader.skip(asc_len)
|
reader.skip(asc_len)
|
||||||
frame_length_type = reader.read(3)
|
frame_length_type = reader.read(3)
|
||||||
if frame_length_type == 0:
|
if frame_length_type == 0:
|
||||||
latm_buffer_fullness = reader.read(8)
|
reader.read(8) # latm_buffer_fullness
|
||||||
elif frame_length_type == 1:
|
elif frame_length_type == 1:
|
||||||
frame_length = reader.read(9)
|
reader.read(9) # frame_length
|
||||||
else:
|
else:
|
||||||
raise core.InvalidPacketError(
|
raise core.InvalidPacketError(
|
||||||
f'frame_length_type {frame_length_type} not supported'
|
f'frame_length_type {frame_length_type} not supported'
|
||||||
@@ -413,7 +413,7 @@ class AacAudioRtpPacket:
|
|||||||
break
|
break
|
||||||
crc_check_present = reader.read(1)
|
crc_check_present = reader.read(1)
|
||||||
if crc_check_present:
|
if crc_check_present:
|
||||||
crc_checksum = reader.read(8)
|
reader.read(8) # crc_checksum
|
||||||
|
|
||||||
return cls(other_data_present, other_data_len_bits, audio_specific_config)
|
return cls(other_data_present, other_data_len_bits, audio_specific_config)
|
||||||
|
|
||||||
|
|||||||
@@ -13,7 +13,6 @@
|
|||||||
# OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
|
# OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
|
||||||
|
|
||||||
from functools import partial
|
from functools import partial
|
||||||
from typing import Optional, Union
|
|
||||||
|
|
||||||
|
|
||||||
class ColorError(ValueError):
|
class ColorError(ValueError):
|
||||||
@@ -38,7 +37,7 @@ STYLES = (
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
ColorSpec = Union[str, int]
|
ColorSpec = str | int
|
||||||
|
|
||||||
|
|
||||||
def _join(*values: ColorSpec) -> str:
|
def _join(*values: ColorSpec) -> str:
|
||||||
@@ -56,14 +55,14 @@ def _color_code(spec: ColorSpec, base: int) -> str:
|
|||||||
elif isinstance(spec, int) and 0 <= spec <= 255:
|
elif isinstance(spec, int) and 0 <= spec <= 255:
|
||||||
return _join(base + 8, 5, spec)
|
return _join(base + 8, 5, spec)
|
||||||
else:
|
else:
|
||||||
raise ColorError('Invalid color spec "%s"' % spec)
|
raise ColorError(f'Invalid color spec "{spec}"')
|
||||||
|
|
||||||
|
|
||||||
def color(
|
def color(
|
||||||
s: str,
|
s: str,
|
||||||
fg: Optional[ColorSpec] = None,
|
fg: ColorSpec | None = None,
|
||||||
bg: Optional[ColorSpec] = None,
|
bg: ColorSpec | None = None,
|
||||||
style: Optional[str] = None,
|
style: str | None = None,
|
||||||
) -> str:
|
) -> str:
|
||||||
codes: list[ColorSpec] = []
|
codes: list[ColorSpec] = []
|
||||||
|
|
||||||
@@ -76,10 +75,10 @@ def color(
|
|||||||
if style_part in STYLES:
|
if style_part in STYLES:
|
||||||
codes.append(STYLES.index(style_part))
|
codes.append(STYLES.index(style_part))
|
||||||
else:
|
else:
|
||||||
raise ColorError('Invalid style "%s"' % style_part)
|
raise ColorError(f'Invalid style "{style_part}"')
|
||||||
|
|
||||||
if codes:
|
if codes:
|
||||||
return '\x1b[{0}m{1}\x1b[0m'.format(_join(*codes), s)
|
return f'\x1b[{_join(*codes)}m{s}\x1b[0m'
|
||||||
else:
|
else:
|
||||||
return s
|
return s
|
||||||
|
|
||||||
|
|||||||
2728
bumble/controller.py
2728
bumble/controller.py
File diff suppressed because it is too large
Load Diff
248
bumble/core.py
248
bumble/core.py
@@ -19,16 +19,13 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import dataclasses
|
import dataclasses
|
||||||
import enum
|
import enum
|
||||||
|
import functools
|
||||||
import struct
|
import struct
|
||||||
|
from collections.abc import Iterable
|
||||||
from typing import (
|
from typing import (
|
||||||
TYPE_CHECKING,
|
|
||||||
Any,
|
Any,
|
||||||
ClassVar,
|
ClassVar,
|
||||||
Iterable,
|
|
||||||
Literal,
|
Literal,
|
||||||
Optional,
|
|
||||||
Type,
|
|
||||||
Union,
|
|
||||||
cast,
|
cast,
|
||||||
overload,
|
overload,
|
||||||
)
|
)
|
||||||
@@ -103,7 +100,7 @@ class BaseError(BaseBumbleError):
|
|||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
error_code: Optional[int],
|
error_code: int | None,
|
||||||
error_namespace: str = '',
|
error_namespace: str = '',
|
||||||
error_name: str = '',
|
error_name: str = '',
|
||||||
details: str = '',
|
details: str = '',
|
||||||
@@ -216,11 +213,9 @@ class UUID:
|
|||||||
UUIDS: list[UUID] = [] # Registry of all instances created
|
UUIDS: list[UUID] = [] # Registry of all instances created
|
||||||
|
|
||||||
uuid_bytes: bytes
|
uuid_bytes: bytes
|
||||||
name: Optional[str]
|
name: str | None
|
||||||
|
|
||||||
def __init__(
|
def __init__(self, uuid_str_or_int: str | int, name: str | None = None) -> None:
|
||||||
self, uuid_str_or_int: Union[str, int], name: Optional[str] = None
|
|
||||||
) -> None:
|
|
||||||
if isinstance(uuid_str_or_int, int):
|
if isinstance(uuid_str_or_int, int):
|
||||||
self.uuid_bytes = struct.pack('<H', uuid_str_or_int)
|
self.uuid_bytes = struct.pack('<H', uuid_str_or_int)
|
||||||
else:
|
else:
|
||||||
@@ -253,7 +248,7 @@ class UUID:
|
|||||||
return self
|
return self
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def from_bytes(cls, uuid_bytes: bytes, name: Optional[str] = None) -> UUID:
|
def from_bytes(cls, uuid_bytes: bytes, name: str | None = None) -> UUID:
|
||||||
if len(uuid_bytes) in (2, 4, 16):
|
if len(uuid_bytes) in (2, 4, 16):
|
||||||
self = cls.__new__(cls)
|
self = cls.__new__(cls)
|
||||||
self.uuid_bytes = uuid_bytes
|
self.uuid_bytes = uuid_bytes
|
||||||
@@ -264,11 +259,11 @@ class UUID:
|
|||||||
raise InvalidArgumentError('only 2, 4 and 16 bytes are allowed')
|
raise InvalidArgumentError('only 2, 4 and 16 bytes are allowed')
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def from_16_bits(cls, uuid_16: int, name: Optional[str] = None) -> UUID:
|
def from_16_bits(cls, uuid_16: int, name: str | None = None) -> UUID:
|
||||||
return cls.from_bytes(struct.pack('<H', uuid_16), name)
|
return cls.from_bytes(struct.pack('<H', uuid_16), name)
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def from_32_bits(cls, uuid_32: int, name: Optional[str] = None) -> UUID:
|
def from_32_bits(cls, uuid_32: int, name: str | None = None) -> UUID:
|
||||||
return cls.from_bytes(struct.pack('<I', uuid_32), name)
|
return cls.from_bytes(struct.pack('<I', uuid_32), name)
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
@@ -279,6 +274,18 @@ class UUID:
|
|||||||
def parse_uuid_2(cls, uuid_as_bytes: bytes, offset: int) -> tuple[int, UUID]:
|
def parse_uuid_2(cls, uuid_as_bytes: bytes, offset: int) -> tuple[int, UUID]:
|
||||||
return offset + 2, cls.from_bytes(uuid_as_bytes[offset : offset + 2])
|
return offset + 2, cls.from_bytes(uuid_as_bytes[offset : offset + 2])
|
||||||
|
|
||||||
|
@functools.cached_property
|
||||||
|
def uuid_128_bytes(self) -> bytes:
|
||||||
|
match len(self.uuid_bytes):
|
||||||
|
case 2:
|
||||||
|
return self.BASE_UUID + self.uuid_bytes + bytes([0, 0])
|
||||||
|
case 4:
|
||||||
|
return self.BASE_UUID + self.uuid_bytes
|
||||||
|
case 16:
|
||||||
|
return self.uuid_bytes
|
||||||
|
case _:
|
||||||
|
assert False, "unreachable"
|
||||||
|
|
||||||
def to_bytes(self, force_128: bool = False) -> bytes:
|
def to_bytes(self, force_128: bool = False) -> bytes:
|
||||||
'''
|
'''
|
||||||
Serialize UUID in little-endian byte-order
|
Serialize UUID in little-endian byte-order
|
||||||
@@ -286,14 +293,7 @@ class UUID:
|
|||||||
if not force_128:
|
if not force_128:
|
||||||
return self.uuid_bytes
|
return self.uuid_bytes
|
||||||
|
|
||||||
if len(self.uuid_bytes) == 2:
|
return self.uuid_128_bytes
|
||||||
return self.BASE_UUID + self.uuid_bytes + bytes([0, 0])
|
|
||||||
elif len(self.uuid_bytes) == 4:
|
|
||||||
return self.BASE_UUID + self.uuid_bytes
|
|
||||||
elif len(self.uuid_bytes) == 16:
|
|
||||||
return self.uuid_bytes
|
|
||||||
else:
|
|
||||||
assert False, "unreachable"
|
|
||||||
|
|
||||||
def to_pdu_bytes(self) -> bytes:
|
def to_pdu_bytes(self) -> bytes:
|
||||||
'''
|
'''
|
||||||
@@ -323,7 +323,7 @@ class UUID:
|
|||||||
|
|
||||||
def __eq__(self, other: object) -> bool:
|
def __eq__(self, other: object) -> bool:
|
||||||
if isinstance(other, UUID):
|
if isinstance(other, UUID):
|
||||||
return self.to_bytes(force_128=True) == other.to_bytes(force_128=True)
|
return self.uuid_128_bytes == other.uuid_128_bytes
|
||||||
|
|
||||||
if isinstance(other, str):
|
if isinstance(other, str):
|
||||||
return UUID(other) == self
|
return UUID(other) == self
|
||||||
@@ -331,7 +331,7 @@ class UUID:
|
|||||||
return False
|
return False
|
||||||
|
|
||||||
def __hash__(self) -> int:
|
def __hash__(self) -> int:
|
||||||
return hash(self.uuid_bytes)
|
return hash(self.uuid_128_bytes)
|
||||||
|
|
||||||
def __str__(self) -> str:
|
def __str__(self) -> str:
|
||||||
result = self.to_hex_str(separator='-')
|
result = self.to_hex_str(separator='-')
|
||||||
@@ -734,7 +734,7 @@ class ClassOfDevice:
|
|||||||
MajorDeviceClass.HEALTH: HEALTH_MINOR_DEVICE_CLASS_LABELS,
|
MajorDeviceClass.HEALTH: HEALTH_MINOR_DEVICE_CLASS_LABELS,
|
||||||
}
|
}
|
||||||
|
|
||||||
_MINOR_DEVICE_CLASSES: ClassVar[dict[MajorDeviceClass, Type]] = {
|
_MINOR_DEVICE_CLASSES: ClassVar[dict[MajorDeviceClass, type]] = {
|
||||||
MajorDeviceClass.COMPUTER: ComputerMinorDeviceClass,
|
MajorDeviceClass.COMPUTER: ComputerMinorDeviceClass,
|
||||||
MajorDeviceClass.PHONE: PhoneMinorDeviceClass,
|
MajorDeviceClass.PHONE: PhoneMinorDeviceClass,
|
||||||
MajorDeviceClass.LAN_NETWORK_ACCESS_POINT: LanNetworkMinorDeviceClass,
|
MajorDeviceClass.LAN_NETWORK_ACCESS_POINT: LanNetworkMinorDeviceClass,
|
||||||
@@ -749,17 +749,17 @@ class ClassOfDevice:
|
|||||||
|
|
||||||
major_service_classes: MajorServiceClasses
|
major_service_classes: MajorServiceClasses
|
||||||
major_device_class: MajorDeviceClass
|
major_device_class: MajorDeviceClass
|
||||||
minor_device_class: Union[
|
minor_device_class: (
|
||||||
ComputerMinorDeviceClass,
|
ComputerMinorDeviceClass
|
||||||
PhoneMinorDeviceClass,
|
| PhoneMinorDeviceClass
|
||||||
LanNetworkMinorDeviceClass,
|
| LanNetworkMinorDeviceClass
|
||||||
AudioVideoMinorDeviceClass,
|
| AudioVideoMinorDeviceClass
|
||||||
PeripheralMinorDeviceClass,
|
| PeripheralMinorDeviceClass
|
||||||
WearableMinorDeviceClass,
|
| WearableMinorDeviceClass
|
||||||
ToyMinorDeviceClass,
|
| ToyMinorDeviceClass
|
||||||
HealthMinorDeviceClass,
|
| HealthMinorDeviceClass
|
||||||
int,
|
| int
|
||||||
]
|
)
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def from_int(cls, class_of_device: int) -> Self:
|
def from_int(cls, class_of_device: int) -> Self:
|
||||||
@@ -929,7 +929,7 @@ class DeviceClass:
|
|||||||
# pylint: enable=line-too-long
|
# pylint: enable=line-too-long
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def split_class_of_device(class_of_device):
|
def split_class_of_device(class_of_device: int) -> tuple[int, int, int]:
|
||||||
# Split the bit fields of the composite class of device value into:
|
# Split the bit fields of the composite class of device value into:
|
||||||
# (service_classes, major_device_class, minor_device_class)
|
# (service_classes, major_device_class, minor_device_class)
|
||||||
return (
|
return (
|
||||||
@@ -1548,7 +1548,7 @@ class DataType:
|
|||||||
return f"{self.__class__.__name__}({self.value_string()})"
|
return f"{self.__class__.__name__}({self.value_string()})"
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def from_advertising_data(cls, advertising_data: AdvertisingData) -> Optional[Self]:
|
def from_advertising_data(cls, advertising_data: AdvertisingData) -> Self | None:
|
||||||
if (data := advertising_data.get(cls.ad_type, raw=True)) is None:
|
if (data := advertising_data.get(cls.ad_type, raw=True)) is None:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
@@ -1576,16 +1576,16 @@ class DataType:
|
|||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
# Advertising Data
|
# Advertising Data
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
AdvertisingDataObject = Union[
|
AdvertisingDataObject = (
|
||||||
list[UUID],
|
list[UUID]
|
||||||
tuple[UUID, bytes],
|
| tuple[UUID, bytes]
|
||||||
bytes,
|
| bytes
|
||||||
str,
|
| str
|
||||||
int,
|
| int
|
||||||
tuple[int, int],
|
| tuple[int, int]
|
||||||
tuple[int, bytes],
|
| tuple[int, bytes]
|
||||||
Appearance,
|
| Appearance
|
||||||
]
|
)
|
||||||
|
|
||||||
|
|
||||||
class AdvertisingData:
|
class AdvertisingData:
|
||||||
@@ -1722,7 +1722,7 @@ class AdvertisingData:
|
|||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
ad_structures: Optional[Iterable[Union[tuple[int, bytes], DataType]]] = None,
|
ad_structures: Iterable[tuple[int, bytes] | DataType] | None = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
if ad_structures is None:
|
if ad_structures is None:
|
||||||
ad_structures = []
|
ad_structures = []
|
||||||
@@ -1775,66 +1775,71 @@ class AdvertisingData:
|
|||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def ad_data_to_string(cls, ad_type: int, ad_data: bytes) -> str:
|
def ad_data_to_string(cls, ad_type: int, ad_data: bytes) -> str:
|
||||||
if ad_type == AdvertisingData.FLAGS:
|
match ad_type:
|
||||||
ad_type_str = 'Flags'
|
case AdvertisingData.FLAGS:
|
||||||
ad_data_str = AdvertisingData.flags_to_string(ad_data[0], short=True)
|
ad_type_str = 'Flags'
|
||||||
elif ad_type == AdvertisingData.COMPLETE_LIST_OF_16_BIT_SERVICE_CLASS_UUIDS:
|
ad_data_str = AdvertisingData.flags_to_string(ad_data[0], short=True)
|
||||||
ad_type_str = 'Complete List of 16-bit Service Class UUIDs'
|
case AdvertisingData.COMPLETE_LIST_OF_16_BIT_SERVICE_CLASS_UUIDS:
|
||||||
ad_data_str = AdvertisingData.uuid_list_to_string(ad_data, 2)
|
ad_type_str = 'Complete List of 16-bit Service Class UUIDs'
|
||||||
elif ad_type == AdvertisingData.INCOMPLETE_LIST_OF_16_BIT_SERVICE_CLASS_UUIDS:
|
ad_data_str = AdvertisingData.uuid_list_to_string(ad_data, 2)
|
||||||
ad_type_str = 'Incomplete List of 16-bit Service Class UUIDs'
|
case AdvertisingData.INCOMPLETE_LIST_OF_16_BIT_SERVICE_CLASS_UUIDS:
|
||||||
ad_data_str = AdvertisingData.uuid_list_to_string(ad_data, 2)
|
ad_type_str = 'Incomplete List of 16-bit Service Class UUIDs'
|
||||||
elif ad_type == AdvertisingData.COMPLETE_LIST_OF_32_BIT_SERVICE_CLASS_UUIDS:
|
ad_data_str = AdvertisingData.uuid_list_to_string(ad_data, 2)
|
||||||
ad_type_str = 'Complete List of 32-bit Service Class UUIDs'
|
case AdvertisingData.COMPLETE_LIST_OF_32_BIT_SERVICE_CLASS_UUIDS:
|
||||||
ad_data_str = AdvertisingData.uuid_list_to_string(ad_data, 4)
|
ad_type_str = 'Complete List of 32-bit Service Class UUIDs'
|
||||||
elif ad_type == AdvertisingData.INCOMPLETE_LIST_OF_32_BIT_SERVICE_CLASS_UUIDS:
|
ad_data_str = AdvertisingData.uuid_list_to_string(ad_data, 4)
|
||||||
ad_type_str = 'Incomplete List of 32-bit Service Class UUIDs'
|
case AdvertisingData.INCOMPLETE_LIST_OF_32_BIT_SERVICE_CLASS_UUIDS:
|
||||||
ad_data_str = AdvertisingData.uuid_list_to_string(ad_data, 4)
|
ad_type_str = 'Incomplete List of 32-bit Service Class UUIDs'
|
||||||
elif ad_type == AdvertisingData.COMPLETE_LIST_OF_128_BIT_SERVICE_CLASS_UUIDS:
|
ad_data_str = AdvertisingData.uuid_list_to_string(ad_data, 4)
|
||||||
ad_type_str = 'Complete List of 128-bit Service Class UUIDs'
|
case AdvertisingData.COMPLETE_LIST_OF_128_BIT_SERVICE_CLASS_UUIDS:
|
||||||
ad_data_str = AdvertisingData.uuid_list_to_string(ad_data, 16)
|
ad_type_str = 'Complete List of 128-bit Service Class UUIDs'
|
||||||
elif ad_type == AdvertisingData.INCOMPLETE_LIST_OF_128_BIT_SERVICE_CLASS_UUIDS:
|
ad_data_str = AdvertisingData.uuid_list_to_string(ad_data, 16)
|
||||||
ad_type_str = 'Incomplete List of 128-bit Service Class UUIDs'
|
case AdvertisingData.INCOMPLETE_LIST_OF_128_BIT_SERVICE_CLASS_UUIDS:
|
||||||
ad_data_str = AdvertisingData.uuid_list_to_string(ad_data, 16)
|
ad_type_str = 'Incomplete List of 128-bit Service Class UUIDs'
|
||||||
elif ad_type == AdvertisingData.SERVICE_DATA_16_BIT_UUID:
|
ad_data_str = AdvertisingData.uuid_list_to_string(ad_data, 16)
|
||||||
ad_type_str = 'Service Data'
|
case AdvertisingData.SERVICE_DATA_16_BIT_UUID:
|
||||||
uuid = UUID.from_bytes(ad_data[:2])
|
ad_type_str = 'Service Data'
|
||||||
ad_data_str = f'service={uuid}, data={ad_data[2:].hex()}'
|
uuid = UUID.from_bytes(ad_data[:2])
|
||||||
elif ad_type == AdvertisingData.SERVICE_DATA_32_BIT_UUID:
|
ad_data_str = f'service={uuid}, data={ad_data[2:].hex()}'
|
||||||
ad_type_str = 'Service Data'
|
case AdvertisingData.SERVICE_DATA_32_BIT_UUID:
|
||||||
uuid = UUID.from_bytes(ad_data[:4])
|
ad_type_str = 'Service Data'
|
||||||
ad_data_str = f'service={uuid}, data={ad_data[4:].hex()}'
|
uuid = UUID.from_bytes(ad_data[:4])
|
||||||
elif ad_type == AdvertisingData.SERVICE_DATA_128_BIT_UUID:
|
ad_data_str = f'service={uuid}, data={ad_data[4:].hex()}'
|
||||||
ad_type_str = 'Service Data'
|
case AdvertisingData.SERVICE_DATA_128_BIT_UUID:
|
||||||
uuid = UUID.from_bytes(ad_data[:16])
|
ad_type_str = 'Service Data'
|
||||||
ad_data_str = f'service={uuid}, data={ad_data[16:].hex()}'
|
uuid = UUID.from_bytes(ad_data[:16])
|
||||||
elif ad_type == AdvertisingData.SHORTENED_LOCAL_NAME:
|
ad_data_str = f'service={uuid}, data={ad_data[16:].hex()}'
|
||||||
ad_type_str = 'Shortened Local Name'
|
case AdvertisingData.SHORTENED_LOCAL_NAME:
|
||||||
ad_data_str = f'"{ad_data.decode("utf-8")}"'
|
ad_type_str = 'Shortened Local Name'
|
||||||
elif ad_type == AdvertisingData.COMPLETE_LOCAL_NAME:
|
|
||||||
ad_type_str = 'Complete Local Name'
|
|
||||||
try:
|
|
||||||
ad_data_str = f'"{ad_data.decode("utf-8")}"'
|
ad_data_str = f'"{ad_data.decode("utf-8")}"'
|
||||||
except UnicodeDecodeError:
|
case AdvertisingData.COMPLETE_LOCAL_NAME:
|
||||||
|
ad_type_str = 'Complete Local Name'
|
||||||
|
try:
|
||||||
|
ad_data_str = f'"{ad_data.decode("utf-8")}"'
|
||||||
|
except UnicodeDecodeError:
|
||||||
|
ad_data_str = ad_data.hex()
|
||||||
|
case AdvertisingData.TX_POWER_LEVEL:
|
||||||
|
ad_type_str = 'TX Power Level'
|
||||||
|
ad_data_str = str(ad_data[0])
|
||||||
|
case AdvertisingData.MANUFACTURER_SPECIFIC_DATA:
|
||||||
|
ad_type_str = 'Manufacturer Specific Data'
|
||||||
|
company_id = struct.unpack_from('<H', ad_data, 0)[0]
|
||||||
|
company_name = COMPANY_IDENTIFIERS.get(
|
||||||
|
company_id, f'0x{company_id:04X}'
|
||||||
|
)
|
||||||
|
ad_data_str = f'company={company_name}, data={ad_data[2:].hex()}'
|
||||||
|
case AdvertisingData.APPEARANCE:
|
||||||
|
ad_type_str = 'Appearance'
|
||||||
|
appearance = Appearance.from_int(
|
||||||
|
struct.unpack_from('<H', ad_data, 0)[0]
|
||||||
|
)
|
||||||
|
ad_data_str = str(appearance)
|
||||||
|
case AdvertisingData.BROADCAST_NAME:
|
||||||
|
ad_type_str = 'Broadcast Name'
|
||||||
|
ad_data_str = ad_data.decode('utf-8')
|
||||||
|
case _:
|
||||||
|
ad_type_str = AdvertisingData.Type(ad_type).name
|
||||||
ad_data_str = ad_data.hex()
|
ad_data_str = ad_data.hex()
|
||||||
elif ad_type == AdvertisingData.TX_POWER_LEVEL:
|
|
||||||
ad_type_str = 'TX Power Level'
|
|
||||||
ad_data_str = str(ad_data[0])
|
|
||||||
elif ad_type == AdvertisingData.MANUFACTURER_SPECIFIC_DATA:
|
|
||||||
ad_type_str = 'Manufacturer Specific Data'
|
|
||||||
company_id = struct.unpack_from('<H', ad_data, 0)[0]
|
|
||||||
company_name = COMPANY_IDENTIFIERS.get(company_id, f'0x{company_id:04X}')
|
|
||||||
ad_data_str = f'company={company_name}, data={ad_data[2:].hex()}'
|
|
||||||
elif ad_type == AdvertisingData.APPEARANCE:
|
|
||||||
ad_type_str = 'Appearance'
|
|
||||||
appearance = Appearance.from_int(struct.unpack_from('<H', ad_data, 0)[0])
|
|
||||||
ad_data_str = str(appearance)
|
|
||||||
elif ad_type == AdvertisingData.BROADCAST_NAME:
|
|
||||||
ad_type_str = 'Broadcast Name'
|
|
||||||
ad_data_str = ad_data.decode('utf-8')
|
|
||||||
else:
|
|
||||||
ad_type_str = AdvertisingData.Type(ad_type).name
|
|
||||||
ad_data_str = ad_data.hex()
|
|
||||||
|
|
||||||
return f'[{ad_type_str}]: {ad_data_str}'
|
return f'[{ad_type_str}]: {ad_data_str}'
|
||||||
|
|
||||||
@@ -2020,7 +2025,7 @@ class AdvertisingData:
|
|||||||
AdvertisingData.Type.LIST_OF_128_BIT_SERVICE_SOLICITATION_UUIDS,
|
AdvertisingData.Type.LIST_OF_128_BIT_SERVICE_SOLICITATION_UUIDS,
|
||||||
],
|
],
|
||||||
raw: Literal[False] = False,
|
raw: Literal[False] = False,
|
||||||
) -> Optional[list[UUID]]: ...
|
) -> list[UUID] | None: ...
|
||||||
|
|
||||||
@overload
|
@overload
|
||||||
def get(
|
def get(
|
||||||
@@ -2031,7 +2036,7 @@ class AdvertisingData:
|
|||||||
AdvertisingData.Type.SERVICE_DATA_128_BIT_UUID,
|
AdvertisingData.Type.SERVICE_DATA_128_BIT_UUID,
|
||||||
],
|
],
|
||||||
raw: Literal[False] = False,
|
raw: Literal[False] = False,
|
||||||
) -> Optional[tuple[UUID, bytes]]: ...
|
) -> tuple[UUID, bytes] | None: ...
|
||||||
|
|
||||||
@overload
|
@overload
|
||||||
def get(
|
def get(
|
||||||
@@ -2043,7 +2048,7 @@ class AdvertisingData:
|
|||||||
AdvertisingData.Type.BROADCAST_NAME,
|
AdvertisingData.Type.BROADCAST_NAME,
|
||||||
],
|
],
|
||||||
raw: Literal[False] = False,
|
raw: Literal[False] = False,
|
||||||
) -> Optional[Optional[str]]: ...
|
) -> str | None: ...
|
||||||
|
|
||||||
@overload
|
@overload
|
||||||
def get(
|
def get(
|
||||||
@@ -2055,38 +2060,36 @@ class AdvertisingData:
|
|||||||
AdvertisingData.Type.CLASS_OF_DEVICE,
|
AdvertisingData.Type.CLASS_OF_DEVICE,
|
||||||
],
|
],
|
||||||
raw: Literal[False] = False,
|
raw: Literal[False] = False,
|
||||||
) -> Optional[int]: ...
|
) -> int | None: ...
|
||||||
|
|
||||||
@overload
|
@overload
|
||||||
def get(
|
def get(
|
||||||
self,
|
self,
|
||||||
type_id: Literal[AdvertisingData.Type.PERIPHERAL_CONNECTION_INTERVAL_RANGE,],
|
type_id: Literal[AdvertisingData.Type.PERIPHERAL_CONNECTION_INTERVAL_RANGE,],
|
||||||
raw: Literal[False] = False,
|
raw: Literal[False] = False,
|
||||||
) -> Optional[tuple[int, int]]: ...
|
) -> tuple[int, int] | None: ...
|
||||||
|
|
||||||
@overload
|
@overload
|
||||||
def get(
|
def get(
|
||||||
self,
|
self,
|
||||||
type_id: Literal[AdvertisingData.Type.MANUFACTURER_SPECIFIC_DATA,],
|
type_id: Literal[AdvertisingData.Type.MANUFACTURER_SPECIFIC_DATA,],
|
||||||
raw: Literal[False] = False,
|
raw: Literal[False] = False,
|
||||||
) -> Optional[tuple[int, bytes]]: ...
|
) -> tuple[int, bytes] | None: ...
|
||||||
|
|
||||||
@overload
|
@overload
|
||||||
def get(
|
def get(
|
||||||
self,
|
self,
|
||||||
type_id: Literal[AdvertisingData.Type.APPEARANCE,],
|
type_id: Literal[AdvertisingData.Type.APPEARANCE,],
|
||||||
raw: Literal[False] = False,
|
raw: Literal[False] = False,
|
||||||
) -> Optional[Appearance]: ...
|
) -> Appearance | None: ...
|
||||||
|
|
||||||
@overload
|
@overload
|
||||||
def get(self, type_id: int, raw: Literal[True]) -> Optional[bytes]: ...
|
def get(self, type_id: int, raw: Literal[True]) -> bytes | None: ...
|
||||||
|
|
||||||
@overload
|
@overload
|
||||||
def get(
|
def get(self, type_id: int, raw: bool = False) -> AdvertisingDataObject | None: ...
|
||||||
self, type_id: int, raw: bool = False
|
|
||||||
) -> Optional[AdvertisingDataObject]: ...
|
|
||||||
|
|
||||||
def get(self, type_id: int, raw: bool = False) -> Optional[AdvertisingDataObject]:
|
def get(self, type_id: int, raw: bool = False) -> AdvertisingDataObject | None:
|
||||||
'''
|
'''
|
||||||
Get advertising data as a simple AdvertisingDataObject object.
|
Get advertising data as a simple AdvertisingDataObject object.
|
||||||
|
|
||||||
@@ -2113,13 +2116,10 @@ class AdvertisingData:
|
|||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
# Connection PHY
|
# Connection PHY
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
|
@dataclasses.dataclass
|
||||||
class ConnectionPHY:
|
class ConnectionPHY:
|
||||||
def __init__(self, tx_phy, rx_phy):
|
tx_phy: int
|
||||||
self.tx_phy = tx_phy
|
rx_phy: int
|
||||||
self.rx_phy = rx_phy
|
|
||||||
|
|
||||||
def __str__(self):
|
|
||||||
return f'ConnectionPHY(tx_phy={self.tx_phy}, rx_phy={self.rx_phy})'
|
|
||||||
|
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -25,10 +25,11 @@ try:
|
|||||||
from bumble.crypto.cryptography import EccKey, aes_cmac, e
|
from bumble.crypto.cryptography import EccKey, aes_cmac, e
|
||||||
except ImportError:
|
except ImportError:
|
||||||
logging.getLogger(__name__).debug(
|
logging.getLogger(__name__).debug(
|
||||||
"Unable to import cryptography, use built-in primitives."
|
"Unable to import cryptography, using built-in primitives."
|
||||||
)
|
)
|
||||||
from bumble.crypto.builtin import EccKey, aes_cmac, e # type: ignore[assignment]
|
from bumble.crypto.builtin import EccKey, aes_cmac, e # type: ignore[assignment]
|
||||||
|
|
||||||
|
_EccKey = EccKey # For the linter only
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
# Logging
|
# Logging
|
||||||
|
|||||||
@@ -29,7 +29,6 @@ import dataclasses
|
|||||||
import functools
|
import functools
|
||||||
import secrets
|
import secrets
|
||||||
import struct
|
import struct
|
||||||
from typing import Optional
|
|
||||||
|
|
||||||
from bumble import core
|
from bumble import core
|
||||||
|
|
||||||
@@ -85,7 +84,6 @@ class _AES:
|
|||||||
# fmt: on
|
# fmt: on
|
||||||
|
|
||||||
def __init__(self, key: bytes) -> None:
|
def __init__(self, key: bytes) -> None:
|
||||||
|
|
||||||
if len(key) not in (16, 24, 32):
|
if len(key) not in (16, 24, 32):
|
||||||
raise core.InvalidArgumentError(f'Invalid key size {len(key)}')
|
raise core.InvalidArgumentError(f'Invalid key size {len(key)}')
|
||||||
|
|
||||||
@@ -112,7 +110,6 @@ class _AES:
|
|||||||
r_con_pointer = 0
|
r_con_pointer = 0
|
||||||
t = kc
|
t = kc
|
||||||
while t < round_key_count:
|
while t < round_key_count:
|
||||||
|
|
||||||
tt = tk[kc - 1]
|
tt = tk[kc - 1]
|
||||||
tk[0] ^= (
|
tk[0] ^= (
|
||||||
(self._S[(tt >> 16) & 0xFF] << 24)
|
(self._S[(tt >> 16) & 0xFF] << 24)
|
||||||
@@ -269,7 +266,6 @@ class _ECB:
|
|||||||
|
|
||||||
|
|
||||||
class _CBC:
|
class _CBC:
|
||||||
|
|
||||||
def __init__(self, key: bytes, iv: bytes = bytes(16)) -> None:
|
def __init__(self, key: bytes, iv: bytes = bytes(16)) -> None:
|
||||||
if len(iv) != 16:
|
if len(iv) != 16:
|
||||||
raise core.InvalidArgumentError(
|
raise core.InvalidArgumentError(
|
||||||
@@ -302,7 +298,6 @@ class _CBC:
|
|||||||
|
|
||||||
|
|
||||||
class _CMAC:
|
class _CMAC:
|
||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
key: bytes,
|
key: bytes,
|
||||||
@@ -313,7 +308,7 @@ class _CMAC:
|
|||||||
self.digest_size = mac_len
|
self.digest_size = mac_len
|
||||||
self._key = key
|
self._key = key
|
||||||
self._block_size = bs = 16
|
self._block_size = bs = 16
|
||||||
self._mac_tag: Optional[bytes] = None
|
self._mac_tag: bytes | None = None
|
||||||
self._update_after_digest = update_after_digest
|
self._update_after_digest = update_after_digest
|
||||||
|
|
||||||
# Section 5.3 of NIST SP 800 38B and Appendix B
|
# Section 5.3 of NIST SP 800 38B and Appendix B
|
||||||
@@ -352,7 +347,7 @@ class _CMAC:
|
|||||||
self._last_ct = zero_block
|
self._last_ct = zero_block
|
||||||
|
|
||||||
# Last block that was encrypted with AES
|
# Last block that was encrypted with AES
|
||||||
self._last_pt: Optional[bytes] = None
|
self._last_pt: bytes | None = None
|
||||||
|
|
||||||
# Counter for total message size
|
# Counter for total message size
|
||||||
self._data_size = 0
|
self._data_size = 0
|
||||||
@@ -414,7 +409,6 @@ class _CMAC:
|
|||||||
self._last_pt = _xor(second_last, data_block[-bs:])
|
self._last_pt = _xor(second_last, data_block[-bs:])
|
||||||
|
|
||||||
def digest(self) -> bytes:
|
def digest(self) -> bytes:
|
||||||
|
|
||||||
bs = self._block_size
|
bs = self._block_size
|
||||||
|
|
||||||
if self._mac_tag is not None and not self._update_after_digest:
|
if self._mac_tag is not None and not self._update_after_digest:
|
||||||
|
|||||||
@@ -25,7 +25,8 @@ from __future__ import annotations
|
|||||||
import dataclasses
|
import dataclasses
|
||||||
import math
|
import math
|
||||||
import struct
|
import struct
|
||||||
from typing import Any, ClassVar, Sequence
|
from collections.abc import Sequence
|
||||||
|
from typing import Any, ClassVar
|
||||||
|
|
||||||
from typing_extensions import Self
|
from typing_extensions import Self
|
||||||
|
|
||||||
|
|||||||
@@ -12,7 +12,6 @@
|
|||||||
# See the License for the specific language governing permissions and
|
# See the License for the specific language governing permissions and
|
||||||
# limitations under the License.
|
# limitations under the License.
|
||||||
|
|
||||||
from typing import Union
|
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
# Constants
|
# Constants
|
||||||
@@ -167,12 +166,12 @@ class G722Decoder:
|
|||||||
# The initial value in BLOCK 3H
|
# The initial value in BLOCK 3H
|
||||||
self._band[1].det = 8
|
self._band[1].det = 8
|
||||||
|
|
||||||
def decode_frame(self, encoded_data: Union[bytes, bytearray]) -> bytearray:
|
def decode_frame(self, encoded_data: bytes | bytearray) -> bytearray:
|
||||||
result_array = bytearray(len(encoded_data) * 4)
|
result_array = bytearray(len(encoded_data) * 4)
|
||||||
self.g722_decode(result_array, encoded_data)
|
self.g722_decode(result_array, encoded_data)
|
||||||
return result_array
|
return result_array
|
||||||
|
|
||||||
def g722_decode(self, result_array, encoded_data: Union[bytes, bytearray]) -> int:
|
def g722_decode(self, result_array, encoded_data: bytes | bytearray) -> int:
|
||||||
"""Decode the data frame using g722 decoder."""
|
"""Decode the data frame using g722 decoder."""
|
||||||
result_length = 0
|
result_length = 0
|
||||||
|
|
||||||
|
|||||||
2272
bumble/device.py
2272
bumble/device.py
File diff suppressed because it is too large
Load Diff
@@ -24,7 +24,8 @@ from __future__ import annotations
|
|||||||
import logging
|
import logging
|
||||||
import pathlib
|
import pathlib
|
||||||
import platform
|
import platform
|
||||||
from typing import TYPE_CHECKING, Iterable, Optional
|
from collections.abc import Iterable
|
||||||
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
from bumble.drivers import intel, rtk
|
from bumble.drivers import intel, rtk
|
||||||
from bumble.drivers.common import Driver
|
from bumble.drivers.common import Driver
|
||||||
@@ -41,7 +42,7 @@ logger = logging.getLogger(__name__)
|
|||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
# Functions
|
# Functions
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
async def get_driver_for_host(host: Host) -> Optional[Driver]:
|
async def get_driver_for_host(host: Host) -> Driver | None:
|
||||||
"""Probe diver classes until one returns a valid instance for a host, or none is
|
"""Probe diver classes until one returns a valid instance for a host, or none is
|
||||||
found.
|
found.
|
||||||
If a "driver" HCI metadata entry is present, only that driver class will be probed.
|
If a "driver" HCI metadata entry is present, only that driver class will be probed.
|
||||||
@@ -49,6 +50,10 @@ async def get_driver_for_host(host: Host) -> Optional[Driver]:
|
|||||||
driver_classes: dict[str, type[Driver]] = {"rtk": rtk.Driver, "intel": intel.Driver}
|
driver_classes: dict[str, type[Driver]] = {"rtk": rtk.Driver, "intel": intel.Driver}
|
||||||
probe_list: Iterable[str]
|
probe_list: Iterable[str]
|
||||||
if driver_name := host.hci_metadata.get("driver"):
|
if driver_name := host.hci_metadata.get("driver"):
|
||||||
|
# The "driver" metadata may include runtime options after a '/' (for example
|
||||||
|
# "intel/ddc=..."). Keep only the base driver name (the portion before the
|
||||||
|
# first slash) so it matches a key in driver_classes (e.g. "intel").
|
||||||
|
driver_name = driver_name.split("/")[0]
|
||||||
# Only probe a single driver
|
# Only probe a single driver
|
||||||
probe_list = [driver_name]
|
probe_list = [driver_name]
|
||||||
else:
|
else:
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ import os
|
|||||||
import pathlib
|
import pathlib
|
||||||
import platform
|
import platform
|
||||||
import struct
|
import struct
|
||||||
from typing import TYPE_CHECKING, Any, Optional
|
from typing import TYPE_CHECKING, Any
|
||||||
|
|
||||||
from bumble import core, hci, utils
|
from bumble import core, hci, utils
|
||||||
from bumble.drivers import common
|
from bumble.drivers import common
|
||||||
@@ -89,51 +89,54 @@ HCI_INTEL_WRITE_BOOT_PARAMS_COMMAND = hci.hci_vendor_command_op_code(0x000E)
|
|||||||
hci.HCI_Command.register_commands(globals())
|
hci.HCI_Command.register_commands(globals())
|
||||||
|
|
||||||
|
|
||||||
@hci.HCI_Command.command
|
|
||||||
@dataclasses.dataclass
|
@dataclasses.dataclass
|
||||||
class HCI_Intel_Read_Version_Command(hci.HCI_Command):
|
class HCI_Intel_Read_Version_ReturnParameters(hci.HCI_StatusReturnParameters):
|
||||||
|
tlv: bytes = hci.field(metadata=hci.metadata('*'))
|
||||||
|
|
||||||
|
|
||||||
|
@hci.HCI_SyncCommand.sync_command(HCI_Intel_Read_Version_ReturnParameters)
|
||||||
|
@dataclasses.dataclass
|
||||||
|
class HCI_Intel_Read_Version_Command(
|
||||||
|
hci.HCI_SyncCommand[HCI_Intel_Read_Version_ReturnParameters]
|
||||||
|
):
|
||||||
param0: int = dataclasses.field(metadata=hci.metadata(1))
|
param0: int = dataclasses.field(metadata=hci.metadata(1))
|
||||||
|
|
||||||
return_parameters_fields = [
|
|
||||||
("status", hci.STATUS_SPEC),
|
|
||||||
("tlv", "*"),
|
|
||||||
]
|
|
||||||
|
|
||||||
|
@hci.HCI_SyncCommand.sync_command(hci.HCI_StatusReturnParameters)
|
||||||
@hci.HCI_Command.command
|
|
||||||
@dataclasses.dataclass
|
@dataclasses.dataclass
|
||||||
class Hci_Intel_Secure_Send_Command(hci.HCI_Command):
|
class Hci_Intel_Secure_Send_Command(
|
||||||
|
hci.HCI_SyncCommand[hci.HCI_StatusReturnParameters]
|
||||||
|
):
|
||||||
data_type: int = dataclasses.field(metadata=hci.metadata(1))
|
data_type: int = dataclasses.field(metadata=hci.metadata(1))
|
||||||
data: bytes = dataclasses.field(metadata=hci.metadata("*"))
|
data: bytes = dataclasses.field(metadata=hci.metadata("*"))
|
||||||
|
|
||||||
return_parameters_fields = [
|
|
||||||
("status", 1),
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
@hci.HCI_Command.command
|
|
||||||
@dataclasses.dataclass
|
@dataclasses.dataclass
|
||||||
class HCI_Intel_Reset_Command(hci.HCI_Command):
|
class HCI_Intel_Reset_ReturnParameters(hci.HCI_ReturnParameters):
|
||||||
|
data: bytes = hci.field(metadata=hci.metadata('*'))
|
||||||
|
|
||||||
|
|
||||||
|
@hci.HCI_SyncCommand.sync_command(HCI_Intel_Reset_ReturnParameters)
|
||||||
|
@dataclasses.dataclass
|
||||||
|
class HCI_Intel_Reset_Command(hci.HCI_SyncCommand[HCI_Intel_Reset_ReturnParameters]):
|
||||||
reset_type: int = dataclasses.field(metadata=hci.metadata(1))
|
reset_type: int = dataclasses.field(metadata=hci.metadata(1))
|
||||||
patch_enable: int = dataclasses.field(metadata=hci.metadata(1))
|
patch_enable: int = dataclasses.field(metadata=hci.metadata(1))
|
||||||
ddc_reload: int = dataclasses.field(metadata=hci.metadata(1))
|
ddc_reload: int = dataclasses.field(metadata=hci.metadata(1))
|
||||||
boot_option: int = dataclasses.field(metadata=hci.metadata(1))
|
boot_option: int = dataclasses.field(metadata=hci.metadata(1))
|
||||||
boot_address: int = dataclasses.field(metadata=hci.metadata(4))
|
boot_address: int = dataclasses.field(metadata=hci.metadata(4))
|
||||||
|
|
||||||
return_parameters_fields = [
|
|
||||||
("data", "*"),
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
@hci.HCI_Command.command
|
|
||||||
@dataclasses.dataclass
|
@dataclasses.dataclass
|
||||||
class Hci_Intel_Write_Device_Config_Command(hci.HCI_Command):
|
class HCI_Intel_Write_Device_Config_ReturnParameters(hci.HCI_StatusReturnParameters):
|
||||||
data: bytes = dataclasses.field(metadata=hci.metadata("*"))
|
params: bytes = hci.field(metadata=hci.metadata('*'))
|
||||||
|
|
||||||
return_parameters_fields = [
|
|
||||||
("status", hci.STATUS_SPEC),
|
@hci.HCI_SyncCommand.sync_command(HCI_Intel_Write_Device_Config_ReturnParameters)
|
||||||
("params", "*"),
|
@dataclasses.dataclass
|
||||||
]
|
class HCI_Intel_Write_Device_Config_Command(
|
||||||
|
hci.HCI_SyncCommand[HCI_Intel_Write_Device_Config_ReturnParameters]
|
||||||
|
):
|
||||||
|
data: bytes = dataclasses.field(metadata=hci.metadata("*"))
|
||||||
|
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
@@ -198,50 +201,51 @@ def _parse_tlv(data: bytes) -> list[tuple[ValueType, Any]]:
|
|||||||
value = data[2 : 2 + value_length]
|
value = data[2 : 2 + value_length]
|
||||||
typed_value: Any
|
typed_value: Any
|
||||||
|
|
||||||
if value_type == ValueType.END:
|
match value_type:
|
||||||
break
|
case ValueType.END:
|
||||||
|
break
|
||||||
|
|
||||||
if value_type in (ValueType.CNVI, ValueType.CNVR):
|
case ValueType.CNVI | ValueType.CNVR:
|
||||||
(v,) = struct.unpack("<I", value)
|
(v,) = struct.unpack("<I", value)
|
||||||
typed_value = (
|
typed_value = (
|
||||||
(((v >> 0) & 0xF) << 12)
|
(((v >> 0) & 0xF) << 12)
|
||||||
| (((v >> 4) & 0xF) << 0)
|
| (((v >> 4) & 0xF) << 0)
|
||||||
| (((v >> 8) & 0xF) << 4)
|
| (((v >> 8) & 0xF) << 4)
|
||||||
| (((v >> 24) & 0xF) << 8)
|
| (((v >> 24) & 0xF) << 8)
|
||||||
)
|
)
|
||||||
elif value_type == ValueType.HARDWARE_INFO:
|
case ValueType.HARDWARE_INFO:
|
||||||
(v,) = struct.unpack("<I", value)
|
(v,) = struct.unpack("<I", value)
|
||||||
typed_value = HardwareInfo(
|
typed_value = HardwareInfo(
|
||||||
HardwarePlatform((v >> 8) & 0xFF), HardwareVariant((v >> 16) & 0x3F)
|
HardwarePlatform((v >> 8) & 0xFF), HardwareVariant((v >> 16) & 0x3F)
|
||||||
)
|
)
|
||||||
elif value_type in (
|
case (
|
||||||
ValueType.USB_VENDOR_ID,
|
ValueType.USB_VENDOR_ID
|
||||||
ValueType.USB_PRODUCT_ID,
|
| ValueType.USB_PRODUCT_ID
|
||||||
ValueType.DEVICE_REVISION,
|
| ValueType.DEVICE_REVISION
|
||||||
):
|
):
|
||||||
(typed_value,) = struct.unpack("<H", value)
|
(typed_value,) = struct.unpack("<H", value)
|
||||||
elif value_type == ValueType.CURRENT_MODE_OF_OPERATION:
|
case ValueType.CURRENT_MODE_OF_OPERATION:
|
||||||
typed_value = ModeOfOperation(value[0])
|
typed_value = ModeOfOperation(value[0])
|
||||||
elif value_type in (
|
case (
|
||||||
ValueType.BUILD_TYPE,
|
ValueType.BUILD_TYPE
|
||||||
ValueType.BUILD_NUMBER,
|
| ValueType.BUILD_NUMBER
|
||||||
ValueType.SECURE_BOOT,
|
| ValueType.SECURE_BOOT
|
||||||
ValueType.OTP_LOCK,
|
| ValueType.OTP_LOCK
|
||||||
ValueType.API_LOCK,
|
| ValueType.API_LOCK
|
||||||
ValueType.DEBUG_LOCK,
|
| ValueType.DEBUG_LOCK
|
||||||
ValueType.SECURE_BOOT_ENGINE_TYPE,
|
| ValueType.SECURE_BOOT_ENGINE_TYPE
|
||||||
):
|
):
|
||||||
typed_value = value[0]
|
typed_value = value[0]
|
||||||
elif value_type == ValueType.TIMESTAMP:
|
case ValueType.TIMESTAMP:
|
||||||
typed_value = Timestamp(value[0], value[1])
|
typed_value = Timestamp(value[0], value[1])
|
||||||
elif value_type == ValueType.FIRMWARE_BUILD:
|
case ValueType.FIRMWARE_BUILD:
|
||||||
typed_value = FirmwareBuild(value[0], Timestamp(value[1], value[2]))
|
typed_value = FirmwareBuild(value[0], Timestamp(value[1], value[2]))
|
||||||
elif value_type == ValueType.BLUETOOTH_ADDRESS:
|
case ValueType.BLUETOOTH_ADDRESS:
|
||||||
typed_value = hci.Address(
|
typed_value = hci.Address(
|
||||||
value, address_type=hci.Address.PUBLIC_DEVICE_ADDRESS
|
value, address_type=hci.Address.PUBLIC_DEVICE_ADDRESS
|
||||||
)
|
)
|
||||||
else:
|
case _:
|
||||||
typed_value = value
|
typed_value = value
|
||||||
|
|
||||||
result.append((value_type, typed_value))
|
result.append((value_type, typed_value))
|
||||||
data = data[2 + value_length :]
|
data = data[2 + value_length :]
|
||||||
@@ -353,8 +357,8 @@ class Driver(common.Driver):
|
|||||||
self.reset_complete = asyncio.Event()
|
self.reset_complete = asyncio.Event()
|
||||||
|
|
||||||
# Parse configuration options from the driver name.
|
# Parse configuration options from the driver name.
|
||||||
self.ddc_addon: Optional[bytes] = None
|
self.ddc_addon: bytes | None = None
|
||||||
self.ddc_override: Optional[bytes] = None
|
self.ddc_override: bytes | None = None
|
||||||
driver = host.hci_metadata.get("driver")
|
driver = host.hci_metadata.get("driver")
|
||||||
if driver is not None and driver.startswith("intel/"):
|
if driver is not None and driver.startswith("intel/"):
|
||||||
for key, value in [
|
for key, value in [
|
||||||
@@ -380,7 +384,7 @@ class Driver(common.Driver):
|
|||||||
|
|
||||||
if (vendor_id, product_id) not in INTEL_USB_PRODUCTS:
|
if (vendor_id, product_id) not in INTEL_USB_PRODUCTS:
|
||||||
logger.debug(
|
logger.debug(
|
||||||
f"USB device ({vendor_id:04X}, {product_id:04X}) " "not in known list"
|
f"USB device ({vendor_id:04X}, {product_id:04X}) not in known list"
|
||||||
)
|
)
|
||||||
return False
|
return False
|
||||||
|
|
||||||
@@ -402,7 +406,7 @@ class Driver(common.Driver):
|
|||||||
self.host.on_hci_event_packet(event)
|
self.host.on_hci_event_packet(event)
|
||||||
return
|
return
|
||||||
|
|
||||||
if not event.return_parameters == hci.HCI_SUCCESS:
|
if not event.return_parameters.status == hci.HCI_SUCCESS:
|
||||||
raise DriverError("HCI_Command_Complete_Event error")
|
raise DriverError("HCI_Command_Complete_Event error")
|
||||||
|
|
||||||
if self.max_in_flight_firmware_load_commands != event.num_hci_command_packets:
|
if self.max_in_flight_firmware_load_commands != event.num_hci_command_packets:
|
||||||
@@ -459,6 +463,10 @@ class Driver(common.Driver):
|
|||||||
== ModeOfOperation.OPERATIONAL
|
== ModeOfOperation.OPERATIONAL
|
||||||
):
|
):
|
||||||
logger.debug("firmware already loaded")
|
logger.debug("firmware already loaded")
|
||||||
|
# If the firmeare is already loaded, still attempt to load any
|
||||||
|
# device configuration (DDC). DDC can be applied independently of a
|
||||||
|
# firmware reload and may contain runtime overrides or patches.
|
||||||
|
await self.load_ddc_if_any()
|
||||||
return
|
return
|
||||||
|
|
||||||
# We only support some platforms and variants.
|
# We only support some platforms and variants.
|
||||||
@@ -479,9 +487,7 @@ class Driver(common.Driver):
|
|||||||
raise DriverError("insufficient device info, missing CNVI or CNVR")
|
raise DriverError("insufficient device info, missing CNVI or CNVR")
|
||||||
|
|
||||||
firmware_base_name = (
|
firmware_base_name = (
|
||||||
"ibt-"
|
f"ibt-{device_info[ValueType.CNVI]:04X}-{device_info[ValueType.CNVR]:04X}"
|
||||||
f"{device_info[ValueType.CNVI]:04X}-"
|
|
||||||
f"{device_info[ValueType.CNVR]:04X}"
|
|
||||||
)
|
)
|
||||||
logger.debug(f"FW base name: {firmware_base_name}")
|
logger.debug(f"FW base name: {firmware_base_name}")
|
||||||
|
|
||||||
@@ -598,17 +604,39 @@ class Driver(common.Driver):
|
|||||||
await self.reset_complete.wait()
|
await self.reset_complete.wait()
|
||||||
logger.debug("reset complete")
|
logger.debug("reset complete")
|
||||||
|
|
||||||
# Load the device config if there is one.
|
await self.load_ddc_if_any(firmware_base_name)
|
||||||
|
|
||||||
|
async def load_ddc_if_any(self, firmware_base_name: str | None = None) -> None:
|
||||||
|
"""
|
||||||
|
Check for and load any Device Data Configuration (DDC) blobs.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
firmware_base_name: Base name of the selected firmware (e.g. "ibt-XXXX-YYYY").
|
||||||
|
If None, don't attempt to look up a .ddc file that
|
||||||
|
corresponds to the firmware image.
|
||||||
|
Priority:
|
||||||
|
1. If a ddc_override was provided via driver metadata, use it (highest priority).
|
||||||
|
2. Otherwise, if firmware_base_name is provided, attempt to find a .ddc file
|
||||||
|
that corresponds to the selected firmware image.
|
||||||
|
3. Finally, if a ddc_addon was provided, append/load it after the primary DDC.
|
||||||
|
"""
|
||||||
|
# If an explicit DDC override was supplied, use it and skip file lookup.
|
||||||
if self.ddc_override:
|
if self.ddc_override:
|
||||||
logger.debug("loading overridden DDC")
|
logger.debug("loading overridden DDC")
|
||||||
await self.load_device_config(self.ddc_override)
|
await self.load_device_config(self.ddc_override)
|
||||||
else:
|
else:
|
||||||
ddc_name = f"{firmware_base_name}.ddc"
|
# Only attempt .ddc file lookup if a firmware_base_name was provided.
|
||||||
ddc_path = _find_binary_path(ddc_name)
|
if firmware_base_name is None:
|
||||||
if ddc_path:
|
logger.debug(
|
||||||
logger.debug(f"loading DDC from {ddc_path}")
|
"no firmware_base_name provided; skipping .ddc file lookup"
|
||||||
ddc_data = ddc_path.read_bytes()
|
)
|
||||||
await self.load_device_config(ddc_data)
|
else:
|
||||||
|
ddc_name = f"{firmware_base_name}.ddc"
|
||||||
|
ddc_path = _find_binary_path(ddc_name)
|
||||||
|
if ddc_path:
|
||||||
|
logger.debug(f"loading DDC from {ddc_path}")
|
||||||
|
ddc_data = ddc_path.read_bytes()
|
||||||
|
await self.load_device_config(ddc_data)
|
||||||
if self.ddc_addon:
|
if self.ddc_addon:
|
||||||
logger.debug("loading DDC addon")
|
logger.debug("loading DDC addon")
|
||||||
await self.load_device_config(self.ddc_addon)
|
await self.load_device_config(self.ddc_addon)
|
||||||
@@ -617,8 +645,8 @@ class Driver(common.Driver):
|
|||||||
while ddc_data:
|
while ddc_data:
|
||||||
ddc_len = 1 + ddc_data[0]
|
ddc_len = 1 + ddc_data[0]
|
||||||
ddc_payload = ddc_data[:ddc_len]
|
ddc_payload = ddc_data[:ddc_len]
|
||||||
await self.host.send_command(
|
await self.host.send_sync_command(
|
||||||
Hci_Intel_Write_Device_Config_Command(data=ddc_payload)
|
HCI_Intel_Write_Device_Config_Command(data=ddc_payload)
|
||||||
)
|
)
|
||||||
ddc_data = ddc_data[ddc_len:]
|
ddc_data = ddc_data[ddc_len:]
|
||||||
|
|
||||||
@@ -636,31 +664,34 @@ class Driver(common.Driver):
|
|||||||
|
|
||||||
async def read_device_info(self) -> dict[ValueType, Any]:
|
async def read_device_info(self) -> dict[ValueType, Any]:
|
||||||
self.host.ready = True
|
self.host.ready = True
|
||||||
response = await self.host.send_command(hci.HCI_Reset_Command())
|
response1 = await self.host.send_sync_command_raw(hci.HCI_Reset_Command())
|
||||||
if not (
|
if not isinstance(
|
||||||
isinstance(response, hci.HCI_Command_Complete_Event)
|
response1.return_parameters, hci.HCI_StatusReturnParameters
|
||||||
and response.return_parameters
|
) or response1.return_parameters.status not in (
|
||||||
in (hci.HCI_UNKNOWN_HCI_COMMAND_ERROR, hci.HCI_SUCCESS)
|
hci.HCI_UNKNOWN_HCI_COMMAND_ERROR,
|
||||||
|
hci.HCI_SUCCESS,
|
||||||
):
|
):
|
||||||
# When the controller is in operational mode, the response is a
|
# When the controller is in operational mode, the response is a
|
||||||
# successful response.
|
# successful response.
|
||||||
# When the controller is in bootloader mode,
|
# When the controller is in bootloader mode,
|
||||||
# HCI_UNKNOWN_HCI_COMMAND_ERROR is the expected response. Anything
|
# HCI_UNKNOWN_HCI_COMMAND_ERROR is the expected response. Anything
|
||||||
# else is a failure.
|
# else is a failure.
|
||||||
logger.warning(f"unexpected response: {response}")
|
logger.warning(f"unexpected response: {response1}")
|
||||||
raise DriverError("unexpected HCI response")
|
raise DriverError("unexpected HCI response")
|
||||||
|
|
||||||
# Read the firmware version.
|
# Read the firmware version.
|
||||||
response = await self.host.send_command(
|
response2 = await self.host.send_sync_command_raw(
|
||||||
HCI_Intel_Read_Version_Command(param0=0xFF)
|
HCI_Intel_Read_Version_Command(param0=0xFF)
|
||||||
)
|
)
|
||||||
if not isinstance(response, hci.HCI_Command_Complete_Event):
|
if (
|
||||||
raise DriverError("unexpected HCI response")
|
not isinstance(
|
||||||
|
response2.return_parameters, HCI_Intel_Read_Version_ReturnParameters
|
||||||
if response.return_parameters.status != 0: # type: ignore
|
)
|
||||||
|
or response2.return_parameters.status != 0
|
||||||
|
):
|
||||||
raise DriverError("HCI_Intel_Read_Version_Command error")
|
raise DriverError("HCI_Intel_Read_Version_Command error")
|
||||||
|
|
||||||
tlvs = _parse_tlv(response.return_parameters.tlv) # type: ignore
|
tlvs = _parse_tlv(response2.return_parameters.tlv) # type: ignore
|
||||||
|
|
||||||
# Convert the list to a dict. That's Ok here because we only expect each type
|
# Convert the list to a dict. That's Ok here because we only expect each type
|
||||||
# to appear just once.
|
# to appear just once.
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ Support for Realtek USB dongles.
|
|||||||
Based on various online bits of information, including the Linux kernel.
|
Based on various online bits of information, including the Linux kernel.
|
||||||
(see `drivers/bluetooth/btrtl.c`)
|
(see `drivers/bluetooth/btrtl.c`)
|
||||||
"""
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
import enum
|
import enum
|
||||||
@@ -31,10 +32,14 @@ import weakref
|
|||||||
# Imports
|
# Imports
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
from bumble import core, hci
|
from bumble import core, hci
|
||||||
from bumble.drivers import common
|
from bumble.drivers import common
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from bumble.host import Host
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
# Logging
|
# Logging
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
@@ -77,6 +82,7 @@ class RtlProjectId(enum.IntEnum):
|
|||||||
PROJECT_ID_8852A = 18
|
PROJECT_ID_8852A = 18
|
||||||
PROJECT_ID_8852B = 20
|
PROJECT_ID_8852B = 20
|
||||||
PROJECT_ID_8852C = 25
|
PROJECT_ID_8852C = 25
|
||||||
|
PROJECT_ID_8761C = 51
|
||||||
|
|
||||||
|
|
||||||
RTK_PROJECT_ID_TO_ROM = {
|
RTK_PROJECT_ID_TO_ROM = {
|
||||||
@@ -92,6 +98,7 @@ RTK_PROJECT_ID_TO_ROM = {
|
|||||||
18: RTK_ROM_LMP_8852A,
|
18: RTK_ROM_LMP_8852A,
|
||||||
20: RTK_ROM_LMP_8852A,
|
20: RTK_ROM_LMP_8852A,
|
||||||
25: RTK_ROM_LMP_8852A,
|
25: RTK_ROM_LMP_8852A,
|
||||||
|
51: RTK_ROM_LMP_8761A,
|
||||||
}
|
}
|
||||||
|
|
||||||
# List of USB (VendorID, ProductID) for Realtek-based devices.
|
# List of USB (VendorID, ProductID) for Realtek-based devices.
|
||||||
@@ -115,12 +122,19 @@ RTK_USB_PRODUCTS = {
|
|||||||
# Realtek 8761BUV
|
# Realtek 8761BUV
|
||||||
(0x0B05, 0x190E),
|
(0x0B05, 0x190E),
|
||||||
(0x0BDA, 0x8771),
|
(0x0BDA, 0x8771),
|
||||||
|
(0x0BDA, 0x877B),
|
||||||
|
(0x0BDA, 0xA728),
|
||||||
|
(0x0BDA, 0xA729),
|
||||||
(0x2230, 0x0016),
|
(0x2230, 0x0016),
|
||||||
(0x2357, 0x0604),
|
(0x2357, 0x0604),
|
||||||
(0x2550, 0x8761),
|
(0x2550, 0x8761),
|
||||||
(0x2B89, 0x8761),
|
(0x2B89, 0x8761),
|
||||||
|
(0x2C0A, 0x8761),
|
||||||
(0x7392, 0xC611),
|
(0x7392, 0xC611),
|
||||||
(0x0BDA, 0x877B),
|
# Realtek 8761CUV
|
||||||
|
(0x0B05, 0x1BF6),
|
||||||
|
(0x0BDA, 0xC761),
|
||||||
|
(0x7392, 0xF611),
|
||||||
# Realtek 8821AE
|
# Realtek 8821AE
|
||||||
(0x0B05, 0x17DC),
|
(0x0B05, 0x17DC),
|
||||||
(0x13D3, 0x3414),
|
(0x13D3, 0x3414),
|
||||||
@@ -180,23 +194,36 @@ HCI_RTK_DROP_FIRMWARE_COMMAND = hci.hci_vendor_command_op_code(0x66)
|
|||||||
hci.HCI_Command.register_commands(globals())
|
hci.HCI_Command.register_commands(globals())
|
||||||
|
|
||||||
|
|
||||||
@hci.HCI_Command.command
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class HCI_RTK_Read_ROM_Version_Command(hci.HCI_Command):
|
class HCI_RTK_Read_ROM_Version_ReturnParameters(hci.HCI_StatusReturnParameters):
|
||||||
return_parameters_fields = [("status", hci.STATUS_SPEC), ("version", 1)]
|
version: int = field(metadata=hci.metadata(1))
|
||||||
|
|
||||||
|
|
||||||
@hci.HCI_Command.command
|
@hci.HCI_SyncCommand.sync_command(HCI_RTK_Read_ROM_Version_ReturnParameters)
|
||||||
@dataclass
|
@dataclass
|
||||||
class HCI_RTK_Download_Command(hci.HCI_Command):
|
class HCI_RTK_Read_ROM_Version_Command(
|
||||||
|
hci.HCI_SyncCommand[HCI_RTK_Read_ROM_Version_ReturnParameters]
|
||||||
|
):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class HCI_RTK_Download_ReturnParameters(hci.HCI_StatusReturnParameters):
|
||||||
|
index: int = field(metadata=hci.metadata(1))
|
||||||
|
|
||||||
|
|
||||||
|
@hci.HCI_SyncCommand.sync_command(HCI_RTK_Download_ReturnParameters)
|
||||||
|
@dataclass
|
||||||
|
class HCI_RTK_Download_Command(hci.HCI_SyncCommand[HCI_RTK_Download_ReturnParameters]):
|
||||||
index: int = field(metadata=hci.metadata(1))
|
index: int = field(metadata=hci.metadata(1))
|
||||||
payload: bytes = field(metadata=hci.metadata(RTK_FRAGMENT_LENGTH))
|
payload: bytes = field(metadata=hci.metadata(RTK_FRAGMENT_LENGTH))
|
||||||
return_parameters_fields = [("status", hci.STATUS_SPEC), ("index", 1)]
|
|
||||||
|
|
||||||
|
|
||||||
@hci.HCI_Command.command
|
@hci.HCI_SyncCommand.sync_command(hci.HCI_GenericReturnParameters)
|
||||||
@dataclass
|
@dataclass
|
||||||
class HCI_RTK_Drop_Firmware_Command(hci.HCI_Command):
|
class HCI_RTK_Drop_Firmware_Command(
|
||||||
|
hci.HCI_SyncCommand[hci.HCI_GenericReturnParameters]
|
||||||
|
):
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
@@ -361,6 +388,15 @@ class Driver(common.Driver):
|
|||||||
fw_name="rtl8761bu_fw.bin",
|
fw_name="rtl8761bu_fw.bin",
|
||||||
config_name="rtl8761bu_config.bin",
|
config_name="rtl8761bu_config.bin",
|
||||||
),
|
),
|
||||||
|
# 8761CU
|
||||||
|
DriverInfo(
|
||||||
|
rom=RTK_ROM_LMP_8761A,
|
||||||
|
hci=(0x0E, 0x00),
|
||||||
|
config_needed=False,
|
||||||
|
has_rom_version=True,
|
||||||
|
fw_name="rtl8761cu_fw.bin",
|
||||||
|
config_name="rtl8761cu_config.bin",
|
||||||
|
),
|
||||||
# 8822C
|
# 8822C
|
||||||
DriverInfo(
|
DriverInfo(
|
||||||
rom=RTK_ROM_LMP_8822B,
|
rom=RTK_ROM_LMP_8822B,
|
||||||
@@ -418,9 +454,17 @@ class Driver(common.Driver):
|
|||||||
@staticmethod
|
@staticmethod
|
||||||
def find_driver_info(hci_version, hci_subversion, lmp_subversion):
|
def find_driver_info(hci_version, hci_subversion, lmp_subversion):
|
||||||
for driver_info in Driver.DRIVER_INFOS:
|
for driver_info in Driver.DRIVER_INFOS:
|
||||||
if driver_info.rom == lmp_subversion and driver_info.hci == (
|
if driver_info.rom == lmp_subversion and (
|
||||||
hci_subversion,
|
driver_info.hci
|
||||||
hci_version,
|
== (
|
||||||
|
hci_subversion,
|
||||||
|
hci_version,
|
||||||
|
)
|
||||||
|
or driver_info.hci
|
||||||
|
== (
|
||||||
|
hci_subversion,
|
||||||
|
0x0,
|
||||||
|
)
|
||||||
):
|
):
|
||||||
return driver_info
|
return driver_info
|
||||||
|
|
||||||
@@ -465,7 +509,7 @@ class Driver(common.Driver):
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def check(host):
|
def check(host: Host) -> bool:
|
||||||
if not host.hci_metadata:
|
if not host.hci_metadata:
|
||||||
logger.debug("USB metadata not found")
|
logger.debug("USB metadata not found")
|
||||||
return False
|
return False
|
||||||
@@ -482,44 +526,51 @@ class Driver(common.Driver):
|
|||||||
|
|
||||||
if (vendor_id, product_id) not in RTK_USB_PRODUCTS:
|
if (vendor_id, product_id) not in RTK_USB_PRODUCTS:
|
||||||
logger.debug(
|
logger.debug(
|
||||||
f"USB device ({vendor_id:04X}, {product_id:04X}) " "not in known list"
|
f"USB device ({vendor_id:04X}, {product_id:04X}) not in known list"
|
||||||
)
|
)
|
||||||
return False
|
return False
|
||||||
|
|
||||||
return True
|
return True
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
async def get_loaded_firmware_version(host):
|
async def get_loaded_firmware_version(host: Host) -> int | None:
|
||||||
response = await host.send_command(HCI_RTK_Read_ROM_Version_Command())
|
response1 = await host.send_sync_command_raw(HCI_RTK_Read_ROM_Version_Command())
|
||||||
|
if (
|
||||||
if response.return_parameters.status != hci.HCI_SUCCESS:
|
not isinstance(
|
||||||
|
response1.return_parameters, HCI_RTK_Read_ROM_Version_ReturnParameters
|
||||||
|
)
|
||||||
|
or response1.return_parameters.status != hci.HCI_SUCCESS
|
||||||
|
):
|
||||||
return None
|
return None
|
||||||
|
|
||||||
response = await host.send_command(
|
response2 = await host.send_sync_command(
|
||||||
hci.HCI_Read_Local_Version_Information_Command(), check_result=True
|
hci.HCI_Read_Local_Version_Information_Command()
|
||||||
)
|
|
||||||
return (
|
|
||||||
response.return_parameters.hci_subversion << 16
|
|
||||||
| response.return_parameters.lmp_subversion
|
|
||||||
)
|
)
|
||||||
|
return response2.hci_subversion << 16 | response2.lmp_subversion
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
async def driver_info_for_host(cls, host):
|
async def driver_info_for_host(cls, host: Host) -> DriverInfo | None:
|
||||||
try:
|
try:
|
||||||
await host.send_command(
|
await host.send_sync_command(
|
||||||
hci.HCI_Reset_Command(),
|
hci.HCI_Reset_Command(),
|
||||||
check_result=True,
|
|
||||||
response_timeout=cls.POST_RESET_DELAY,
|
response_timeout=cls.POST_RESET_DELAY,
|
||||||
)
|
)
|
||||||
host.ready = True # Needed to let the host know the controller is ready.
|
host.ready = True # Needed to let the host know the controller is ready.
|
||||||
except asyncio.exceptions.TimeoutError:
|
except asyncio.exceptions.TimeoutError:
|
||||||
logger.warning("timeout waiting for hci reset, retrying")
|
logger.warning("timeout waiting for hci reset, retrying")
|
||||||
await host.send_command(hci.HCI_Reset_Command(), check_result=True)
|
await host.send_sync_command(hci.HCI_Reset_Command())
|
||||||
host.ready = True
|
host.ready = True
|
||||||
|
|
||||||
command = hci.HCI_Read_Local_Version_Information_Command()
|
response = await host.send_sync_command_raw(
|
||||||
response = await host.send_command(command, check_result=True)
|
hci.HCI_Read_Local_Version_Information_Command()
|
||||||
if response.command_opcode != command.op_code:
|
)
|
||||||
|
if (
|
||||||
|
not isinstance(
|
||||||
|
response.return_parameters,
|
||||||
|
hci.HCI_Read_Local_Version_Information_ReturnParameters,
|
||||||
|
)
|
||||||
|
or response.return_parameters.status != hci.HCI_SUCCESS
|
||||||
|
):
|
||||||
logger.error("failed to probe local version information")
|
logger.error("failed to probe local version information")
|
||||||
return None
|
return None
|
||||||
|
|
||||||
@@ -544,7 +595,7 @@ class Driver(common.Driver):
|
|||||||
return driver_info
|
return driver_info
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
async def for_host(cls, host, force=False):
|
async def for_host(cls, host: Host, force: bool = False):
|
||||||
# Check that a driver is needed for this host
|
# Check that a driver is needed for this host
|
||||||
if not force and not cls.check(host):
|
if not force and not cls.check(host):
|
||||||
return None
|
return None
|
||||||
@@ -599,15 +650,21 @@ class Driver(common.Driver):
|
|||||||
|
|
||||||
# TODO: load the firmware
|
# TODO: load the firmware
|
||||||
|
|
||||||
async def download_for_rtl8723b(self):
|
async def download_for_rtl8723b(self) -> int | None:
|
||||||
if self.driver_info.has_rom_version:
|
if self.driver_info.has_rom_version:
|
||||||
response = await self.host.send_command(
|
response1 = await self.host.send_sync_command_raw(
|
||||||
HCI_RTK_Read_ROM_Version_Command(), check_result=True
|
HCI_RTK_Read_ROM_Version_Command()
|
||||||
)
|
)
|
||||||
if response.return_parameters.status != hci.HCI_SUCCESS:
|
if (
|
||||||
|
not isinstance(
|
||||||
|
response1.return_parameters,
|
||||||
|
HCI_RTK_Read_ROM_Version_ReturnParameters,
|
||||||
|
)
|
||||||
|
or response1.return_parameters.status != hci.HCI_SUCCESS
|
||||||
|
):
|
||||||
logger.warning("can't get ROM version")
|
logger.warning("can't get ROM version")
|
||||||
return None
|
return None
|
||||||
rom_version = response.return_parameters.version
|
rom_version = response1.return_parameters.version
|
||||||
logger.debug(f"ROM version before download: {rom_version:04X}")
|
logger.debug(f"ROM version before download: {rom_version:04X}")
|
||||||
else:
|
else:
|
||||||
rom_version = 0
|
rom_version = 0
|
||||||
@@ -642,21 +699,25 @@ class Driver(common.Driver):
|
|||||||
fragment_offset = fragment_index * RTK_FRAGMENT_LENGTH
|
fragment_offset = fragment_index * RTK_FRAGMENT_LENGTH
|
||||||
fragment = payload[fragment_offset : fragment_offset + RTK_FRAGMENT_LENGTH]
|
fragment = payload[fragment_offset : fragment_offset + RTK_FRAGMENT_LENGTH]
|
||||||
logger.debug(f"downloading fragment {fragment_index}")
|
logger.debug(f"downloading fragment {fragment_index}")
|
||||||
await self.host.send_command(
|
await self.host.send_sync_command(
|
||||||
HCI_RTK_Download_Command(index=download_index, payload=fragment),
|
HCI_RTK_Download_Command(index=download_index, payload=fragment)
|
||||||
check_result=True,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
logger.debug("download complete!")
|
logger.debug("download complete!")
|
||||||
|
|
||||||
# Read the version again
|
# Read the version again
|
||||||
response = await self.host.send_command(
|
response2 = await self.host.send_sync_command_raw(
|
||||||
HCI_RTK_Read_ROM_Version_Command(), check_result=True
|
HCI_RTK_Read_ROM_Version_Command()
|
||||||
)
|
)
|
||||||
if response.return_parameters.status != hci.HCI_SUCCESS:
|
if (
|
||||||
|
not isinstance(
|
||||||
|
response2.return_parameters, HCI_RTK_Read_ROM_Version_ReturnParameters
|
||||||
|
)
|
||||||
|
or response2.return_parameters.status != hci.HCI_SUCCESS
|
||||||
|
):
|
||||||
logger.warning("can't get ROM version")
|
logger.warning("can't get ROM version")
|
||||||
else:
|
else:
|
||||||
rom_version = response.return_parameters.version
|
rom_version = response2.return_parameters.version
|
||||||
logger.debug(f"ROM version after download: {rom_version:02X}")
|
logger.debug(f"ROM version after download: {rom_version:02X}")
|
||||||
|
|
||||||
return firmware.version
|
return firmware.version
|
||||||
@@ -678,7 +739,7 @@ class Driver(common.Driver):
|
|||||||
|
|
||||||
async def init_controller(self):
|
async def init_controller(self):
|
||||||
await self.download_firmware()
|
await self.download_firmware()
|
||||||
await self.host.send_command(hci.HCI_Reset_Command(), check_result=True)
|
await self.host.send_sync_command(hci.HCI_Reset_Command())
|
||||||
logger.info(f"loaded FW image {self.driver_info.fw_name}")
|
logger.info(f"loaded FW image {self.driver_info.fw_name}")
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,60 +0,0 @@
|
|||||||
# Copyright 2021-2022 Google LLC
|
|
||||||
#
|
|
||||||
# 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
|
|
||||||
#
|
|
||||||
# https://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.
|
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
|
||||||
# Imports
|
|
||||||
# -----------------------------------------------------------------------------
|
|
||||||
import logging
|
|
||||||
import struct
|
|
||||||
|
|
||||||
from bumble.gatt import (
|
|
||||||
GATT_APPEARANCE_CHARACTERISTIC,
|
|
||||||
GATT_DEVICE_NAME_CHARACTERISTIC,
|
|
||||||
GATT_GENERIC_ACCESS_SERVICE,
|
|
||||||
Characteristic,
|
|
||||||
Service,
|
|
||||||
)
|
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
|
||||||
# Logging
|
|
||||||
# -----------------------------------------------------------------------------
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
|
||||||
# Classes
|
|
||||||
# -----------------------------------------------------------------------------
|
|
||||||
|
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
|
||||||
class GenericAccessService(Service):
|
|
||||||
def __init__(self, device_name, appearance=(0, 0)):
|
|
||||||
device_name_characteristic = Characteristic(
|
|
||||||
GATT_DEVICE_NAME_CHARACTERISTIC,
|
|
||||||
Characteristic.Properties.READ,
|
|
||||||
Characteristic.READABLE,
|
|
||||||
device_name.encode('utf-8')[:248],
|
|
||||||
)
|
|
||||||
|
|
||||||
appearance_characteristic = Characteristic(
|
|
||||||
GATT_APPEARANCE_CHARACTERISTIC,
|
|
||||||
Characteristic.Properties.READ,
|
|
||||||
Characteristic.READABLE,
|
|
||||||
struct.pack('<H', (appearance[0] << 6) | appearance[1]),
|
|
||||||
)
|
|
||||||
|
|
||||||
super().__init__(
|
|
||||||
GATT_GENERIC_ACCESS_SERVICE,
|
|
||||||
[device_name_characteristic, appearance_characteristic],
|
|
||||||
)
|
|
||||||
@@ -28,9 +28,10 @@ import enum
|
|||||||
import functools
|
import functools
|
||||||
import logging
|
import logging
|
||||||
import struct
|
import struct
|
||||||
from typing import Iterable, Optional, Sequence, TypeVar, Union
|
from collections.abc import Iterable, Sequence
|
||||||
|
from typing import ClassVar, TypeVar
|
||||||
|
|
||||||
from bumble.att import Attribute, AttributeValue
|
from bumble.att import Attribute, AttributeValue, AttributeValueV2
|
||||||
from bumble.colors import color
|
from bumble.colors import color
|
||||||
from bumble.core import UUID, BaseBumbleError
|
from bumble.core import UUID, BaseBumbleError
|
||||||
|
|
||||||
@@ -227,7 +228,6 @@ GATT_MEDIA_CONTROL_POINT_CHARACTERISTIC = UUID.from_16_bits(0x
|
|||||||
GATT_MEDIA_CONTROL_POINT_OPCODES_SUPPORTED_CHARACTERISTIC = UUID.from_16_bits(0x2BA5, 'Media Control Point Opcodes Supported')
|
GATT_MEDIA_CONTROL_POINT_OPCODES_SUPPORTED_CHARACTERISTIC = UUID.from_16_bits(0x2BA5, 'Media Control Point Opcodes Supported')
|
||||||
GATT_SEARCH_RESULTS_OBJECT_ID_CHARACTERISTIC = UUID.from_16_bits(0x2BA6, 'Search Results Object ID')
|
GATT_SEARCH_RESULTS_OBJECT_ID_CHARACTERISTIC = UUID.from_16_bits(0x2BA6, 'Search Results Object ID')
|
||||||
GATT_SEARCH_CONTROL_POINT_CHARACTERISTIC = UUID.from_16_bits(0x2BA7, 'Search Control Point')
|
GATT_SEARCH_CONTROL_POINT_CHARACTERISTIC = UUID.from_16_bits(0x2BA7, 'Search Control Point')
|
||||||
GATT_CONTENT_CONTROL_ID_CHARACTERISTIC = UUID.from_16_bits(0x2BBA, 'Content Control Id')
|
|
||||||
|
|
||||||
# Telephone Bearer Service (TBS)
|
# Telephone Bearer Service (TBS)
|
||||||
GATT_BEARER_PROVIDER_NAME_CHARACTERISTIC = UUID.from_16_bits(0x2BB3, 'Bearer Provider Name')
|
GATT_BEARER_PROVIDER_NAME_CHARACTERISTIC = UUID.from_16_bits(0x2BB3, 'Bearer Provider Name')
|
||||||
@@ -356,7 +356,7 @@ class Service(Attribute):
|
|||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
uuid: Union[str, UUID],
|
uuid: str | UUID,
|
||||||
characteristics: Iterable[Characteristic],
|
characteristics: Iterable[Characteristic],
|
||||||
primary=True,
|
primary=True,
|
||||||
included_services: Iterable[Service] = (),
|
included_services: Iterable[Service] = (),
|
||||||
@@ -379,7 +379,7 @@ class Service(Attribute):
|
|||||||
self.characteristics = list(characteristics)
|
self.characteristics = list(characteristics)
|
||||||
self.primary = primary
|
self.primary = primary
|
||||||
|
|
||||||
def get_advertising_data(self) -> Optional[bytes]:
|
def get_advertising_data(self) -> bytes | None:
|
||||||
"""
|
"""
|
||||||
Get Service specific advertising data
|
Get Service specific advertising data
|
||||||
Defined by each Service, default value is empty
|
Defined by each Service, default value is empty
|
||||||
@@ -403,7 +403,7 @@ class TemplateService(Service):
|
|||||||
to expose their UUID as a class property
|
to expose their UUID as a class property
|
||||||
'''
|
'''
|
||||||
|
|
||||||
UUID: UUID
|
UUID: ClassVar[UUID]
|
||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
@@ -503,10 +503,10 @@ class Characteristic(Attribute[_T]):
|
|||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
uuid: Union[str, bytes, UUID],
|
uuid: str | bytes | UUID,
|
||||||
properties: Characteristic.Properties,
|
properties: Characteristic.Properties,
|
||||||
permissions: Union[str, Attribute.Permissions],
|
permissions: str | Attribute.Permissions,
|
||||||
value: Union[AttributeValue[_T], _T, None] = None,
|
value: AttributeValue[_T] | _T | None = None,
|
||||||
descriptors: Sequence[Descriptor] = (),
|
descriptors: Sequence[Descriptor] = (),
|
||||||
):
|
):
|
||||||
super().__init__(uuid, permissions, value)
|
super().__init__(uuid, permissions, value)
|
||||||
@@ -579,7 +579,7 @@ class Descriptor(Attribute):
|
|||||||
def __str__(self) -> str:
|
def __str__(self) -> str:
|
||||||
if isinstance(self.value, bytes):
|
if isinstance(self.value, bytes):
|
||||||
value_str = self.value.hex()
|
value_str = self.value.hex()
|
||||||
elif isinstance(self.value, CharacteristicValue):
|
elif isinstance(self.value, (AttributeValue, AttributeValueV2)):
|
||||||
value_str = '<dynamic>'
|
value_str = '<dynamic>'
|
||||||
else:
|
else:
|
||||||
value_str = '<...>'
|
value_str = '<...>'
|
||||||
|
|||||||
@@ -22,7 +22,8 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import struct
|
import struct
|
||||||
from typing import Any, Callable, Generic, Iterable, Literal, Optional, TypeVar
|
from collections.abc import Callable, Iterable
|
||||||
|
from typing import Any, Generic, Literal, TypeVar
|
||||||
|
|
||||||
from bumble import utils
|
from bumble import utils
|
||||||
from bumble.core import InvalidOperationError
|
from bumble.core import InvalidOperationError
|
||||||
@@ -74,8 +75,8 @@ class DelegatedCharacteristicAdapter(CharacteristicAdapter[_T]):
|
|||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
characteristic: Characteristic,
|
characteristic: Characteristic,
|
||||||
encode: Optional[Callable[[_T], bytes]] = None,
|
encode: Callable[[_T], bytes] | None = None,
|
||||||
decode: Optional[Callable[[bytes], _T]] = None,
|
decode: Callable[[bytes], _T] | None = None,
|
||||||
):
|
):
|
||||||
super().__init__(characteristic)
|
super().__init__(characteristic)
|
||||||
self.encode = encode
|
self.encode = encode
|
||||||
@@ -101,8 +102,8 @@ class DelegatedCharacteristicProxyAdapter(CharacteristicProxyAdapter[_T]):
|
|||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
characteristic_proxy: CharacteristicProxy,
|
characteristic_proxy: CharacteristicProxy,
|
||||||
encode: Optional[Callable[[_T], bytes]] = None,
|
encode: Callable[[_T], bytes] | None = None,
|
||||||
decode: Optional[Callable[[bytes], _T]] = None,
|
decode: Callable[[bytes], _T] | None = None,
|
||||||
):
|
):
|
||||||
super().__init__(characteristic_proxy)
|
super().__init__(characteristic_proxy)
|
||||||
self.encode = encode
|
self.encode = encode
|
||||||
@@ -361,5 +362,4 @@ class EnumCharacteristicProxyAdapter(CharacteristicProxyAdapter[_T3]):
|
|||||||
|
|
||||||
def decode_value(self, value: bytes) -> _T3:
|
def decode_value(self, value: bytes) -> _T3:
|
||||||
int_value = int.from_bytes(value, self.byteorder)
|
int_value = int.from_bytes(value, self.byteorder)
|
||||||
a = self.cls(int_value)
|
|
||||||
return self.cls(int_value)
|
return self.cls(int_value)
|
||||||
|
|||||||
@@ -26,21 +26,23 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
|
import functools
|
||||||
import logging
|
import logging
|
||||||
import struct
|
import struct
|
||||||
|
from collections.abc import Callable, Iterable
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from typing import (
|
from typing import (
|
||||||
TYPE_CHECKING,
|
TYPE_CHECKING,
|
||||||
Any,
|
Any,
|
||||||
Callable,
|
ClassVar,
|
||||||
Generic,
|
Generic,
|
||||||
Iterable,
|
|
||||||
Optional,
|
|
||||||
TypeVar,
|
TypeVar,
|
||||||
Union,
|
overload,
|
||||||
)
|
)
|
||||||
|
|
||||||
from bumble import att, core, utils
|
from typing_extensions import Self
|
||||||
|
|
||||||
|
from bumble import att, core, l2cap, utils
|
||||||
from bumble.colors import color
|
from bumble.colors import color
|
||||||
from bumble.core import UUID, InvalidStateError
|
from bumble.core import UUID, InvalidStateError
|
||||||
from bumble.gatt import (
|
from bumble.gatt import (
|
||||||
@@ -57,12 +59,12 @@ from bumble.gatt import (
|
|||||||
)
|
)
|
||||||
from bumble.hci import HCI_Constant
|
from bumble.hci import HCI_Constant
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from bumble import device as device_module
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
# Typing
|
# Typing
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
if TYPE_CHECKING:
|
|
||||||
from bumble.device import Connection
|
|
||||||
|
|
||||||
_T = TypeVar('_T')
|
_T = TypeVar('_T')
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
@@ -192,7 +194,7 @@ class CharacteristicProxy(AttributeProxy[_T]):
|
|||||||
self.descriptors_discovered = False
|
self.descriptors_discovered = False
|
||||||
self.subscribers = {} # Map from subscriber to proxy subscriber
|
self.subscribers = {} # Map from subscriber to proxy subscriber
|
||||||
|
|
||||||
def get_descriptor(self, descriptor_type: UUID) -> Optional[DescriptorProxy]:
|
def get_descriptor(self, descriptor_type: UUID) -> DescriptorProxy | None:
|
||||||
for descriptor in self.descriptors:
|
for descriptor in self.descriptors:
|
||||||
if descriptor.type == descriptor_type:
|
if descriptor.type == descriptor_type:
|
||||||
return descriptor
|
return descriptor
|
||||||
@@ -204,7 +206,7 @@ class CharacteristicProxy(AttributeProxy[_T]):
|
|||||||
|
|
||||||
async def subscribe(
|
async def subscribe(
|
||||||
self,
|
self,
|
||||||
subscriber: Optional[Callable[[_T], Any]] = None,
|
subscriber: Callable[[_T], Any] | None = None,
|
||||||
prefer_notify: bool = True,
|
prefer_notify: bool = True,
|
||||||
) -> None:
|
) -> None:
|
||||||
if subscriber is not None:
|
if subscriber is not None:
|
||||||
@@ -250,10 +252,10 @@ class ProfileServiceProxy:
|
|||||||
Base class for profile-specific service proxies
|
Base class for profile-specific service proxies
|
||||||
'''
|
'''
|
||||||
|
|
||||||
SERVICE_CLASS: type[TemplateService]
|
SERVICE_CLASS: ClassVar[type[TemplateService]]
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def from_client(cls, client: Client) -> Optional[ProfileServiceProxy]:
|
def from_client(cls, client: Client) -> Self | None:
|
||||||
return ServiceProxy.from_client(cls, client, cls.SERVICE_CLASS.UUID)
|
return ServiceProxy.from_client(cls, client, cls.SERVICE_CLASS.UUID)
|
||||||
|
|
||||||
|
|
||||||
@@ -264,16 +266,14 @@ class Client:
|
|||||||
services: list[ServiceProxy]
|
services: list[ServiceProxy]
|
||||||
cached_values: dict[int, tuple[datetime, bytes]]
|
cached_values: dict[int, tuple[datetime, bytes]]
|
||||||
notification_subscribers: dict[
|
notification_subscribers: dict[
|
||||||
int, set[Union[CharacteristicProxy, Callable[[bytes], Any]]]
|
int, set[CharacteristicProxy | Callable[[bytes], Any]]
|
||||||
]
|
]
|
||||||
indication_subscribers: dict[
|
indication_subscribers: dict[int, set[CharacteristicProxy | Callable[[bytes], Any]]]
|
||||||
int, set[Union[CharacteristicProxy, Callable[[bytes], Any]]]
|
pending_response: asyncio.futures.Future[att.ATT_PDU] | None
|
||||||
]
|
pending_request: att.ATT_PDU | None
|
||||||
pending_response: Optional[asyncio.futures.Future[att.ATT_PDU]]
|
|
||||||
pending_request: Optional[att.ATT_PDU]
|
|
||||||
|
|
||||||
def __init__(self, connection: Connection) -> None:
|
def __init__(self, bearer: att.Bearer) -> None:
|
||||||
self.connection = connection
|
self.bearer = bearer
|
||||||
self.mtu_exchange_done = False
|
self.mtu_exchange_done = False
|
||||||
self.request_semaphore = asyncio.Semaphore(1)
|
self.request_semaphore = asyncio.Semaphore(1)
|
||||||
self.pending_request = None
|
self.pending_request = None
|
||||||
@@ -283,21 +283,76 @@ class Client:
|
|||||||
self.services = []
|
self.services = []
|
||||||
self.cached_values = {}
|
self.cached_values = {}
|
||||||
|
|
||||||
connection.on(connection.EVENT_DISCONNECTION, self.on_disconnection)
|
if att.is_enhanced_bearer(bearer):
|
||||||
|
bearer.on(bearer.EVENT_CLOSE, self.on_disconnection)
|
||||||
|
self._bearer_id = (
|
||||||
|
f'[0x{bearer.connection.handle:04X}|CID=0x{bearer.source_cid:04X}]'
|
||||||
|
)
|
||||||
|
self.connection = bearer.connection
|
||||||
|
else:
|
||||||
|
bearer.on(bearer.EVENT_DISCONNECTION, self.on_disconnection)
|
||||||
|
self._bearer_id = f'[0x{bearer.handle:04X}]'
|
||||||
|
self.connection = bearer
|
||||||
|
|
||||||
|
@overload
|
||||||
|
@classmethod
|
||||||
|
async def connect_eatt(
|
||||||
|
cls,
|
||||||
|
connection: device_module.Connection,
|
||||||
|
spec: l2cap.LeCreditBasedChannelSpec | None = None,
|
||||||
|
) -> Client: ...
|
||||||
|
|
||||||
|
@overload
|
||||||
|
@classmethod
|
||||||
|
async def connect_eatt(
|
||||||
|
cls,
|
||||||
|
connection: device_module.Connection,
|
||||||
|
spec: l2cap.LeCreditBasedChannelSpec | None = None,
|
||||||
|
count: int = 1,
|
||||||
|
) -> list[Client]: ...
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
async def connect_eatt(
|
||||||
|
cls,
|
||||||
|
connection: device_module.Connection,
|
||||||
|
spec: l2cap.LeCreditBasedChannelSpec | None = None,
|
||||||
|
count: int = 1,
|
||||||
|
) -> list[Client] | Client:
|
||||||
|
channels = await connection.device.l2cap_channel_manager.create_enhanced_credit_based_channels(
|
||||||
|
connection,
|
||||||
|
spec or l2cap.LeCreditBasedChannelSpec(psm=att.EATT_PSM),
|
||||||
|
count,
|
||||||
|
)
|
||||||
|
|
||||||
|
def on_pdu(client: Client, pdu: bytes):
|
||||||
|
client.on_gatt_pdu(att.ATT_PDU.from_bytes(pdu))
|
||||||
|
|
||||||
|
clients = [cls(channel) for channel in channels]
|
||||||
|
for channel, client in zip(channels, clients):
|
||||||
|
channel.sink = functools.partial(on_pdu, client)
|
||||||
|
channel.att_mtu = att.ATT_DEFAULT_MTU
|
||||||
|
return clients[0] if count == 1 else clients
|
||||||
|
|
||||||
|
@property
|
||||||
|
def mtu(self) -> int:
|
||||||
|
return self.bearer.att_mtu
|
||||||
|
|
||||||
|
@mtu.setter
|
||||||
|
def mtu(self, value: int) -> None:
|
||||||
|
self.bearer.on_att_mtu_update(value)
|
||||||
|
|
||||||
def send_gatt_pdu(self, pdu: bytes) -> None:
|
def send_gatt_pdu(self, pdu: bytes) -> None:
|
||||||
self.connection.send_l2cap_pdu(att.ATT_CID, pdu)
|
if att.is_enhanced_bearer(self.bearer):
|
||||||
|
self.bearer.write(pdu)
|
||||||
|
else:
|
||||||
|
self.bearer.send_l2cap_pdu(att.ATT_CID, pdu)
|
||||||
|
|
||||||
async def send_command(self, command: att.ATT_PDU) -> None:
|
async def send_command(self, command: att.ATT_PDU) -> None:
|
||||||
logger.debug(
|
logger.debug(f'GATT Command from client: {self._bearer_id} {command}')
|
||||||
f'GATT Command from client: [0x{self.connection.handle:04X}] {command}'
|
|
||||||
)
|
|
||||||
self.send_gatt_pdu(bytes(command))
|
self.send_gatt_pdu(bytes(command))
|
||||||
|
|
||||||
async def send_request(self, request: att.ATT_PDU):
|
async def send_request(self, request: att.ATT_PDU):
|
||||||
logger.debug(
|
logger.debug(f'GATT Request from client: {self._bearer_id} {request}')
|
||||||
f'GATT Request from client: [0x{self.connection.handle:04X}] {request}'
|
|
||||||
)
|
|
||||||
|
|
||||||
# Wait until we can send (only one pending command at a time for the connection)
|
# Wait until we can send (only one pending command at a time for the connection)
|
||||||
response = None
|
response = None
|
||||||
@@ -326,10 +381,7 @@ class Client:
|
|||||||
def send_confirmation(
|
def send_confirmation(
|
||||||
self, confirmation: att.ATT_Handle_Value_Confirmation
|
self, confirmation: att.ATT_Handle_Value_Confirmation
|
||||||
) -> None:
|
) -> None:
|
||||||
logger.debug(
|
logger.debug(f'GATT Confirmation from client: {self._bearer_id} {confirmation}')
|
||||||
f'GATT Confirmation from client: [0x{self.connection.handle:04X}] '
|
|
||||||
f'{confirmation}'
|
|
||||||
)
|
|
||||||
self.send_gatt_pdu(bytes(confirmation))
|
self.send_gatt_pdu(bytes(confirmation))
|
||||||
|
|
||||||
async def request_mtu(self, mtu: int) -> int:
|
async def request_mtu(self, mtu: int) -> int:
|
||||||
@@ -341,7 +393,7 @@ class Client:
|
|||||||
|
|
||||||
# We can only send one request per connection
|
# We can only send one request per connection
|
||||||
if self.mtu_exchange_done:
|
if self.mtu_exchange_done:
|
||||||
return self.connection.att_mtu
|
return self.mtu
|
||||||
|
|
||||||
# Send the request
|
# Send the request
|
||||||
self.mtu_exchange_done = True
|
self.mtu_exchange_done = True
|
||||||
@@ -352,15 +404,15 @@ class Client:
|
|||||||
raise att.ATT_Error(error_code=response.error_code, message=response)
|
raise att.ATT_Error(error_code=response.error_code, message=response)
|
||||||
|
|
||||||
# Compute the final MTU
|
# Compute the final MTU
|
||||||
self.connection.att_mtu = min(mtu, response.server_rx_mtu)
|
self.mtu = min(mtu, response.server_rx_mtu)
|
||||||
|
|
||||||
return self.connection.att_mtu
|
return self.mtu
|
||||||
|
|
||||||
def get_services_by_uuid(self, uuid: UUID) -> list[ServiceProxy]:
|
def get_services_by_uuid(self, uuid: UUID) -> list[ServiceProxy]:
|
||||||
return [service for service in self.services if service.uuid == uuid]
|
return [service for service in self.services if service.uuid == uuid]
|
||||||
|
|
||||||
def get_characteristics_by_uuid(
|
def get_characteristics_by_uuid(
|
||||||
self, uuid: UUID, service: Optional[ServiceProxy] = None
|
self, uuid: UUID, service: ServiceProxy | None = None
|
||||||
) -> list[CharacteristicProxy[bytes]]:
|
) -> list[CharacteristicProxy[bytes]]:
|
||||||
services = [service] if service else self.services
|
services = [service] if service else self.services
|
||||||
return [
|
return [
|
||||||
@@ -369,13 +421,14 @@ class Client:
|
|||||||
if c.uuid == uuid
|
if c.uuid == uuid
|
||||||
]
|
]
|
||||||
|
|
||||||
def get_attribute_grouping(self, attribute_handle: int) -> Optional[
|
def get_attribute_grouping(
|
||||||
Union[
|
self, attribute_handle: int
|
||||||
ServiceProxy,
|
) -> (
|
||||||
tuple[ServiceProxy, CharacteristicProxy],
|
ServiceProxy
|
||||||
tuple[ServiceProxy, CharacteristicProxy, DescriptorProxy],
|
| tuple[ServiceProxy, CharacteristicProxy]
|
||||||
]
|
| tuple[ServiceProxy, CharacteristicProxy, DescriptorProxy]
|
||||||
]:
|
| None
|
||||||
|
):
|
||||||
"""
|
"""
|
||||||
Get the attribute(s) associated with an attribute handle
|
Get the attribute(s) associated with an attribute handle
|
||||||
"""
|
"""
|
||||||
@@ -478,7 +531,7 @@ class Client:
|
|||||||
|
|
||||||
return services
|
return services
|
||||||
|
|
||||||
async def discover_service(self, uuid: Union[str, UUID]) -> list[ServiceProxy]:
|
async def discover_service(self, uuid: str | UUID) -> list[ServiceProxy]:
|
||||||
'''
|
'''
|
||||||
See Vol 3, Part G - 4.4.2 Discover Primary Service by Service UUID
|
See Vol 3, Part G - 4.4.2 Discover Primary Service by Service UUID
|
||||||
'''
|
'''
|
||||||
@@ -612,7 +665,7 @@ class Client:
|
|||||||
return included_services
|
return included_services
|
||||||
|
|
||||||
async def discover_characteristics(
|
async def discover_characteristics(
|
||||||
self, uuids, service: Optional[ServiceProxy]
|
self, uuids, service: ServiceProxy | None
|
||||||
) -> list[CharacteristicProxy[bytes]]:
|
) -> list[CharacteristicProxy[bytes]]:
|
||||||
'''
|
'''
|
||||||
See Vol 3, Part G - 4.6.1 Discover All Characteristics of a Service and 4.6.2
|
See Vol 3, Part G - 4.6.1 Discover All Characteristics of a Service and 4.6.2
|
||||||
@@ -699,9 +752,9 @@ class Client:
|
|||||||
|
|
||||||
async def discover_descriptors(
|
async def discover_descriptors(
|
||||||
self,
|
self,
|
||||||
characteristic: Optional[CharacteristicProxy] = None,
|
characteristic: CharacteristicProxy | None = None,
|
||||||
start_handle: Optional[int] = None,
|
start_handle: int | None = None,
|
||||||
end_handle: Optional[int] = None,
|
end_handle: int | None = None,
|
||||||
) -> list[DescriptorProxy]:
|
) -> list[DescriptorProxy]:
|
||||||
'''
|
'''
|
||||||
See Vol 3, Part G - 4.7.1 Discover All Characteristic Descriptors
|
See Vol 3, Part G - 4.7.1 Discover All Characteristic Descriptors
|
||||||
@@ -810,7 +863,7 @@ class Client:
|
|||||||
async def subscribe(
|
async def subscribe(
|
||||||
self,
|
self,
|
||||||
characteristic: CharacteristicProxy,
|
characteristic: CharacteristicProxy,
|
||||||
subscriber: Optional[Callable[[Any], Any]] = None,
|
subscriber: Callable[[Any], Any] | None = None,
|
||||||
prefer_notify: bool = True,
|
prefer_notify: bool = True,
|
||||||
) -> None:
|
) -> None:
|
||||||
# If we haven't already discovered the descriptors for this characteristic,
|
# If we haven't already discovered the descriptors for this characteristic,
|
||||||
@@ -860,7 +913,7 @@ class Client:
|
|||||||
async def unsubscribe(
|
async def unsubscribe(
|
||||||
self,
|
self,
|
||||||
characteristic: CharacteristicProxy,
|
characteristic: CharacteristicProxy,
|
||||||
subscriber: Optional[Callable[[Any], Any]] = None,
|
subscriber: Callable[[Any], Any] | None = None,
|
||||||
force: bool = False,
|
force: bool = False,
|
||||||
) -> None:
|
) -> None:
|
||||||
'''
|
'''
|
||||||
@@ -925,7 +978,7 @@ class Client:
|
|||||||
await self.write_value(cccd, b'\x00\x00', with_response=True)
|
await self.write_value(cccd, b'\x00\x00', with_response=True)
|
||||||
|
|
||||||
async def read_value(
|
async def read_value(
|
||||||
self, attribute: Union[int, AttributeProxy], no_long_read: bool = False
|
self, attribute: int | AttributeProxy, no_long_read: bool = False
|
||||||
) -> bytes:
|
) -> bytes:
|
||||||
'''
|
'''
|
||||||
See Vol 3, Part G - 4.8.1 Read Characteristic Value
|
See Vol 3, Part G - 4.8.1 Read Characteristic Value
|
||||||
@@ -946,7 +999,7 @@ class Client:
|
|||||||
# If the value is the max size for the MTU, try to read more unless the caller
|
# If the value is the max size for the MTU, try to read more unless the caller
|
||||||
# specifically asked not to do that
|
# specifically asked not to do that
|
||||||
attribute_value = response.attribute_value
|
attribute_value = response.attribute_value
|
||||||
if not no_long_read and len(attribute_value) == self.connection.att_mtu - 1:
|
if not no_long_read and len(attribute_value) == self.mtu - 1:
|
||||||
logger.debug('using READ BLOB to get the rest of the value')
|
logger.debug('using READ BLOB to get the rest of the value')
|
||||||
offset = len(attribute_value)
|
offset = len(attribute_value)
|
||||||
while True:
|
while True:
|
||||||
@@ -970,7 +1023,7 @@ class Client:
|
|||||||
part = response.part_attribute_value
|
part = response.part_attribute_value
|
||||||
attribute_value += part
|
attribute_value += part
|
||||||
|
|
||||||
if len(part) < self.connection.att_mtu - 1:
|
if len(part) < self.mtu - 1:
|
||||||
break
|
break
|
||||||
|
|
||||||
offset += len(part)
|
offset += len(part)
|
||||||
@@ -980,7 +1033,7 @@ class Client:
|
|||||||
return attribute_value
|
return attribute_value
|
||||||
|
|
||||||
async def read_characteristics_by_uuid(
|
async def read_characteristics_by_uuid(
|
||||||
self, uuid: UUID, service: Optional[ServiceProxy]
|
self, uuid: UUID, service: ServiceProxy | None
|
||||||
) -> list[bytes]:
|
) -> list[bytes]:
|
||||||
'''
|
'''
|
||||||
See Vol 3, Part G - 4.8.2 Read Using Characteristic UUID
|
See Vol 3, Part G - 4.8.2 Read Using Characteristic UUID
|
||||||
@@ -1038,7 +1091,7 @@ class Client:
|
|||||||
|
|
||||||
async def write_value(
|
async def write_value(
|
||||||
self,
|
self,
|
||||||
attribute: Union[int, AttributeProxy],
|
attribute: int | AttributeProxy,
|
||||||
value: bytes,
|
value: bytes,
|
||||||
with_response: bool = False,
|
with_response: bool = False,
|
||||||
) -> None:
|
) -> None:
|
||||||
@@ -1066,14 +1119,13 @@ class Client:
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
def on_disconnection(self, _) -> None:
|
def on_disconnection(self, *args) -> None:
|
||||||
|
del args # unused.
|
||||||
if self.pending_response and not self.pending_response.done():
|
if self.pending_response and not self.pending_response.done():
|
||||||
self.pending_response.cancel()
|
self.pending_response.cancel()
|
||||||
|
|
||||||
def on_gatt_pdu(self, att_pdu: att.ATT_PDU) -> None:
|
def on_gatt_pdu(self, att_pdu: att.ATT_PDU) -> None:
|
||||||
logger.debug(
|
logger.debug(f'GATT Response to client: {self._bearer_id} {att_pdu}')
|
||||||
f'GATT Response to client: [0x{self.connection.handle:04X}] {att_pdu}'
|
|
||||||
)
|
|
||||||
if att_pdu.op_code in att.ATT_RESPONSES:
|
if att_pdu.op_code in att.ATT_RESPONSES:
|
||||||
if self.pending_request is None:
|
if self.pending_request is None:
|
||||||
# Not expected!
|
# Not expected!
|
||||||
@@ -1103,8 +1155,7 @@ class Client:
|
|||||||
else:
|
else:
|
||||||
logger.warning(
|
logger.warning(
|
||||||
color(
|
color(
|
||||||
'--- Ignoring GATT Response from '
|
'--- Ignoring GATT Response from ' f'{self._bearer_id}: ',
|
||||||
f'[0x{self.connection.handle:04X}]: ',
|
|
||||||
'red',
|
'red',
|
||||||
)
|
)
|
||||||
+ str(att_pdu)
|
+ str(att_pdu)
|
||||||
|
|||||||
@@ -29,11 +29,11 @@ import asyncio
|
|||||||
import logging
|
import logging
|
||||||
import struct
|
import struct
|
||||||
from collections import defaultdict
|
from collections import defaultdict
|
||||||
from typing import TYPE_CHECKING, Iterable, Optional, TypeVar
|
from collections.abc import Iterable
|
||||||
|
from typing import TYPE_CHECKING, TypeVar
|
||||||
|
|
||||||
from bumble import att, utils
|
from bumble import att, core, l2cap, utils
|
||||||
from bumble.colors import color
|
from bumble.colors import color
|
||||||
from bumble.core import UUID
|
|
||||||
from bumble.gatt import (
|
from bumble.gatt import (
|
||||||
GATT_CHARACTERISTIC_ATTRIBUTE_TYPE,
|
GATT_CHARACTERISTIC_ATTRIBUTE_TYPE,
|
||||||
GATT_CLIENT_CHARACTERISTIC_CONFIGURATION_DESCRIPTOR,
|
GATT_CLIENT_CHARACTERISTIC_CONFIGURATION_DESCRIPTOR,
|
||||||
@@ -43,14 +43,13 @@ from bumble.gatt import (
|
|||||||
GATT_SECONDARY_SERVICE_ATTRIBUTE_TYPE,
|
GATT_SECONDARY_SERVICE_ATTRIBUTE_TYPE,
|
||||||
Characteristic,
|
Characteristic,
|
||||||
CharacteristicDeclaration,
|
CharacteristicDeclaration,
|
||||||
CharacteristicValue,
|
|
||||||
Descriptor,
|
Descriptor,
|
||||||
IncludedServiceDeclaration,
|
IncludedServiceDeclaration,
|
||||||
Service,
|
Service,
|
||||||
)
|
)
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from bumble.device import Connection, Device
|
from bumble.device import Device
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
# Logging
|
# Logging
|
||||||
@@ -64,6 +63,18 @@ logger = logging.getLogger(__name__)
|
|||||||
GATT_SERVER_DEFAULT_MAX_MTU = 517
|
GATT_SERVER_DEFAULT_MAX_MTU = 517
|
||||||
|
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
# Helpers
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def _bearer_id(bearer: att.Bearer) -> str:
|
||||||
|
if att.is_enhanced_bearer(bearer):
|
||||||
|
return f'[0x{bearer.connection.handle:04X}|CID=0x{bearer.source_cid:04X}]'
|
||||||
|
else:
|
||||||
|
return f'[0x{bearer.handle:04X}]'
|
||||||
|
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
# GATT Server
|
# GATT Server
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
@@ -71,9 +82,9 @@ class Server(utils.EventEmitter):
|
|||||||
attributes: list[att.Attribute]
|
attributes: list[att.Attribute]
|
||||||
services: list[Service]
|
services: list[Service]
|
||||||
attributes_by_handle: dict[int, att.Attribute]
|
attributes_by_handle: dict[int, att.Attribute]
|
||||||
subscribers: dict[int, dict[int, bytes]]
|
subscribers: dict[att.Bearer, dict[int, bytes]]
|
||||||
indication_semaphores: defaultdict[int, asyncio.Semaphore]
|
indication_semaphores: defaultdict[att.Bearer, asyncio.Semaphore]
|
||||||
pending_confirmations: defaultdict[int, Optional[asyncio.futures.Future]]
|
pending_confirmations: defaultdict[att.Bearer, asyncio.futures.Future | None]
|
||||||
|
|
||||||
EVENT_CHARACTERISTIC_SUBSCRIPTION = "characteristic_subscription"
|
EVENT_CHARACTERISTIC_SUBSCRIPTION = "characteristic_subscription"
|
||||||
|
|
||||||
@@ -95,8 +106,28 @@ class Server(utils.EventEmitter):
|
|||||||
def __str__(self) -> str:
|
def __str__(self) -> str:
|
||||||
return "\n".join(map(str, self.attributes))
|
return "\n".join(map(str, self.attributes))
|
||||||
|
|
||||||
def send_gatt_pdu(self, connection_handle: int, pdu: bytes) -> None:
|
def register_eatt(
|
||||||
self.device.send_l2cap_pdu(connection_handle, att.ATT_CID, pdu)
|
self, spec: l2cap.LeCreditBasedChannelSpec | None = None
|
||||||
|
) -> l2cap.LeCreditBasedChannelServer:
|
||||||
|
def on_channel(channel: l2cap.LeCreditBasedChannel):
|
||||||
|
logger.debug(
|
||||||
|
"New EATT Bearer Connection=0x%04X CID=0x%04X",
|
||||||
|
channel.connection.handle,
|
||||||
|
channel.source_cid,
|
||||||
|
)
|
||||||
|
channel.sink = lambda pdu: self.on_gatt_pdu(
|
||||||
|
channel, att.ATT_PDU.from_bytes(pdu)
|
||||||
|
)
|
||||||
|
|
||||||
|
return self.device.create_l2cap_server(
|
||||||
|
spec or l2cap.LeCreditBasedChannelSpec(psm=att.EATT_PSM), handler=on_channel
|
||||||
|
)
|
||||||
|
|
||||||
|
def send_gatt_pdu(self, bearer: att.Bearer, pdu: bytes) -> None:
|
||||||
|
if att.is_enhanced_bearer(bearer):
|
||||||
|
bearer.write(pdu)
|
||||||
|
else:
|
||||||
|
self.device.send_l2cap_pdu(bearer.handle, att.ATT_CID, pdu)
|
||||||
|
|
||||||
def next_handle(self) -> int:
|
def next_handle(self) -> int:
|
||||||
return 1 + len(self.attributes)
|
return 1 + len(self.attributes)
|
||||||
@@ -109,7 +140,7 @@ class Server(utils.EventEmitter):
|
|||||||
and (data := attribute.get_advertising_data())
|
and (data := attribute.get_advertising_data())
|
||||||
}
|
}
|
||||||
|
|
||||||
def get_attribute(self, handle: int) -> Optional[att.Attribute]:
|
def get_attribute(self, handle: int) -> att.Attribute | None:
|
||||||
attribute = self.attributes_by_handle.get(handle)
|
attribute = self.attributes_by_handle.get(handle)
|
||||||
if attribute:
|
if attribute:
|
||||||
return attribute
|
return attribute
|
||||||
@@ -126,7 +157,7 @@ class Server(utils.EventEmitter):
|
|||||||
|
|
||||||
def get_attribute_group(
|
def get_attribute_group(
|
||||||
self, handle: int, group_type: type[AttributeGroupType]
|
self, handle: int, group_type: type[AttributeGroupType]
|
||||||
) -> Optional[AttributeGroupType]:
|
) -> AttributeGroupType | None:
|
||||||
return next(
|
return next(
|
||||||
(
|
(
|
||||||
attribute
|
attribute
|
||||||
@@ -137,7 +168,7 @@ class Server(utils.EventEmitter):
|
|||||||
None,
|
None,
|
||||||
)
|
)
|
||||||
|
|
||||||
def get_service_attribute(self, service_uuid: UUID) -> Optional[Service]:
|
def get_service_attribute(self, service_uuid: core.UUID) -> Service | None:
|
||||||
return next(
|
return next(
|
||||||
(
|
(
|
||||||
attribute
|
attribute
|
||||||
@@ -150,8 +181,8 @@ class Server(utils.EventEmitter):
|
|||||||
)
|
)
|
||||||
|
|
||||||
def get_characteristic_attributes(
|
def get_characteristic_attributes(
|
||||||
self, service_uuid: UUID, characteristic_uuid: UUID
|
self, service_uuid: core.UUID, characteristic_uuid: core.UUID
|
||||||
) -> Optional[tuple[CharacteristicDeclaration, Characteristic]]:
|
) -> tuple[CharacteristicDeclaration, Characteristic] | None:
|
||||||
service_handle = self.get_service_attribute(service_uuid)
|
service_handle = self.get_service_attribute(service_uuid)
|
||||||
if not service_handle:
|
if not service_handle:
|
||||||
return None
|
return None
|
||||||
@@ -175,8 +206,11 @@ class Server(utils.EventEmitter):
|
|||||||
)
|
)
|
||||||
|
|
||||||
def get_descriptor_attribute(
|
def get_descriptor_attribute(
|
||||||
self, service_uuid: UUID, characteristic_uuid: UUID, descriptor_uuid: UUID
|
self,
|
||||||
) -> Optional[Descriptor]:
|
service_uuid: core.UUID,
|
||||||
|
characteristic_uuid: core.UUID,
|
||||||
|
descriptor_uuid: core.UUID,
|
||||||
|
) -> Descriptor | None:
|
||||||
characteristics = self.get_characteristic_attributes(
|
characteristics = self.get_characteristic_attributes(
|
||||||
service_uuid, characteristic_uuid
|
service_uuid, characteristic_uuid
|
||||||
)
|
)
|
||||||
@@ -256,14 +290,7 @@ class Server(utils.EventEmitter):
|
|||||||
Descriptor(
|
Descriptor(
|
||||||
GATT_CLIENT_CHARACTERISTIC_CONFIGURATION_DESCRIPTOR,
|
GATT_CLIENT_CHARACTERISTIC_CONFIGURATION_DESCRIPTOR,
|
||||||
att.Attribute.READABLE | att.Attribute.WRITEABLE,
|
att.Attribute.READABLE | att.Attribute.WRITEABLE,
|
||||||
CharacteristicValue(
|
self.make_descriptor_value(characteristic),
|
||||||
read=lambda connection, characteristic=characteristic: self.read_cccd(
|
|
||||||
connection, characteristic
|
|
||||||
),
|
|
||||||
write=lambda connection, value, characteristic=characteristic: self.write_cccd(
|
|
||||||
connection, characteristic, value
|
|
||||||
),
|
|
||||||
),
|
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -279,10 +306,21 @@ class Server(utils.EventEmitter):
|
|||||||
for service in services:
|
for service in services:
|
||||||
self.add_service(service)
|
self.add_service(service)
|
||||||
|
|
||||||
def read_cccd(
|
def make_descriptor_value(
|
||||||
self, connection: Connection, characteristic: Characteristic
|
self, characteristic: Characteristic
|
||||||
) -> bytes:
|
) -> att.AttributeValueV2:
|
||||||
subscribers = self.subscribers.get(connection.handle)
|
# It is necessary to use Attribute Value V2 here to identify the bearer of CCCD.
|
||||||
|
return att.AttributeValueV2(
|
||||||
|
lambda bearer, characteristic=characteristic: self.read_cccd(
|
||||||
|
bearer, characteristic
|
||||||
|
),
|
||||||
|
write=lambda bearer, value, characteristic=characteristic: self.write_cccd(
|
||||||
|
bearer, characteristic, value
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
def read_cccd(self, bearer: att.Bearer, characteristic: Characteristic) -> bytes:
|
||||||
|
subscribers = self.subscribers.get(bearer)
|
||||||
cccd = None
|
cccd = None
|
||||||
if subscribers:
|
if subscribers:
|
||||||
cccd = subscribers.get(characteristic.handle)
|
cccd = subscribers.get(characteristic.handle)
|
||||||
@@ -291,12 +329,12 @@ class Server(utils.EventEmitter):
|
|||||||
|
|
||||||
def write_cccd(
|
def write_cccd(
|
||||||
self,
|
self,
|
||||||
connection: Connection,
|
bearer: att.Bearer,
|
||||||
characteristic: Characteristic,
|
characteristic: Characteristic,
|
||||||
value: bytes,
|
value: bytes,
|
||||||
) -> None:
|
) -> None:
|
||||||
logger.debug(
|
logger.debug(
|
||||||
f'Subscription update for connection=0x{connection.handle:04X}, '
|
f'Subscription update for connection={_bearer_id(bearer)}, '
|
||||||
f'handle=0x{characteristic.handle:04X}: {value.hex()}'
|
f'handle=0x{characteristic.handle:04X}: {value.hex()}'
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -305,41 +343,60 @@ class Server(utils.EventEmitter):
|
|||||||
logger.warning('CCCD value not 2 bytes long')
|
logger.warning('CCCD value not 2 bytes long')
|
||||||
return
|
return
|
||||||
|
|
||||||
cccds = self.subscribers.setdefault(connection.handle, {})
|
cccds = self.subscribers.setdefault(bearer, {})
|
||||||
cccds[characteristic.handle] = value
|
cccds[characteristic.handle] = value
|
||||||
logger.debug(f'CCCDs: {cccds}')
|
logger.debug(f'CCCDs: {cccds}')
|
||||||
notify_enabled = value[0] & 0x01 != 0
|
notify_enabled = value[0] & 0x01 != 0
|
||||||
indicate_enabled = value[0] & 0x02 != 0
|
indicate_enabled = value[0] & 0x02 != 0
|
||||||
characteristic.emit(
|
characteristic.emit(
|
||||||
characteristic.EVENT_SUBSCRIPTION,
|
characteristic.EVENT_SUBSCRIPTION,
|
||||||
connection,
|
bearer,
|
||||||
notify_enabled,
|
notify_enabled,
|
||||||
indicate_enabled,
|
indicate_enabled,
|
||||||
)
|
)
|
||||||
self.emit(
|
self.emit(
|
||||||
self.EVENT_CHARACTERISTIC_SUBSCRIPTION,
|
self.EVENT_CHARACTERISTIC_SUBSCRIPTION,
|
||||||
connection,
|
bearer,
|
||||||
characteristic,
|
characteristic,
|
||||||
notify_enabled,
|
notify_enabled,
|
||||||
indicate_enabled,
|
indicate_enabled,
|
||||||
)
|
)
|
||||||
|
|
||||||
def send_response(self, connection: Connection, response: att.ATT_PDU) -> None:
|
def send_response(self, bearer: att.Bearer, response: att.ATT_PDU) -> None:
|
||||||
logger.debug(
|
logger.debug(f'GATT Response from server: {_bearer_id(bearer)} {response}')
|
||||||
f'GATT Response from server: [0x{connection.handle:04X}] {response}'
|
self.send_gatt_pdu(bearer, bytes(response))
|
||||||
)
|
|
||||||
self.send_gatt_pdu(connection.handle, bytes(response))
|
|
||||||
|
|
||||||
async def notify_subscriber(
|
async def notify_subscriber(
|
||||||
self,
|
self,
|
||||||
connection: Connection,
|
bearer: att.Bearer,
|
||||||
attribute: att.Attribute,
|
attribute: att.Attribute,
|
||||||
value: Optional[bytes] = None,
|
value: bytes | None = None,
|
||||||
force: bool = False,
|
force: bool = False,
|
||||||
|
) -> None:
|
||||||
|
if att.is_enhanced_bearer(bearer) or force:
|
||||||
|
return await self._notify_single_subscriber(bearer, attribute, value, force)
|
||||||
|
else:
|
||||||
|
# If API is called to a Connection and not forced, try to notify all subscribed bearers on it.
|
||||||
|
bearers = [
|
||||||
|
channel
|
||||||
|
for channel in self.device.l2cap_channel_manager.le_coc_channels.get(
|
||||||
|
bearer.handle, {}
|
||||||
|
).values()
|
||||||
|
if channel.psm == att.EATT_PSM
|
||||||
|
] + [bearer]
|
||||||
|
for bearer in bearers:
|
||||||
|
await self._notify_single_subscriber(bearer, attribute, value, force)
|
||||||
|
|
||||||
|
async def _notify_single_subscriber(
|
||||||
|
self,
|
||||||
|
bearer: att.Bearer,
|
||||||
|
attribute: att.Attribute,
|
||||||
|
value: bytes | None,
|
||||||
|
force: bool,
|
||||||
) -> None:
|
) -> None:
|
||||||
# Check if there's a subscriber
|
# Check if there's a subscriber
|
||||||
if not force:
|
if not force:
|
||||||
subscribers = self.subscribers.get(connection.handle)
|
subscribers = self.subscribers.get(bearer)
|
||||||
if not subscribers:
|
if not subscribers:
|
||||||
logger.debug('not notifying, no subscribers')
|
logger.debug('not notifying, no subscribers')
|
||||||
return
|
return
|
||||||
@@ -355,34 +412,53 @@ class Server(utils.EventEmitter):
|
|||||||
|
|
||||||
# Get or encode the value
|
# Get or encode the value
|
||||||
value = (
|
value = (
|
||||||
await attribute.read_value(connection)
|
await attribute.read_value(bearer)
|
||||||
if value is None
|
if value is None
|
||||||
else attribute.encode_value(value)
|
else attribute.encode_value(value)
|
||||||
)
|
)
|
||||||
|
|
||||||
# Truncate if needed
|
# Truncate if needed
|
||||||
if len(value) > connection.att_mtu - 3:
|
if len(value) > bearer.att_mtu - 3:
|
||||||
value = value[: connection.att_mtu - 3]
|
value = value[: bearer.att_mtu - 3]
|
||||||
|
|
||||||
# Notify
|
# Notify
|
||||||
notification = att.ATT_Handle_Value_Notification(
|
notification = att.ATT_Handle_Value_Notification(
|
||||||
attribute_handle=attribute.handle, attribute_value=value
|
attribute_handle=attribute.handle, attribute_value=value
|
||||||
)
|
)
|
||||||
logger.debug(
|
logger.debug(f'GATT Notify from server: {_bearer_id(bearer)} {notification}')
|
||||||
f'GATT Notify from server: [0x{connection.handle:04X}] {notification}'
|
self.send_gatt_pdu(bearer, bytes(notification))
|
||||||
)
|
|
||||||
self.send_gatt_pdu(connection.handle, bytes(notification))
|
|
||||||
|
|
||||||
async def indicate_subscriber(
|
async def indicate_subscriber(
|
||||||
self,
|
self,
|
||||||
connection: Connection,
|
bearer: att.Bearer,
|
||||||
attribute: att.Attribute,
|
attribute: att.Attribute,
|
||||||
value: Optional[bytes] = None,
|
value: bytes | None = None,
|
||||||
force: bool = False,
|
force: bool = False,
|
||||||
|
) -> None:
|
||||||
|
if att.is_enhanced_bearer(bearer) or force:
|
||||||
|
return await self._notify_single_subscriber(bearer, attribute, value, force)
|
||||||
|
else:
|
||||||
|
# If API is called to a Connection and not forced, try to indicate all subscribed bearers on it.
|
||||||
|
bearers = [
|
||||||
|
channel
|
||||||
|
for channel in self.device.l2cap_channel_manager.le_coc_channels.get(
|
||||||
|
bearer.handle, {}
|
||||||
|
).values()
|
||||||
|
if channel.psm == att.EATT_PSM
|
||||||
|
] + [bearer]
|
||||||
|
for bearer in bearers:
|
||||||
|
await self._indicate_single_bearer(bearer, attribute, value, force)
|
||||||
|
|
||||||
|
async def _indicate_single_bearer(
|
||||||
|
self,
|
||||||
|
bearer: att.Bearer,
|
||||||
|
attribute: att.Attribute,
|
||||||
|
value: bytes | None,
|
||||||
|
force: bool,
|
||||||
) -> None:
|
) -> None:
|
||||||
# Check if there's a subscriber
|
# Check if there's a subscriber
|
||||||
if not force:
|
if not force:
|
||||||
subscribers = self.subscribers.get(connection.handle)
|
subscribers = self.subscribers.get(bearer)
|
||||||
if not subscribers:
|
if not subscribers:
|
||||||
logger.debug('not indicating, no subscribers')
|
logger.debug('not indicating, no subscribers')
|
||||||
return
|
return
|
||||||
@@ -398,73 +474,71 @@ class Server(utils.EventEmitter):
|
|||||||
|
|
||||||
# Get or encode the value
|
# Get or encode the value
|
||||||
value = (
|
value = (
|
||||||
await attribute.read_value(connection)
|
await attribute.read_value(bearer)
|
||||||
if value is None
|
if value is None
|
||||||
else attribute.encode_value(value)
|
else attribute.encode_value(value)
|
||||||
)
|
)
|
||||||
|
|
||||||
# Truncate if needed
|
# Truncate if needed
|
||||||
if len(value) > connection.att_mtu - 3:
|
if len(value) > bearer.att_mtu - 3:
|
||||||
value = value[: connection.att_mtu - 3]
|
value = value[: bearer.att_mtu - 3]
|
||||||
|
|
||||||
# Indicate
|
# Indicate
|
||||||
indication = att.ATT_Handle_Value_Indication(
|
indication = att.ATT_Handle_Value_Indication(
|
||||||
attribute_handle=attribute.handle, attribute_value=value
|
attribute_handle=attribute.handle, attribute_value=value
|
||||||
)
|
)
|
||||||
logger.debug(
|
logger.debug(f'GATT Indicate from server: {_bearer_id(bearer)} {indication}')
|
||||||
f'GATT Indicate from server: [0x{connection.handle:04X}] {indication}'
|
|
||||||
)
|
|
||||||
|
|
||||||
# Wait until we can send (only one pending indication at a time per connection)
|
# Wait until we can send (only one pending indication at a time per connection)
|
||||||
async with self.indication_semaphores[connection.handle]:
|
async with self.indication_semaphores[bearer]:
|
||||||
assert self.pending_confirmations[connection.handle] is None
|
assert self.pending_confirmations[bearer] is None
|
||||||
|
|
||||||
# Create a future value to hold the eventual response
|
# Create a future value to hold the eventual response
|
||||||
pending_confirmation = self.pending_confirmations[connection.handle] = (
|
pending_confirmation = self.pending_confirmations[bearer] = (
|
||||||
asyncio.get_running_loop().create_future()
|
asyncio.get_running_loop().create_future()
|
||||||
)
|
)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
self.send_gatt_pdu(connection.handle, bytes(indication))
|
self.send_gatt_pdu(bearer, bytes(indication))
|
||||||
await asyncio.wait_for(pending_confirmation, GATT_REQUEST_TIMEOUT)
|
await asyncio.wait_for(pending_confirmation, GATT_REQUEST_TIMEOUT)
|
||||||
except asyncio.TimeoutError as error:
|
except asyncio.TimeoutError as error:
|
||||||
logger.warning(color('!!! GATT Indicate timeout', 'red'))
|
logger.warning(color('!!! GATT Indicate timeout', 'red'))
|
||||||
raise TimeoutError(f'GATT timeout for {indication.name}') from error
|
raise TimeoutError(f'GATT timeout for {indication.name}') from error
|
||||||
finally:
|
finally:
|
||||||
self.pending_confirmations[connection.handle] = None
|
self.pending_confirmations[bearer] = None
|
||||||
|
|
||||||
async def _notify_or_indicate_subscribers(
|
async def _notify_or_indicate_subscribers(
|
||||||
self,
|
self,
|
||||||
indicate: bool,
|
indicate: bool,
|
||||||
attribute: att.Attribute,
|
attribute: att.Attribute,
|
||||||
value: Optional[bytes] = None,
|
value: bytes | None = None,
|
||||||
force: bool = False,
|
force: bool = False,
|
||||||
) -> None:
|
) -> None:
|
||||||
# Get all the connections for which there's at least one subscription
|
# Get all the bearers for which there's at least one subscription
|
||||||
connections = [
|
bearers: list[att.Bearer] = [
|
||||||
connection
|
bearer
|
||||||
for connection in [
|
for bearer, subscribers in self.subscribers.items()
|
||||||
self.device.lookup_connection(connection_handle)
|
if force or subscribers.get(attribute.handle)
|
||||||
for (connection_handle, subscribers) in self.subscribers.items()
|
|
||||||
if force or subscribers.get(attribute.handle)
|
|
||||||
]
|
|
||||||
if connection is not None
|
|
||||||
]
|
]
|
||||||
|
|
||||||
# Indicate or notify for each connection
|
# Indicate or notify for each connection
|
||||||
if connections:
|
if bearers:
|
||||||
coroutine = self.indicate_subscriber if indicate else self.notify_subscriber
|
coroutine = (
|
||||||
|
self._indicate_single_bearer
|
||||||
|
if indicate
|
||||||
|
else self._notify_single_subscriber
|
||||||
|
)
|
||||||
await asyncio.wait(
|
await asyncio.wait(
|
||||||
[
|
[
|
||||||
asyncio.create_task(coroutine(connection, attribute, value, force))
|
asyncio.create_task(coroutine(bearer, attribute, value, force))
|
||||||
for connection in connections
|
for bearer in bearers
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
|
|
||||||
async def notify_subscribers(
|
async def notify_subscribers(
|
||||||
self,
|
self,
|
||||||
attribute: att.Attribute,
|
attribute: att.Attribute,
|
||||||
value: Optional[bytes] = None,
|
value: bytes | None = None,
|
||||||
force: bool = False,
|
force: bool = False,
|
||||||
):
|
):
|
||||||
return await self._notify_or_indicate_subscribers(
|
return await self._notify_or_indicate_subscribers(
|
||||||
@@ -474,26 +548,23 @@ class Server(utils.EventEmitter):
|
|||||||
async def indicate_subscribers(
|
async def indicate_subscribers(
|
||||||
self,
|
self,
|
||||||
attribute: att.Attribute,
|
attribute: att.Attribute,
|
||||||
value: Optional[bytes] = None,
|
value: bytes | None = None,
|
||||||
force: bool = False,
|
force: bool = False,
|
||||||
):
|
):
|
||||||
return await self._notify_or_indicate_subscribers(True, attribute, value, force)
|
return await self._notify_or_indicate_subscribers(True, attribute, value, force)
|
||||||
|
|
||||||
def on_disconnection(self, connection: Connection) -> None:
|
def on_disconnection(self, bearer: att.Bearer) -> None:
|
||||||
if connection.handle in self.subscribers:
|
self.subscribers.pop(bearer, None)
|
||||||
del self.subscribers[connection.handle]
|
self.indication_semaphores.pop(bearer, None)
|
||||||
if connection.handle in self.indication_semaphores:
|
self.pending_confirmations.pop(bearer, None)
|
||||||
del self.indication_semaphores[connection.handle]
|
|
||||||
if connection.handle in self.pending_confirmations:
|
|
||||||
del self.pending_confirmations[connection.handle]
|
|
||||||
|
|
||||||
def on_gatt_pdu(self, connection: Connection, att_pdu: att.ATT_PDU) -> None:
|
def on_gatt_pdu(self, bearer: att.Bearer, att_pdu: att.ATT_PDU) -> None:
|
||||||
logger.debug(f'GATT Request to server: [0x{connection.handle:04X}] {att_pdu}')
|
logger.debug(f'GATT Request to server: {_bearer_id(bearer)} {att_pdu}')
|
||||||
handler_name = f'on_{att_pdu.name.lower()}'
|
handler_name = f'on_{att_pdu.name.lower()}'
|
||||||
handler = getattr(self, handler_name, None)
|
handler = getattr(self, handler_name, None)
|
||||||
if handler is not None:
|
if handler is not None:
|
||||||
try:
|
try:
|
||||||
handler(connection, att_pdu)
|
handler(bearer, att_pdu)
|
||||||
except att.ATT_Error as error:
|
except att.ATT_Error as error:
|
||||||
logger.debug(f'normal exception returned by handler: {error}')
|
logger.debug(f'normal exception returned by handler: {error}')
|
||||||
response = att.ATT_Error_Response(
|
response = att.ATT_Error_Response(
|
||||||
@@ -501,7 +572,7 @@ class Server(utils.EventEmitter):
|
|||||||
attribute_handle_in_error=error.att_handle,
|
attribute_handle_in_error=error.att_handle,
|
||||||
error_code=error.error_code,
|
error_code=error.error_code,
|
||||||
)
|
)
|
||||||
self.send_response(connection, response)
|
self.send_response(bearer, response)
|
||||||
except Exception:
|
except Exception:
|
||||||
logger.exception(color("!!! Exception in handler:", "red"))
|
logger.exception(color("!!! Exception in handler:", "red"))
|
||||||
response = att.ATT_Error_Response(
|
response = att.ATT_Error_Response(
|
||||||
@@ -509,18 +580,18 @@ class Server(utils.EventEmitter):
|
|||||||
attribute_handle_in_error=0x0000,
|
attribute_handle_in_error=0x0000,
|
||||||
error_code=att.ATT_UNLIKELY_ERROR_ERROR,
|
error_code=att.ATT_UNLIKELY_ERROR_ERROR,
|
||||||
)
|
)
|
||||||
self.send_response(connection, response)
|
self.send_response(bearer, response)
|
||||||
raise
|
raise
|
||||||
else:
|
else:
|
||||||
# No specific handler registered
|
# No specific handler registered
|
||||||
if att_pdu.op_code in att.ATT_REQUESTS:
|
if att_pdu.op_code in att.ATT_REQUESTS:
|
||||||
# Invoke the generic handler
|
# Invoke the generic handler
|
||||||
self.on_att_request(connection, att_pdu)
|
self.on_att_request(bearer, att_pdu)
|
||||||
else:
|
else:
|
||||||
# Just ignore
|
# Just ignore
|
||||||
logger.warning(
|
logger.warning(
|
||||||
color(
|
color(
|
||||||
f'--- Ignoring GATT Request from [0x{connection.handle:04X}]: ',
|
f'--- Ignoring GATT Request from {_bearer_id(bearer)}: ',
|
||||||
'red',
|
'red',
|
||||||
)
|
)
|
||||||
+ str(att_pdu)
|
+ str(att_pdu)
|
||||||
@@ -529,13 +600,14 @@ class Server(utils.EventEmitter):
|
|||||||
#######################################################
|
#######################################################
|
||||||
# ATT handlers
|
# ATT handlers
|
||||||
#######################################################
|
#######################################################
|
||||||
def on_att_request(self, connection: Connection, pdu: att.ATT_PDU) -> None:
|
def on_att_request(self, bearer: att.Bearer, pdu: att.ATT_PDU) -> None:
|
||||||
'''
|
'''
|
||||||
Handler for requests without a more specific handler
|
Handler for requests without a more specific handler
|
||||||
'''
|
'''
|
||||||
logger.warning(
|
logger.warning(
|
||||||
color(
|
color(
|
||||||
f'--- Unsupported ATT Request from [0x{connection.handle:04X}]: ', 'red'
|
f'--- Unsupported ATT Request from {_bearer_id(bearer)}: ',
|
||||||
|
'red',
|
||||||
)
|
)
|
||||||
+ str(pdu)
|
+ str(pdu)
|
||||||
)
|
)
|
||||||
@@ -544,29 +616,28 @@ class Server(utils.EventEmitter):
|
|||||||
attribute_handle_in_error=0x0000,
|
attribute_handle_in_error=0x0000,
|
||||||
error_code=att.ATT_REQUEST_NOT_SUPPORTED_ERROR,
|
error_code=att.ATT_REQUEST_NOT_SUPPORTED_ERROR,
|
||||||
)
|
)
|
||||||
self.send_response(connection, response)
|
self.send_response(bearer, response)
|
||||||
|
|
||||||
def on_att_exchange_mtu_request(
|
def on_att_exchange_mtu_request(
|
||||||
self, connection: Connection, request: att.ATT_Exchange_MTU_Request
|
self, bearer: att.Bearer, request: att.ATT_Exchange_MTU_Request
|
||||||
):
|
):
|
||||||
'''
|
'''
|
||||||
See Bluetooth spec Vol 3, Part F - 3.4.2.1 Exchange MTU Request
|
See Bluetooth spec Vol 3, Part F - 3.4.2.1 Exchange MTU Request
|
||||||
'''
|
'''
|
||||||
self.send_response(
|
self.send_response(
|
||||||
connection, att.ATT_Exchange_MTU_Response(server_rx_mtu=self.max_mtu)
|
bearer, att.ATT_Exchange_MTU_Response(server_rx_mtu=self.max_mtu)
|
||||||
)
|
)
|
||||||
|
|
||||||
# Compute the final MTU
|
# Compute the final MTU
|
||||||
if request.client_rx_mtu >= att.ATT_DEFAULT_MTU:
|
if request.client_rx_mtu >= att.ATT_DEFAULT_MTU:
|
||||||
mtu = min(self.max_mtu, request.client_rx_mtu)
|
mtu = min(self.max_mtu, request.client_rx_mtu)
|
||||||
|
|
||||||
# Notify the device
|
bearer.on_att_mtu_update(mtu)
|
||||||
self.device.on_connection_att_mtu_update(connection.handle, mtu)
|
|
||||||
else:
|
else:
|
||||||
logger.warning('invalid client_rx_mtu received, MTU not changed')
|
logger.warning('invalid client_rx_mtu received, MTU not changed')
|
||||||
|
|
||||||
def on_att_find_information_request(
|
def on_att_find_information_request(
|
||||||
self, connection: Connection, request: att.ATT_Find_Information_Request
|
self, bearer: att.Bearer, request: att.ATT_Find_Information_Request
|
||||||
):
|
):
|
||||||
'''
|
'''
|
||||||
See Bluetooth spec Vol 3, Part F - 3.4.3.1 Find Information Request
|
See Bluetooth spec Vol 3, Part F - 3.4.3.1 Find Information Request
|
||||||
@@ -579,7 +650,7 @@ class Server(utils.EventEmitter):
|
|||||||
or request.starting_handle > request.ending_handle
|
or request.starting_handle > request.ending_handle
|
||||||
):
|
):
|
||||||
self.send_response(
|
self.send_response(
|
||||||
connection,
|
bearer,
|
||||||
att.ATT_Error_Response(
|
att.ATT_Error_Response(
|
||||||
request_opcode_in_error=request.op_code,
|
request_opcode_in_error=request.op_code,
|
||||||
attribute_handle_in_error=request.starting_handle,
|
attribute_handle_in_error=request.starting_handle,
|
||||||
@@ -589,7 +660,7 @@ class Server(utils.EventEmitter):
|
|||||||
return
|
return
|
||||||
|
|
||||||
# Build list of returned attributes
|
# Build list of returned attributes
|
||||||
pdu_space_available = connection.att_mtu - 2
|
pdu_space_available = bearer.att_mtu - 2
|
||||||
attributes: list[att.Attribute] = []
|
attributes: list[att.Attribute] = []
|
||||||
uuid_size = 0
|
uuid_size = 0
|
||||||
for attribute in (
|
for attribute in (
|
||||||
@@ -631,18 +702,18 @@ class Server(utils.EventEmitter):
|
|||||||
error_code=att.ATT_ATTRIBUTE_NOT_FOUND_ERROR,
|
error_code=att.ATT_ATTRIBUTE_NOT_FOUND_ERROR,
|
||||||
)
|
)
|
||||||
|
|
||||||
self.send_response(connection, response)
|
self.send_response(bearer, response)
|
||||||
|
|
||||||
@utils.AsyncRunner.run_in_task()
|
@utils.AsyncRunner.run_in_task()
|
||||||
async def on_att_find_by_type_value_request(
|
async def on_att_find_by_type_value_request(
|
||||||
self, connection: Connection, request: att.ATT_Find_By_Type_Value_Request
|
self, bearer: att.Bearer, request: att.ATT_Find_By_Type_Value_Request
|
||||||
):
|
):
|
||||||
'''
|
'''
|
||||||
See Bluetooth spec Vol 3, Part F - 3.4.3.3 Find By Type Value Request
|
See Bluetooth spec Vol 3, Part F - 3.4.3.3 Find By Type Value Request
|
||||||
'''
|
'''
|
||||||
|
|
||||||
# Build list of returned attributes
|
# Build list of returned attributes
|
||||||
pdu_space_available = connection.att_mtu - 2
|
pdu_space_available = bearer.att_mtu - 2
|
||||||
attributes = []
|
attributes = []
|
||||||
response: att.ATT_PDU
|
response: att.ATT_PDU
|
||||||
async for attribute in (
|
async for attribute in (
|
||||||
@@ -651,7 +722,7 @@ class Server(utils.EventEmitter):
|
|||||||
if attribute.handle >= request.starting_handle
|
if attribute.handle >= request.starting_handle
|
||||||
and attribute.handle <= request.ending_handle
|
and attribute.handle <= request.ending_handle
|
||||||
and attribute.type == request.attribute_type
|
and attribute.type == request.attribute_type
|
||||||
and (await attribute.read_value(connection)) == request.attribute_value
|
and (await attribute.read_value(bearer)) == request.attribute_value
|
||||||
and pdu_space_available >= 4
|
and pdu_space_available >= 4
|
||||||
):
|
):
|
||||||
# TODO: check permissions
|
# TODO: check permissions
|
||||||
@@ -687,17 +758,17 @@ class Server(utils.EventEmitter):
|
|||||||
error_code=att.ATT_ATTRIBUTE_NOT_FOUND_ERROR,
|
error_code=att.ATT_ATTRIBUTE_NOT_FOUND_ERROR,
|
||||||
)
|
)
|
||||||
|
|
||||||
self.send_response(connection, response)
|
self.send_response(bearer, response)
|
||||||
|
|
||||||
@utils.AsyncRunner.run_in_task()
|
@utils.AsyncRunner.run_in_task()
|
||||||
async def on_att_read_by_type_request(
|
async def on_att_read_by_type_request(
|
||||||
self, connection: Connection, request: att.ATT_Read_By_Type_Request
|
self, bearer: att.Bearer, request: att.ATT_Read_By_Type_Request
|
||||||
):
|
):
|
||||||
'''
|
'''
|
||||||
See Bluetooth spec Vol 3, Part F - 3.4.4.1 Read By Type Request
|
See Bluetooth spec Vol 3, Part F - 3.4.4.1 Read By Type Request
|
||||||
'''
|
'''
|
||||||
|
|
||||||
pdu_space_available = connection.att_mtu - 2
|
pdu_space_available = bearer.att_mtu - 2
|
||||||
|
|
||||||
response: att.ATT_PDU = att.ATT_Error_Response(
|
response: att.ATT_PDU = att.ATT_Error_Response(
|
||||||
request_opcode_in_error=request.op_code,
|
request_opcode_in_error=request.op_code,
|
||||||
@@ -705,6 +776,18 @@ class Server(utils.EventEmitter):
|
|||||||
error_code=att.ATT_ATTRIBUTE_NOT_FOUND_ERROR,
|
error_code=att.ATT_ATTRIBUTE_NOT_FOUND_ERROR,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
if (
|
||||||
|
request.starting_handle == 0x0000
|
||||||
|
or request.starting_handle > request.ending_handle
|
||||||
|
):
|
||||||
|
response = att.ATT_Error_Response(
|
||||||
|
request_opcode_in_error=request.op_code,
|
||||||
|
attribute_handle_in_error=request.starting_handle,
|
||||||
|
error_code=att.ATT_INVALID_HANDLE_ERROR,
|
||||||
|
)
|
||||||
|
self.send_response(bearer, response)
|
||||||
|
return
|
||||||
|
|
||||||
attributes: list[tuple[int, bytes]] = []
|
attributes: list[tuple[int, bytes]] = []
|
||||||
for attribute in (
|
for attribute in (
|
||||||
attribute
|
attribute
|
||||||
@@ -715,7 +798,7 @@ class Server(utils.EventEmitter):
|
|||||||
and pdu_space_available
|
and pdu_space_available
|
||||||
):
|
):
|
||||||
try:
|
try:
|
||||||
attribute_value = await attribute.read_value(connection)
|
attribute_value = await attribute.read_value(bearer)
|
||||||
except att.ATT_Error as error:
|
except att.ATT_Error as error:
|
||||||
# If the first attribute is unreadable, return an error
|
# If the first attribute is unreadable, return an error
|
||||||
# Otherwise return attributes up to this point
|
# Otherwise return attributes up to this point
|
||||||
@@ -728,7 +811,7 @@ class Server(utils.EventEmitter):
|
|||||||
break
|
break
|
||||||
|
|
||||||
# Check the attribute value size
|
# Check the attribute value size
|
||||||
max_attribute_size = min(connection.att_mtu - 4, 253)
|
max_attribute_size = min(bearer.att_mtu - 4, 253)
|
||||||
if len(attribute_value) > max_attribute_size:
|
if len(attribute_value) > max_attribute_size:
|
||||||
# We need to truncate
|
# We need to truncate
|
||||||
attribute_value = attribute_value[:max_attribute_size]
|
attribute_value = attribute_value[:max_attribute_size]
|
||||||
@@ -755,11 +838,11 @@ class Server(utils.EventEmitter):
|
|||||||
else:
|
else:
|
||||||
logging.debug(f"not found {request}")
|
logging.debug(f"not found {request}")
|
||||||
|
|
||||||
self.send_response(connection, response)
|
self.send_response(bearer, response)
|
||||||
|
|
||||||
@utils.AsyncRunner.run_in_task()
|
@utils.AsyncRunner.run_in_task()
|
||||||
async def on_att_read_request(
|
async def on_att_read_request(
|
||||||
self, connection: Connection, request: att.ATT_Read_Request
|
self, bearer: att.Bearer, request: att.ATT_Read_Request
|
||||||
):
|
):
|
||||||
'''
|
'''
|
||||||
See Bluetooth spec Vol 3, Part F - 3.4.4.3 Read Request
|
See Bluetooth spec Vol 3, Part F - 3.4.4.3 Read Request
|
||||||
@@ -768,7 +851,7 @@ class Server(utils.EventEmitter):
|
|||||||
response: att.ATT_PDU
|
response: att.ATT_PDU
|
||||||
if attribute := self.get_attribute(request.attribute_handle):
|
if attribute := self.get_attribute(request.attribute_handle):
|
||||||
try:
|
try:
|
||||||
value = await attribute.read_value(connection)
|
value = await attribute.read_value(bearer)
|
||||||
except att.ATT_Error as error:
|
except att.ATT_Error as error:
|
||||||
response = att.ATT_Error_Response(
|
response = att.ATT_Error_Response(
|
||||||
request_opcode_in_error=request.op_code,
|
request_opcode_in_error=request.op_code,
|
||||||
@@ -776,7 +859,7 @@ class Server(utils.EventEmitter):
|
|||||||
error_code=error.error_code,
|
error_code=error.error_code,
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
value_size = min(connection.att_mtu - 1, len(value))
|
value_size = min(bearer.att_mtu - 1, len(value))
|
||||||
response = att.ATT_Read_Response(attribute_value=value[:value_size])
|
response = att.ATT_Read_Response(attribute_value=value[:value_size])
|
||||||
else:
|
else:
|
||||||
response = att.ATT_Error_Response(
|
response = att.ATT_Error_Response(
|
||||||
@@ -784,11 +867,11 @@ class Server(utils.EventEmitter):
|
|||||||
attribute_handle_in_error=request.attribute_handle,
|
attribute_handle_in_error=request.attribute_handle,
|
||||||
error_code=att.ATT_INVALID_HANDLE_ERROR,
|
error_code=att.ATT_INVALID_HANDLE_ERROR,
|
||||||
)
|
)
|
||||||
self.send_response(connection, response)
|
self.send_response(bearer, response)
|
||||||
|
|
||||||
@utils.AsyncRunner.run_in_task()
|
@utils.AsyncRunner.run_in_task()
|
||||||
async def on_att_read_blob_request(
|
async def on_att_read_blob_request(
|
||||||
self, connection: Connection, request: att.ATT_Read_Blob_Request
|
self, bearer: att.Bearer, request: att.ATT_Read_Blob_Request
|
||||||
):
|
):
|
||||||
'''
|
'''
|
||||||
See Bluetooth spec Vol 3, Part F - 3.4.4.5 Read Blob Request
|
See Bluetooth spec Vol 3, Part F - 3.4.4.5 Read Blob Request
|
||||||
@@ -797,7 +880,7 @@ class Server(utils.EventEmitter):
|
|||||||
response: att.ATT_PDU
|
response: att.ATT_PDU
|
||||||
if attribute := self.get_attribute(request.attribute_handle):
|
if attribute := self.get_attribute(request.attribute_handle):
|
||||||
try:
|
try:
|
||||||
value = await attribute.read_value(connection)
|
value = await attribute.read_value(bearer)
|
||||||
except att.ATT_Error as error:
|
except att.ATT_Error as error:
|
||||||
response = att.ATT_Error_Response(
|
response = att.ATT_Error_Response(
|
||||||
request_opcode_in_error=request.op_code,
|
request_opcode_in_error=request.op_code,
|
||||||
@@ -811,7 +894,7 @@ class Server(utils.EventEmitter):
|
|||||||
attribute_handle_in_error=request.attribute_handle,
|
attribute_handle_in_error=request.attribute_handle,
|
||||||
error_code=att.ATT_INVALID_OFFSET_ERROR,
|
error_code=att.ATT_INVALID_OFFSET_ERROR,
|
||||||
)
|
)
|
||||||
elif len(value) <= connection.att_mtu - 1:
|
elif len(value) <= bearer.att_mtu - 1:
|
||||||
response = att.ATT_Error_Response(
|
response = att.ATT_Error_Response(
|
||||||
request_opcode_in_error=request.op_code,
|
request_opcode_in_error=request.op_code,
|
||||||
attribute_handle_in_error=request.attribute_handle,
|
attribute_handle_in_error=request.attribute_handle,
|
||||||
@@ -819,7 +902,7 @@ class Server(utils.EventEmitter):
|
|||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
part_size = min(
|
part_size = min(
|
||||||
connection.att_mtu - 1, len(value) - request.value_offset
|
bearer.att_mtu - 1, len(value) - request.value_offset
|
||||||
)
|
)
|
||||||
response = att.ATT_Read_Blob_Response(
|
response = att.ATT_Read_Blob_Response(
|
||||||
part_attribute_value=value[
|
part_attribute_value=value[
|
||||||
@@ -832,11 +915,11 @@ class Server(utils.EventEmitter):
|
|||||||
attribute_handle_in_error=request.attribute_handle,
|
attribute_handle_in_error=request.attribute_handle,
|
||||||
error_code=att.ATT_INVALID_HANDLE_ERROR,
|
error_code=att.ATT_INVALID_HANDLE_ERROR,
|
||||||
)
|
)
|
||||||
self.send_response(connection, response)
|
self.send_response(bearer, response)
|
||||||
|
|
||||||
@utils.AsyncRunner.run_in_task()
|
@utils.AsyncRunner.run_in_task()
|
||||||
async def on_att_read_by_group_type_request(
|
async def on_att_read_by_group_type_request(
|
||||||
self, connection: Connection, request: att.ATT_Read_By_Group_Type_Request
|
self, bearer: att.Bearer, request: att.ATT_Read_By_Group_Type_Request
|
||||||
):
|
):
|
||||||
'''
|
'''
|
||||||
See Bluetooth spec Vol 3, Part F - 3.4.4.9 Read by Group Type Request
|
See Bluetooth spec Vol 3, Part F - 3.4.4.9 Read by Group Type Request
|
||||||
@@ -851,10 +934,10 @@ class Server(utils.EventEmitter):
|
|||||||
attribute_handle_in_error=request.starting_handle,
|
attribute_handle_in_error=request.starting_handle,
|
||||||
error_code=att.ATT_UNSUPPORTED_GROUP_TYPE_ERROR,
|
error_code=att.ATT_UNSUPPORTED_GROUP_TYPE_ERROR,
|
||||||
)
|
)
|
||||||
self.send_response(connection, response)
|
self.send_response(bearer, response)
|
||||||
return
|
return
|
||||||
|
|
||||||
pdu_space_available = connection.att_mtu - 2
|
pdu_space_available = bearer.att_mtu - 2
|
||||||
attributes: list[tuple[int, int, bytes]] = []
|
attributes: list[tuple[int, int, bytes]] = []
|
||||||
for attribute in (
|
for attribute in (
|
||||||
attribute
|
attribute
|
||||||
@@ -866,9 +949,9 @@ class Server(utils.EventEmitter):
|
|||||||
):
|
):
|
||||||
# No need to catch permission errors here, since these attributes
|
# No need to catch permission errors here, since these attributes
|
||||||
# must all be world-readable
|
# must all be world-readable
|
||||||
attribute_value = await attribute.read_value(connection)
|
attribute_value = await attribute.read_value(bearer)
|
||||||
# Check the attribute value size
|
# Check the attribute value size
|
||||||
max_attribute_size = min(connection.att_mtu - 6, 251)
|
max_attribute_size = min(bearer.att_mtu - 6, 251)
|
||||||
if len(attribute_value) > max_attribute_size:
|
if len(attribute_value) > max_attribute_size:
|
||||||
# We need to truncate
|
# We need to truncate
|
||||||
attribute_value = attribute_value[:max_attribute_size]
|
attribute_value = attribute_value[:max_attribute_size]
|
||||||
@@ -903,11 +986,99 @@ class Server(utils.EventEmitter):
|
|||||||
error_code=att.ATT_ATTRIBUTE_NOT_FOUND_ERROR,
|
error_code=att.ATT_ATTRIBUTE_NOT_FOUND_ERROR,
|
||||||
)
|
)
|
||||||
|
|
||||||
self.send_response(connection, response)
|
self.send_response(bearer, response)
|
||||||
|
|
||||||
|
@utils.AsyncRunner.run_in_task()
|
||||||
|
async def on_att_read_multiple_request(
|
||||||
|
self, bearer: att.Bearer, request: att.ATT_Read_Multiple_Request
|
||||||
|
):
|
||||||
|
'''
|
||||||
|
See Bluetooth spec Vol 3, Part F - 3.4.4.7 Read Multiple Request.
|
||||||
|
'''
|
||||||
|
response: att.ATT_PDU
|
||||||
|
|
||||||
|
pdu_space_available = bearer.att_mtu - 1
|
||||||
|
values: list[bytes] = []
|
||||||
|
|
||||||
|
for handle in request.set_of_handles:
|
||||||
|
if not (attribute := self.get_attribute(handle)):
|
||||||
|
response = att.ATT_Error_Response(
|
||||||
|
request_opcode_in_error=request.op_code,
|
||||||
|
attribute_handle_in_error=handle,
|
||||||
|
error_code=att.ATT_ATTRIBUTE_NOT_FOUND_ERROR,
|
||||||
|
)
|
||||||
|
self.send_response(bearer, response)
|
||||||
|
return
|
||||||
|
# No need to catch permission errors here, since these attributes
|
||||||
|
# must all be world-readable
|
||||||
|
attribute_value = await attribute.read_value(bearer)
|
||||||
|
# Check the attribute value size
|
||||||
|
max_attribute_size = min(bearer.att_mtu - 1, 251)
|
||||||
|
if len(attribute_value) > max_attribute_size:
|
||||||
|
# We need to truncate
|
||||||
|
attribute_value = attribute_value[:max_attribute_size]
|
||||||
|
|
||||||
|
# Check if there is enough space
|
||||||
|
entry_size = len(attribute_value)
|
||||||
|
if pdu_space_available < entry_size:
|
||||||
|
break
|
||||||
|
|
||||||
|
# Add the attribute to the list
|
||||||
|
values.append(attribute_value)
|
||||||
|
pdu_space_available -= entry_size
|
||||||
|
|
||||||
|
response = att.ATT_Read_Multiple_Response(set_of_values=b''.join(values))
|
||||||
|
self.send_response(bearer, response)
|
||||||
|
|
||||||
|
@utils.AsyncRunner.run_in_task()
|
||||||
|
async def on_att_read_multiple_variable_request(
|
||||||
|
self, bearer: att.Bearer, request: att.ATT_Read_Multiple_Variable_Request
|
||||||
|
):
|
||||||
|
'''
|
||||||
|
See Bluetooth spec Vol 3, Part F - 3.4.4.11 Read Multiple Variable Request.
|
||||||
|
'''
|
||||||
|
response: att.ATT_PDU
|
||||||
|
|
||||||
|
pdu_space_available = bearer.att_mtu - 1
|
||||||
|
length_value_tuple_list: list[tuple[int, bytes]] = []
|
||||||
|
|
||||||
|
for handle in request.set_of_handles:
|
||||||
|
if not (attribute := self.get_attribute(handle)):
|
||||||
|
response = att.ATT_Error_Response(
|
||||||
|
request_opcode_in_error=request.op_code,
|
||||||
|
attribute_handle_in_error=handle,
|
||||||
|
error_code=att.ATT_ATTRIBUTE_NOT_FOUND_ERROR,
|
||||||
|
)
|
||||||
|
self.send_response(bearer, response)
|
||||||
|
return
|
||||||
|
# No need to catch permission errors here, since these attributes
|
||||||
|
# must all be world-readable
|
||||||
|
attribute_value = await attribute.read_value(bearer)
|
||||||
|
length = len(attribute_value)
|
||||||
|
# Check the attribute value size
|
||||||
|
max_attribute_size = min(bearer.att_mtu - 3, 251)
|
||||||
|
if len(attribute_value) > max_attribute_size:
|
||||||
|
# We need to truncate
|
||||||
|
attribute_value = attribute_value[:max_attribute_size]
|
||||||
|
|
||||||
|
# Check if there is enough space
|
||||||
|
entry_size = 2 + len(attribute_value)
|
||||||
|
|
||||||
|
# Add the attribute to the list
|
||||||
|
length_value_tuple_list.append((length, attribute_value))
|
||||||
|
pdu_space_available -= entry_size
|
||||||
|
|
||||||
|
if pdu_space_available <= 0:
|
||||||
|
break
|
||||||
|
|
||||||
|
response = att.ATT_Read_Multiple_Variable_Response(
|
||||||
|
length_value_tuple_list=length_value_tuple_list
|
||||||
|
)
|
||||||
|
self.send_response(bearer, response)
|
||||||
|
|
||||||
@utils.AsyncRunner.run_in_task()
|
@utils.AsyncRunner.run_in_task()
|
||||||
async def on_att_write_request(
|
async def on_att_write_request(
|
||||||
self, connection: Connection, request: att.ATT_Write_Request
|
self, bearer: att.Bearer, request: att.ATT_Write_Request
|
||||||
):
|
):
|
||||||
'''
|
'''
|
||||||
See Bluetooth spec Vol 3, Part F - 3.4.5.1 Write Request
|
See Bluetooth spec Vol 3, Part F - 3.4.5.1 Write Request
|
||||||
@@ -917,7 +1088,7 @@ class Server(utils.EventEmitter):
|
|||||||
attribute = self.get_attribute(request.attribute_handle)
|
attribute = self.get_attribute(request.attribute_handle)
|
||||||
if attribute is None:
|
if attribute is None:
|
||||||
self.send_response(
|
self.send_response(
|
||||||
connection,
|
bearer,
|
||||||
att.ATT_Error_Response(
|
att.ATT_Error_Response(
|
||||||
request_opcode_in_error=request.op_code,
|
request_opcode_in_error=request.op_code,
|
||||||
attribute_handle_in_error=request.attribute_handle,
|
attribute_handle_in_error=request.attribute_handle,
|
||||||
@@ -931,7 +1102,7 @@ class Server(utils.EventEmitter):
|
|||||||
# Check the request parameters
|
# Check the request parameters
|
||||||
if len(request.attribute_value) > GATT_MAX_ATTRIBUTE_VALUE_SIZE:
|
if len(request.attribute_value) > GATT_MAX_ATTRIBUTE_VALUE_SIZE:
|
||||||
self.send_response(
|
self.send_response(
|
||||||
connection,
|
bearer,
|
||||||
att.ATT_Error_Response(
|
att.ATT_Error_Response(
|
||||||
request_opcode_in_error=request.op_code,
|
request_opcode_in_error=request.op_code,
|
||||||
attribute_handle_in_error=request.attribute_handle,
|
attribute_handle_in_error=request.attribute_handle,
|
||||||
@@ -943,7 +1114,7 @@ class Server(utils.EventEmitter):
|
|||||||
response: att.ATT_PDU
|
response: att.ATT_PDU
|
||||||
try:
|
try:
|
||||||
# Accept the value
|
# Accept the value
|
||||||
await attribute.write_value(connection, request.attribute_value)
|
await attribute.write_value(bearer, request.attribute_value)
|
||||||
except att.ATT_Error as error:
|
except att.ATT_Error as error:
|
||||||
response = att.ATT_Error_Response(
|
response = att.ATT_Error_Response(
|
||||||
request_opcode_in_error=request.op_code,
|
request_opcode_in_error=request.op_code,
|
||||||
@@ -953,11 +1124,11 @@ class Server(utils.EventEmitter):
|
|||||||
else:
|
else:
|
||||||
# Done
|
# Done
|
||||||
response = att.ATT_Write_Response()
|
response = att.ATT_Write_Response()
|
||||||
self.send_response(connection, response)
|
self.send_response(bearer, response)
|
||||||
|
|
||||||
@utils.AsyncRunner.run_in_task()
|
@utils.AsyncRunner.run_in_task()
|
||||||
async def on_att_write_command(
|
async def on_att_write_command(
|
||||||
self, connection: Connection, request: att.ATT_Write_Command
|
self, bearer: att.Bearer, request: att.ATT_Write_Command
|
||||||
):
|
):
|
||||||
'''
|
'''
|
||||||
See Bluetooth spec Vol 3, Part F - 3.4.5.3 Write Command
|
See Bluetooth spec Vol 3, Part F - 3.4.5.3 Write Command
|
||||||
@@ -976,22 +1147,20 @@ class Server(utils.EventEmitter):
|
|||||||
|
|
||||||
# Accept the value
|
# Accept the value
|
||||||
try:
|
try:
|
||||||
await attribute.write_value(connection, request.attribute_value)
|
await attribute.write_value(bearer, request.attribute_value)
|
||||||
except Exception:
|
except Exception:
|
||||||
logger.exception('!!! ignoring exception')
|
logger.exception('!!! ignoring exception')
|
||||||
|
|
||||||
def on_att_handle_value_confirmation(
|
def on_att_handle_value_confirmation(
|
||||||
self,
|
self,
|
||||||
connection: Connection,
|
bearer: att.Bearer,
|
||||||
confirmation: att.ATT_Handle_Value_Confirmation,
|
confirmation: att.ATT_Handle_Value_Confirmation,
|
||||||
):
|
):
|
||||||
'''
|
'''
|
||||||
See Bluetooth spec Vol 3, Part F - 3.4.7.3 Handle Value Confirmation
|
See Bluetooth spec Vol 3, Part F - 3.4.7.3 Handle Value Confirmation
|
||||||
'''
|
'''
|
||||||
del confirmation # Unused.
|
del confirmation # Unused.
|
||||||
if (
|
if (pending_confirmation := self.pending_confirmations[bearer]) is None:
|
||||||
pending_confirmation := self.pending_confirmations[connection.handle]
|
|
||||||
) is None:
|
|
||||||
# Not expected!
|
# Not expected!
|
||||||
logger.warning(
|
logger.warning(
|
||||||
'!!! unexpected confirmation, there is no pending indication'
|
'!!! unexpected confirmation, there is no pending indication'
|
||||||
|
|||||||
2579
bumble/hci.py
2579
bumble/hci.py
File diff suppressed because it is too large
Load Diff
@@ -20,7 +20,7 @@ from __future__ import annotations
|
|||||||
import datetime
|
import datetime
|
||||||
import logging
|
import logging
|
||||||
from collections.abc import Callable, MutableMapping
|
from collections.abc import Callable, MutableMapping
|
||||||
from typing import Any, Optional, cast
|
from typing import Any, cast
|
||||||
|
|
||||||
from bumble import avc, avctp, avdtp, avrcp, crypto, rfcomm, sdp
|
from bumble import avc, avctp, avdtp, avrcp, crypto, rfcomm, sdp
|
||||||
from bumble.att import ATT_CID, ATT_PDU
|
from bumble.att import ATT_CID, ATT_PDU
|
||||||
@@ -70,7 +70,7 @@ AVCTP_PID_NAMES = {avrcp.AVRCP_PID: 'AVRCP'}
|
|||||||
class PacketTracer:
|
class PacketTracer:
|
||||||
class AclStream:
|
class AclStream:
|
||||||
psms: MutableMapping[int, int]
|
psms: MutableMapping[int, int]
|
||||||
peer: Optional[PacketTracer.AclStream]
|
peer: PacketTracer.AclStream | None
|
||||||
avdtp_assemblers: MutableMapping[int, avdtp.MessageAssembler]
|
avdtp_assemblers: MutableMapping[int, avdtp.MessageAssembler]
|
||||||
avctp_assemblers: MutableMapping[int, avctp.MessageAssembler]
|
avctp_assemblers: MutableMapping[int, avctp.MessageAssembler]
|
||||||
|
|
||||||
@@ -201,7 +201,7 @@ class PacketTracer:
|
|||||||
self.label = label
|
self.label = label
|
||||||
self.emit_message = emit_message
|
self.emit_message = emit_message
|
||||||
self.acl_streams = {} # ACL streams, by connection handle
|
self.acl_streams = {} # ACL streams, by connection handle
|
||||||
self.packet_timestamp: Optional[datetime.datetime] = None
|
self.packet_timestamp: datetime.datetime | None = None
|
||||||
|
|
||||||
def start_acl_stream(self, connection_handle: int) -> PacketTracer.AclStream:
|
def start_acl_stream(self, connection_handle: int) -> PacketTracer.AclStream:
|
||||||
logger.info(
|
logger.info(
|
||||||
@@ -230,7 +230,7 @@ class PacketTracer:
|
|||||||
self.peer.end_acl_stream(connection_handle)
|
self.peer.end_acl_stream(connection_handle)
|
||||||
|
|
||||||
def on_packet(
|
def on_packet(
|
||||||
self, timestamp: Optional[datetime.datetime], packet: HCI_Packet
|
self, timestamp: datetime.datetime | None, packet: HCI_Packet
|
||||||
) -> None:
|
) -> None:
|
||||||
self.packet_timestamp = timestamp
|
self.packet_timestamp = timestamp
|
||||||
self.emit(packet)
|
self.emit(packet)
|
||||||
@@ -262,7 +262,7 @@ class PacketTracer:
|
|||||||
self,
|
self,
|
||||||
packet: HCI_Packet,
|
packet: HCI_Packet,
|
||||||
direction: int = 0,
|
direction: int = 0,
|
||||||
timestamp: Optional[datetime.datetime] = None,
|
timestamp: datetime.datetime | None = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
if direction == 0:
|
if direction == 0:
|
||||||
self.host_to_controller_analyzer.on_packet(timestamp, packet)
|
self.host_to_controller_analyzer.on_packet(timestamp, packet)
|
||||||
|
|||||||
219
bumble/hfp.py
219
bumble/hfp.py
@@ -25,7 +25,8 @@ import enum
|
|||||||
import logging
|
import logging
|
||||||
import re
|
import re
|
||||||
import traceback
|
import traceback
|
||||||
from typing import TYPE_CHECKING, Any, ClassVar, Iterable, Optional, Union
|
from collections.abc import Iterable
|
||||||
|
from typing import Any, ClassVar, Literal, overload
|
||||||
|
|
||||||
from typing_extensions import Self
|
from typing_extensions import Self
|
||||||
|
|
||||||
@@ -67,6 +68,8 @@ class HfpProtocolError(ProtocolError):
|
|||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
class HfpProtocol:
|
class HfpProtocol:
|
||||||
|
MAX_BUFFER_SIZE: ClassVar[int] = 65536
|
||||||
|
|
||||||
dlc: rfcomm.DLC
|
dlc: rfcomm.DLC
|
||||||
buffer: str
|
buffer: str
|
||||||
lines: collections.deque
|
lines: collections.deque
|
||||||
@@ -80,13 +83,22 @@ class HfpProtocol:
|
|||||||
|
|
||||||
dlc.sink = self.feed
|
dlc.sink = self.feed
|
||||||
|
|
||||||
def feed(self, data: Union[bytes, str]) -> None:
|
def feed(self, data: bytes | str) -> None:
|
||||||
# Convert the data to a string if needed
|
# Convert the data to a string if needed
|
||||||
if isinstance(data, bytes):
|
if isinstance(data, bytes):
|
||||||
data = data.decode('utf-8')
|
data = data.decode('utf-8', errors='replace')
|
||||||
|
|
||||||
logger.debug(f'<<< Data received: {data}')
|
logger.debug(f'<<< Data received: {data}')
|
||||||
|
|
||||||
|
# Drop incoming data if it would overflow the buffer; keep existing
|
||||||
|
# partial packet state intact so a future clean packet can still parse.
|
||||||
|
if len(self.buffer) + len(data) > self.MAX_BUFFER_SIZE:
|
||||||
|
logger.warning(
|
||||||
|
'HFP buffer overflow (>%d bytes), dropping incoming data',
|
||||||
|
self.MAX_BUFFER_SIZE,
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
# Add to the buffer and look for lines
|
# Add to the buffer and look for lines
|
||||||
self.buffer += data
|
self.buffer += data
|
||||||
while (separator := self.buffer.find('\r')) >= 0:
|
while (separator := self.buffer.find('\r')) >= 0:
|
||||||
@@ -324,8 +336,8 @@ class CallInfo:
|
|||||||
status: CallInfoStatus
|
status: CallInfoStatus
|
||||||
mode: CallInfoMode
|
mode: CallInfoMode
|
||||||
multi_party: CallInfoMultiParty
|
multi_party: CallInfoMultiParty
|
||||||
number: Optional[str] = None
|
number: str | None = None
|
||||||
type: Optional[int] = None
|
type: int | None = None
|
||||||
|
|
||||||
|
|
||||||
@dataclasses.dataclass
|
@dataclasses.dataclass
|
||||||
@@ -353,10 +365,10 @@ class CallLineIdentification:
|
|||||||
|
|
||||||
number: str
|
number: str
|
||||||
type: int
|
type: int
|
||||||
subaddr: Optional[str] = None
|
subaddr: str | None = None
|
||||||
satype: Optional[int] = None
|
satype: int | None = None
|
||||||
alpha: Optional[str] = None
|
alpha: str | None = None
|
||||||
cli_validity: Optional[int] = None
|
cli_validity: int | None = None
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def parse_from(cls, parameters: list[bytes]) -> Self:
|
def parse_from(cls, parameters: list[bytes]) -> Self:
|
||||||
@@ -419,61 +431,6 @@ class CmeError(enum.IntEnum):
|
|||||||
# Hands-Free Control Interoperability Requirements
|
# Hands-Free Control Interoperability Requirements
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
|
|
||||||
# Response codes.
|
|
||||||
RESPONSE_CODES = {
|
|
||||||
"+APLSIRI",
|
|
||||||
"+BAC",
|
|
||||||
"+BCC",
|
|
||||||
"+BCS",
|
|
||||||
"+BIA",
|
|
||||||
"+BIEV",
|
|
||||||
"+BIND",
|
|
||||||
"+BINP",
|
|
||||||
"+BLDN",
|
|
||||||
"+BRSF",
|
|
||||||
"+BTRH",
|
|
||||||
"+BVRA",
|
|
||||||
"+CCWA",
|
|
||||||
"+CHLD",
|
|
||||||
"+CHUP",
|
|
||||||
"+CIND",
|
|
||||||
"+CLCC",
|
|
||||||
"+CLIP",
|
|
||||||
"+CMEE",
|
|
||||||
"+CMER",
|
|
||||||
"+CNUM",
|
|
||||||
"+COPS",
|
|
||||||
"+IPHONEACCEV",
|
|
||||||
"+NREC",
|
|
||||||
"+VGM",
|
|
||||||
"+VGS",
|
|
||||||
"+VTS",
|
|
||||||
"+XAPL",
|
|
||||||
"A",
|
|
||||||
"D",
|
|
||||||
}
|
|
||||||
|
|
||||||
# Unsolicited responses and statuses.
|
|
||||||
UNSOLICITED_CODES = {
|
|
||||||
"+APLSIRI",
|
|
||||||
"+BCS",
|
|
||||||
"+BIND",
|
|
||||||
"+BSIR",
|
|
||||||
"+BTRH",
|
|
||||||
"+BVRA",
|
|
||||||
"+CCWA",
|
|
||||||
"+CIEV",
|
|
||||||
"+CLIP",
|
|
||||||
"+VGM",
|
|
||||||
"+VGS",
|
|
||||||
"BLACKLISTED",
|
|
||||||
"BUSY",
|
|
||||||
"DELAYED",
|
|
||||||
"NO ANSWER",
|
|
||||||
"NO CARRIER",
|
|
||||||
"RING",
|
|
||||||
}
|
|
||||||
|
|
||||||
# Status codes
|
# Status codes
|
||||||
STATUS_CODES = {
|
STATUS_CODES = {
|
||||||
"+CME ERROR",
|
"+CME ERROR",
|
||||||
@@ -489,9 +446,9 @@ STATUS_CODES = {
|
|||||||
|
|
||||||
@dataclasses.dataclass
|
@dataclasses.dataclass
|
||||||
class HfConfiguration:
|
class HfConfiguration:
|
||||||
supported_hf_features: list[HfFeature]
|
supported_hf_features: collections.abc.Sequence[HfFeature]
|
||||||
supported_hf_indicators: list[HfIndicator]
|
supported_hf_indicators: collections.abc.Sequence[HfIndicator]
|
||||||
supported_audio_codecs: list[AudioCodec]
|
supported_audio_codecs: collections.abc.Sequence[AudioCodec]
|
||||||
|
|
||||||
|
|
||||||
@dataclasses.dataclass
|
@dataclasses.dataclass
|
||||||
@@ -584,7 +541,7 @@ class AgIndicatorState:
|
|||||||
indicator: AgIndicator
|
indicator: AgIndicator
|
||||||
supported_values: set[int]
|
supported_values: set[int]
|
||||||
current_status: int
|
current_status: int
|
||||||
index: Optional[int] = None
|
index: int | None = None
|
||||||
enabled: bool = True
|
enabled: bool = True
|
||||||
|
|
||||||
@property
|
@property
|
||||||
@@ -597,7 +554,7 @@ class AgIndicatorState:
|
|||||||
supported_values_text = (
|
supported_values_text = (
|
||||||
f'({",".join(str(v) for v in self.supported_values)})'
|
f'({",".join(str(v) for v in self.supported_values)})'
|
||||||
)
|
)
|
||||||
return f'(\"{self.indicator.value}\",{supported_values_text})'
|
return f'("{self.indicator.value}",{supported_values_text})'
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def call(cls: type[Self]) -> Self:
|
def call(cls: type[Self]) -> Self:
|
||||||
@@ -726,12 +683,9 @@ class HfProtocol(utils.EventEmitter):
|
|||||||
|
|
||||||
dlc: rfcomm.DLC
|
dlc: rfcomm.DLC
|
||||||
command_lock: asyncio.Lock
|
command_lock: asyncio.Lock
|
||||||
if TYPE_CHECKING:
|
pending_command: str | None = None
|
||||||
response_queue: asyncio.Queue[AtResponse]
|
response_queue: asyncio.Queue[AtResponse]
|
||||||
unsolicited_queue: asyncio.Queue[Optional[AtResponse]]
|
unsolicited_queue: asyncio.Queue[AtResponse | None]
|
||||||
else:
|
|
||||||
response_queue: asyncio.Queue
|
|
||||||
unsolicited_queue: asyncio.Queue
|
|
||||||
read_buffer: bytearray
|
read_buffer: bytearray
|
||||||
active_codec: AudioCodec
|
active_codec: AudioCodec
|
||||||
|
|
||||||
@@ -753,7 +707,7 @@ class HfProtocol(utils.EventEmitter):
|
|||||||
|
|
||||||
# Build local features.
|
# Build local features.
|
||||||
self.supported_hf_features = sum(configuration.supported_hf_features)
|
self.supported_hf_features = sum(configuration.supported_hf_features)
|
||||||
self.supported_audio_codecs = configuration.supported_audio_codecs
|
self.supported_audio_codecs = list(configuration.supported_audio_codecs)
|
||||||
|
|
||||||
self.hf_indicators = {
|
self.hf_indicators = {
|
||||||
indicator: HfIndicatorState(indicator=indicator)
|
indicator: HfIndicatorState(indicator=indicator)
|
||||||
@@ -804,23 +758,46 @@ class HfProtocol(utils.EventEmitter):
|
|||||||
self.read_buffer = self.read_buffer[trailer + 2 :]
|
self.read_buffer = self.read_buffer[trailer + 2 :]
|
||||||
|
|
||||||
# Forward the received code to the correct queue.
|
# Forward the received code to the correct queue.
|
||||||
if self.command_lock.locked() and (
|
if self.pending_command and (
|
||||||
response.code in STATUS_CODES or response.code in RESPONSE_CODES
|
response.code in STATUS_CODES or response.code in self.pending_command
|
||||||
):
|
):
|
||||||
self.response_queue.put_nowait(response)
|
self.response_queue.put_nowait(response)
|
||||||
elif response.code in UNSOLICITED_CODES:
|
|
||||||
self.unsolicited_queue.put_nowait(response)
|
|
||||||
else:
|
else:
|
||||||
logger.warning(
|
self.unsolicited_queue.put_nowait(response)
|
||||||
f"dropping unexpected response with code '{response.code}'"
|
|
||||||
)
|
@overload
|
||||||
|
async def execute_command(
|
||||||
|
self,
|
||||||
|
cmd: str,
|
||||||
|
timeout: float = 1.0,
|
||||||
|
*,
|
||||||
|
response_type: Literal[AtResponseType.NONE] = AtResponseType.NONE,
|
||||||
|
) -> None: ...
|
||||||
|
|
||||||
|
@overload
|
||||||
|
async def execute_command(
|
||||||
|
self,
|
||||||
|
cmd: str,
|
||||||
|
timeout: float = 1.0,
|
||||||
|
*,
|
||||||
|
response_type: Literal[AtResponseType.SINGLE],
|
||||||
|
) -> AtResponse: ...
|
||||||
|
|
||||||
|
@overload
|
||||||
|
async def execute_command(
|
||||||
|
self,
|
||||||
|
cmd: str,
|
||||||
|
timeout: float = 1.0,
|
||||||
|
*,
|
||||||
|
response_type: Literal[AtResponseType.MULTIPLE],
|
||||||
|
) -> list[AtResponse]: ...
|
||||||
|
|
||||||
async def execute_command(
|
async def execute_command(
|
||||||
self,
|
self,
|
||||||
cmd: str,
|
cmd: str,
|
||||||
timeout: float = 1.0,
|
timeout: float = 1.0,
|
||||||
response_type: AtResponseType = AtResponseType.NONE,
|
response_type: AtResponseType = AtResponseType.NONE,
|
||||||
) -> Union[None, AtResponse, list[AtResponse]]:
|
) -> None | AtResponse | list[AtResponse]:
|
||||||
"""
|
"""
|
||||||
Sends an AT command and wait for the peer response.
|
Sends an AT command and wait for the peer response.
|
||||||
Wait for the AT responses sent by the peer, to the status code.
|
Wait for the AT responses sent by the peer, to the status code.
|
||||||
@@ -834,27 +811,34 @@ class HfProtocol(utils.EventEmitter):
|
|||||||
asyncio.TimeoutError: the status is not received after a timeout (default 1 second).
|
asyncio.TimeoutError: the status is not received after a timeout (default 1 second).
|
||||||
ProtocolError: the status is not OK.
|
ProtocolError: the status is not OK.
|
||||||
"""
|
"""
|
||||||
async with self.command_lock:
|
try:
|
||||||
logger.debug(f">>> {cmd}")
|
async with self.command_lock:
|
||||||
self.dlc.write(cmd + '\r')
|
self.pending_command = cmd
|
||||||
responses: list[AtResponse] = []
|
logger.debug(f">>> {cmd}")
|
||||||
|
self.dlc.write(cmd + '\r')
|
||||||
|
responses: list[AtResponse] = []
|
||||||
|
|
||||||
while True:
|
while True:
|
||||||
result = await asyncio.wait_for(
|
result = await asyncio.wait_for(
|
||||||
self.response_queue.get(), timeout=timeout
|
self.response_queue.get(), timeout=timeout
|
||||||
)
|
)
|
||||||
if result.code == 'OK':
|
if result.code == 'OK':
|
||||||
if response_type == AtResponseType.SINGLE and len(responses) != 1:
|
if (
|
||||||
raise HfpProtocolError("NO ANSWER")
|
response_type == AtResponseType.SINGLE
|
||||||
|
and len(responses) != 1
|
||||||
|
):
|
||||||
|
raise HfpProtocolError("NO ANSWER")
|
||||||
|
|
||||||
if response_type == AtResponseType.MULTIPLE:
|
if response_type == AtResponseType.MULTIPLE:
|
||||||
return responses
|
return responses
|
||||||
if response_type == AtResponseType.SINGLE:
|
if response_type == AtResponseType.SINGLE:
|
||||||
return responses[0]
|
return responses[0]
|
||||||
return None
|
return None
|
||||||
if result.code in STATUS_CODES:
|
if result.code in STATUS_CODES:
|
||||||
raise HfpProtocolError(result.code)
|
raise HfpProtocolError(result.code)
|
||||||
responses.append(result)
|
responses.append(result)
|
||||||
|
finally:
|
||||||
|
self.pending_command = None
|
||||||
|
|
||||||
async def initiate_slc(self):
|
async def initiate_slc(self):
|
||||||
"""4.2.1 Service Level Connection Initialization."""
|
"""4.2.1 Service Level Connection Initialization."""
|
||||||
@@ -1066,7 +1050,6 @@ class HfProtocol(utils.EventEmitter):
|
|||||||
responses = await self.execute_command(
|
responses = await self.execute_command(
|
||||||
"AT+CLCC", response_type=AtResponseType.MULTIPLE
|
"AT+CLCC", response_type=AtResponseType.MULTIPLE
|
||||||
)
|
)
|
||||||
assert isinstance(responses, list)
|
|
||||||
|
|
||||||
calls = []
|
calls = []
|
||||||
for response in responses:
|
for response in responses:
|
||||||
@@ -1351,7 +1334,7 @@ class AgProtocol(utils.EventEmitter):
|
|||||||
logger.warning(f'AG indicator {indicator} is disabled')
|
logger.warning(f'AG indicator {indicator} is disabled')
|
||||||
|
|
||||||
indicator_state.current_status = value
|
indicator_state.current_status = value
|
||||||
self.send_response(f'+CIEV: {index+1},{value}')
|
self.send_response(f'+CIEV: {index + 1},{value}')
|
||||||
|
|
||||||
async def negotiate_codec(self, codec: AudioCodec) -> None:
|
async def negotiate_codec(self, codec: AudioCodec) -> None:
|
||||||
"""Starts codec negotiation."""
|
"""Starts codec negotiation."""
|
||||||
@@ -1411,13 +1394,13 @@ class AgProtocol(utils.EventEmitter):
|
|||||||
self.emit(self.EVENT_VOICE_RECOGNITION, VoiceRecognitionState(int(vrec)))
|
self.emit(self.EVENT_VOICE_RECOGNITION, VoiceRecognitionState(int(vrec)))
|
||||||
|
|
||||||
def _on_chld(self, operation_code: bytes) -> None:
|
def _on_chld(self, operation_code: bytes) -> None:
|
||||||
call_index: Optional[int] = None
|
call_index: int | None = None
|
||||||
if len(operation_code) > 1:
|
if len(operation_code) > 1:
|
||||||
call_index = int(operation_code[1:])
|
call_index = int(operation_code[1:])
|
||||||
operation_code = operation_code[:1] + b'x'
|
operation_code = operation_code[:1] + b'x'
|
||||||
try:
|
try:
|
||||||
operation = CallHoldOperation(operation_code.decode())
|
operation = CallHoldOperation(operation_code.decode())
|
||||||
except:
|
except Exception:
|
||||||
logger.error(f'Invalid operation: {operation_code.decode()}')
|
logger.error(f'Invalid operation: {operation_code.decode()}')
|
||||||
self.send_cme_error(CmeError.OPERATION_NOT_SUPPORTED)
|
self.send_cme_error(CmeError.OPERATION_NOT_SUPPORTED)
|
||||||
return
|
return
|
||||||
@@ -1481,8 +1464,8 @@ class AgProtocol(utils.EventEmitter):
|
|||||||
def _on_cmer(
|
def _on_cmer(
|
||||||
self,
|
self,
|
||||||
mode: bytes,
|
mode: bytes,
|
||||||
keypad: Optional[bytes] = None,
|
keypad: bytes | None = None,
|
||||||
display: Optional[bytes] = None,
|
display: bytes | None = None,
|
||||||
indicator: bytes = b'',
|
indicator: bytes = b'',
|
||||||
) -> None:
|
) -> None:
|
||||||
if (
|
if (
|
||||||
@@ -1589,7 +1572,7 @@ class AgProtocol(utils.EventEmitter):
|
|||||||
|
|
||||||
def _on_clcc(self) -> None:
|
def _on_clcc(self) -> None:
|
||||||
for call in self.calls:
|
for call in self.calls:
|
||||||
number_text = f',\"{call.number}\"' if call.number is not None else ''
|
number_text = f',"{call.number}"' if call.number is not None else ''
|
||||||
type_text = f',{call.type}' if call.type is not None else ''
|
type_text = f',{call.type}' if call.type is not None else ''
|
||||||
response = (
|
response = (
|
||||||
f'+CLCC: {call.index}'
|
f'+CLCC: {call.index}'
|
||||||
@@ -1844,7 +1827,7 @@ def make_ag_sdp_records(
|
|||||||
|
|
||||||
async def find_hf_sdp_record(
|
async def find_hf_sdp_record(
|
||||||
connection: device.Connection,
|
connection: device.Connection,
|
||||||
) -> Optional[tuple[int, ProfileVersion, HfSdpFeature]]:
|
) -> tuple[int, ProfileVersion, HfSdpFeature] | None:
|
||||||
"""Searches a Hands-Free SDP record from remote device.
|
"""Searches a Hands-Free SDP record from remote device.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
@@ -1864,9 +1847,9 @@ async def find_hf_sdp_record(
|
|||||||
],
|
],
|
||||||
)
|
)
|
||||||
for attribute_lists in search_result:
|
for attribute_lists in search_result:
|
||||||
channel: Optional[int] = None
|
channel: int | None = None
|
||||||
version: Optional[ProfileVersion] = None
|
version: ProfileVersion | None = None
|
||||||
features: Optional[HfSdpFeature] = None
|
features: HfSdpFeature | None = None
|
||||||
for attribute in attribute_lists:
|
for attribute in attribute_lists:
|
||||||
# The layout is [[L2CAP_PROTOCOL], [RFCOMM_PROTOCOL, RFCOMM_CHANNEL]].
|
# The layout is [[L2CAP_PROTOCOL], [RFCOMM_PROTOCOL, RFCOMM_CHANNEL]].
|
||||||
if attribute.id == sdp.SDP_PROTOCOL_DESCRIPTOR_LIST_ATTRIBUTE_ID:
|
if attribute.id == sdp.SDP_PROTOCOL_DESCRIPTOR_LIST_ATTRIBUTE_ID:
|
||||||
@@ -1896,7 +1879,7 @@ async def find_hf_sdp_record(
|
|||||||
|
|
||||||
async def find_ag_sdp_record(
|
async def find_ag_sdp_record(
|
||||||
connection: device.Connection,
|
connection: device.Connection,
|
||||||
) -> Optional[tuple[int, ProfileVersion, AgSdpFeature]]:
|
) -> tuple[int, ProfileVersion, AgSdpFeature] | None:
|
||||||
"""Searches an Audio-Gateway SDP record from remote device.
|
"""Searches an Audio-Gateway SDP record from remote device.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
@@ -1915,9 +1898,9 @@ async def find_ag_sdp_record(
|
|||||||
],
|
],
|
||||||
)
|
)
|
||||||
for attribute_lists in search_result:
|
for attribute_lists in search_result:
|
||||||
channel: Optional[int] = None
|
channel: int | None = None
|
||||||
version: Optional[ProfileVersion] = None
|
version: ProfileVersion | None = None
|
||||||
features: Optional[AgSdpFeature] = None
|
features: AgSdpFeature | None = None
|
||||||
for attribute in attribute_lists:
|
for attribute in attribute_lists:
|
||||||
# The layout is [[L2CAP_PROTOCOL], [RFCOMM_PROTOCOL, RFCOMM_CHANNEL]].
|
# The layout is [[L2CAP_PROTOCOL], [RFCOMM_PROTOCOL, RFCOMM_CHANNEL]].
|
||||||
if attribute.id == sdp.SDP_PROTOCOL_DESCRIPTOR_LIST_ATTRIBUTE_ID:
|
if attribute.id == sdp.SDP_PROTOCOL_DESCRIPTOR_LIST_ATTRIBUTE_ID:
|
||||||
|
|||||||
@@ -21,8 +21,8 @@ import enum
|
|||||||
import logging
|
import logging
|
||||||
import struct
|
import struct
|
||||||
from abc import ABC, abstractmethod
|
from abc import ABC, abstractmethod
|
||||||
|
from collections.abc import Callable
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from typing import Callable, Optional
|
|
||||||
|
|
||||||
from typing_extensions import override
|
from typing_extensions import override
|
||||||
|
|
||||||
@@ -195,9 +195,9 @@ class SendHandshakeMessage(Message):
|
|||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
class HID(ABC, utils.EventEmitter):
|
class HID(ABC, utils.EventEmitter):
|
||||||
l2cap_ctrl_channel: Optional[l2cap.ClassicChannel] = None
|
l2cap_ctrl_channel: l2cap.ClassicChannel | None = None
|
||||||
l2cap_intr_channel: Optional[l2cap.ClassicChannel] = None
|
l2cap_intr_channel: l2cap.ClassicChannel | None = None
|
||||||
connection: Optional[device.Connection] = None
|
connection: device.Connection | None = None
|
||||||
|
|
||||||
EVENT_INTERRUPT_DATA = "interrupt_data"
|
EVENT_INTERRUPT_DATA = "interrupt_data"
|
||||||
EVENT_CONTROL_DATA = "control_data"
|
EVENT_CONTROL_DATA = "control_data"
|
||||||
@@ -212,7 +212,7 @@ class HID(ABC, utils.EventEmitter):
|
|||||||
|
|
||||||
def __init__(self, device: device.Device, role: Role) -> None:
|
def __init__(self, device: device.Device, role: Role) -> None:
|
||||||
super().__init__()
|
super().__init__()
|
||||||
self.remote_device_bd_address: Optional[Address] = None
|
self.remote_device_bd_address: Address | None = None
|
||||||
self.device = device
|
self.device = device
|
||||||
self.role = role
|
self.role = role
|
||||||
|
|
||||||
@@ -246,7 +246,7 @@ class HID(ABC, utils.EventEmitter):
|
|||||||
# Create a new L2CAP connection - interrupt channel
|
# Create a new L2CAP connection - interrupt channel
|
||||||
try:
|
try:
|
||||||
channel = await self.connection.create_l2cap_channel(
|
channel = await self.connection.create_l2cap_channel(
|
||||||
l2cap.ClassicChannelSpec(HID_CONTROL_PSM)
|
l2cap.ClassicChannelSpec(HID_INTERRUPT_PSM)
|
||||||
)
|
)
|
||||||
channel.sink = self.on_intr_pdu
|
channel.sink = self.on_intr_pdu
|
||||||
self.l2cap_intr_channel = channel
|
self.l2cap_intr_channel = channel
|
||||||
@@ -312,11 +312,11 @@ class HID(ABC, utils.EventEmitter):
|
|||||||
|
|
||||||
def send_pdu_on_ctrl(self, msg: bytes) -> None:
|
def send_pdu_on_ctrl(self, msg: bytes) -> None:
|
||||||
assert self.l2cap_ctrl_channel
|
assert self.l2cap_ctrl_channel
|
||||||
self.l2cap_ctrl_channel.send_pdu(msg)
|
self.l2cap_ctrl_channel.write(msg)
|
||||||
|
|
||||||
def send_pdu_on_intr(self, msg: bytes) -> None:
|
def send_pdu_on_intr(self, msg: bytes) -> None:
|
||||||
assert self.l2cap_intr_channel
|
assert self.l2cap_intr_channel
|
||||||
self.l2cap_intr_channel.send_pdu(msg)
|
self.l2cap_intr_channel.write(msg)
|
||||||
|
|
||||||
def send_data(self, data: bytes) -> None:
|
def send_data(self, data: bytes) -> None:
|
||||||
if self.role == HID.Role.HOST:
|
if self.role == HID.Role.HOST:
|
||||||
@@ -353,10 +353,10 @@ class Device(HID):
|
|||||||
data: bytes = b''
|
data: bytes = b''
|
||||||
status: int = 0
|
status: int = 0
|
||||||
|
|
||||||
get_report_cb: Optional[Callable[[int, int, int], GetSetStatus]] = None
|
get_report_cb: Callable[[int, int, int], GetSetStatus] | None = None
|
||||||
set_report_cb: Optional[Callable[[int, int, int, bytes], GetSetStatus]] = None
|
set_report_cb: Callable[[int, int, int, bytes], GetSetStatus] | None = None
|
||||||
get_protocol_cb: Optional[Callable[[], GetSetStatus]] = None
|
get_protocol_cb: Callable[[], GetSetStatus] | None = None
|
||||||
set_protocol_cb: Optional[Callable[[int], GetSetStatus]] = None
|
set_protocol_cb: Callable[[int], GetSetStatus] | None = None
|
||||||
|
|
||||||
def __init__(self, device: device.Device) -> None:
|
def __init__(self, device: device.Device) -> None:
|
||||||
super().__init__(device, HID.Role.DEVICE)
|
super().__init__(device, HID.Role.DEVICE)
|
||||||
|
|||||||
624
bumble/host.py
624
bumble/host.py
@@ -21,12 +21,16 @@ import asyncio
|
|||||||
import collections
|
import collections
|
||||||
import dataclasses
|
import dataclasses
|
||||||
import logging
|
import logging
|
||||||
import struct
|
from collections.abc import Awaitable, Callable
|
||||||
from typing import TYPE_CHECKING, Any, Awaitable, Callable, Optional, Union, cast
|
from typing import TYPE_CHECKING, Any, TypeVar, overload
|
||||||
|
|
||||||
from bumble import drivers, hci, utils
|
from bumble import drivers, hci, utils
|
||||||
from bumble.colors import color
|
from bumble.colors import color
|
||||||
from bumble.core import ConnectionPHY, InvalidStateError, PhysicalTransport
|
from bumble.core import (
|
||||||
|
ConnectionPHY,
|
||||||
|
InvalidStateError,
|
||||||
|
PhysicalTransport,
|
||||||
|
)
|
||||||
from bumble.l2cap import L2CAP_PDU
|
from bumble.l2cap import L2CAP_PDU
|
||||||
from bumble.snoop import Snooper
|
from bumble.snoop import Snooper
|
||||||
from bumble.transport.common import TransportLostError
|
from bumble.transport.common import TransportLostError
|
||||||
@@ -34,7 +38,6 @@ from bumble.transport.common import TransportLostError
|
|||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from bumble.transport.common import TransportSink, TransportSource
|
from bumble.transport.common import TransportSink, TransportSource
|
||||||
|
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
# Logging
|
# Logging
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
@@ -108,8 +111,7 @@ class DataPacketQueue(utils.EventEmitter):
|
|||||||
|
|
||||||
if self._packets:
|
if self._packets:
|
||||||
logger.debug(
|
logger.debug(
|
||||||
f'{self._in_flight} packets in flight, '
|
f'{self._in_flight} packets in flight, {len(self._packets)} in queue'
|
||||||
f'{len(self._packets)} in queue'
|
|
||||||
)
|
)
|
||||||
|
|
||||||
def flush(self, connection_handle: int) -> None:
|
def flush(self, connection_handle: int) -> None:
|
||||||
@@ -199,7 +201,7 @@ class Connection:
|
|||||||
self.peer_address = peer_address
|
self.peer_address = peer_address
|
||||||
self.assembler = hci.HCI_AclDataPacketAssembler(self.on_acl_pdu)
|
self.assembler = hci.HCI_AclDataPacketAssembler(self.on_acl_pdu)
|
||||||
self.transport = transport
|
self.transport = transport
|
||||||
acl_packet_queue: Optional[DataPacketQueue] = (
|
acl_packet_queue: DataPacketQueue | None = (
|
||||||
host.le_acl_packet_queue
|
host.le_acl_packet_queue
|
||||||
if transport == PhysicalTransport.LE
|
if transport == PhysicalTransport.LE
|
||||||
else host.acl_packet_queue
|
else host.acl_packet_queue
|
||||||
@@ -236,26 +238,27 @@ class IsoLink:
|
|||||||
|
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
|
_RP = TypeVar('_RP', bound=hci.HCI_ReturnParameters)
|
||||||
|
|
||||||
|
|
||||||
class Host(utils.EventEmitter):
|
class Host(utils.EventEmitter):
|
||||||
connections: dict[int, Connection]
|
connections: dict[int, Connection]
|
||||||
cis_links: dict[int, IsoLink]
|
cis_links: dict[int, IsoLink]
|
||||||
bis_links: dict[int, IsoLink]
|
bis_links: dict[int, IsoLink]
|
||||||
sco_links: dict[int, ScoLink]
|
sco_links: dict[int, ScoLink]
|
||||||
bigs: dict[int, set[int]]
|
bigs: dict[int, set[int]]
|
||||||
acl_packet_queue: Optional[DataPacketQueue] = None
|
acl_packet_queue: DataPacketQueue | None = None
|
||||||
le_acl_packet_queue: Optional[DataPacketQueue] = None
|
le_acl_packet_queue: DataPacketQueue | None = None
|
||||||
iso_packet_queue: Optional[DataPacketQueue] = None
|
iso_packet_queue: DataPacketQueue | None = None
|
||||||
hci_sink: Optional[TransportSink] = None
|
hci_sink: TransportSink | None = None
|
||||||
hci_metadata: dict[str, Any]
|
hci_metadata: dict[str, Any]
|
||||||
long_term_key_provider: Optional[
|
long_term_key_provider: Callable[[int, bytes, int], Awaitable[bytes | None]] | None
|
||||||
Callable[[int, bytes, int], Awaitable[Optional[bytes]]]
|
link_key_provider: Callable[[hci.Address], Awaitable[bytes | None]] | None
|
||||||
]
|
|
||||||
link_key_provider: Optional[Callable[[hci.Address], Awaitable[Optional[bytes]]]]
|
|
||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
controller_source: Optional[TransportSource] = None,
|
controller_source: TransportSource | None = None,
|
||||||
controller_sink: Optional[TransportSink] = None,
|
controller_sink: TransportSink | None = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
super().__init__()
|
super().__init__()
|
||||||
|
|
||||||
@@ -266,13 +269,20 @@ class Host(utils.EventEmitter):
|
|||||||
self.bis_links = {} # BIS links, by connection handle
|
self.bis_links = {} # BIS links, by connection handle
|
||||||
self.sco_links = {} # SCO links, by connection handle
|
self.sco_links = {} # SCO links, by connection handle
|
||||||
self.bigs = {} # BIG Handle to BIS Handles
|
self.bigs = {} # BIG Handle to BIS Handles
|
||||||
self.pending_command = None
|
self.pending_command: hci.HCI_SyncCommand | hci.HCI_AsyncCommand | None = None
|
||||||
self.pending_response: Optional[asyncio.Future[Any]] = None
|
self.pending_response: (
|
||||||
|
asyncio.Future[
|
||||||
|
hci.HCI_Command_Complete_Event | hci.HCI_Command_Status_Event
|
||||||
|
]
|
||||||
|
| None
|
||||||
|
) = None
|
||||||
self.number_of_supported_advertising_sets = 0
|
self.number_of_supported_advertising_sets = 0
|
||||||
self.maximum_advertising_data_length = 31
|
self.maximum_advertising_data_length = 31
|
||||||
self.local_version = None
|
self.local_version: (
|
||||||
|
hci.HCI_Read_Local_Version_Information_ReturnParameters | None
|
||||||
|
) = None
|
||||||
self.local_supported_commands = 0
|
self.local_supported_commands = 0
|
||||||
self.local_le_features = 0
|
self.local_le_features = hci.LeFeatureMask(0) # LE features
|
||||||
self.local_lmp_features = hci.LmpFeatureMask(0) # Classic LMP features
|
self.local_lmp_features = hci.LmpFeatureMask(0) # Classic LMP features
|
||||||
self.suggested_max_tx_octets = 251 # Max allowed
|
self.suggested_max_tx_octets = 251 # Max allowed
|
||||||
self.suggested_max_tx_time = 2120 # Max allowed
|
self.suggested_max_tx_time = 2120 # Max allowed
|
||||||
@@ -280,7 +290,7 @@ class Host(utils.EventEmitter):
|
|||||||
self.long_term_key_provider = None
|
self.long_term_key_provider = None
|
||||||
self.link_key_provider = None
|
self.link_key_provider = None
|
||||||
self.pairing_io_capability_provider = None # Classic only
|
self.pairing_io_capability_provider = None # Classic only
|
||||||
self.snooper: Optional[Snooper] = None
|
self.snooper: Snooper | None = None
|
||||||
|
|
||||||
# Connect to the source and sink if specified
|
# Connect to the source and sink if specified
|
||||||
if controller_source:
|
if controller_source:
|
||||||
@@ -291,9 +301,9 @@ class Host(utils.EventEmitter):
|
|||||||
def find_connection_by_bd_addr(
|
def find_connection_by_bd_addr(
|
||||||
self,
|
self,
|
||||||
bd_addr: hci.Address,
|
bd_addr: hci.Address,
|
||||||
transport: Optional[int] = None,
|
transport: int | None = None,
|
||||||
check_address_type: bool = False,
|
check_address_type: bool = False,
|
||||||
) -> Optional[Connection]:
|
) -> Connection | None:
|
||||||
for connection in self.connections.values():
|
for connection in self.connections.values():
|
||||||
if bytes(connection.peer_address) == bytes(bd_addr):
|
if bytes(connection.peer_address) == bytes(bd_addr):
|
||||||
if (
|
if (
|
||||||
@@ -314,7 +324,7 @@ class Host(utils.EventEmitter):
|
|||||||
self.emit('flush')
|
self.emit('flush')
|
||||||
self.command_semaphore.release()
|
self.command_semaphore.release()
|
||||||
|
|
||||||
async def reset(self, driver_factory=drivers.get_driver_for_host):
|
async def reset(self, driver_factory=drivers.get_driver_for_host) -> None:
|
||||||
if self.ready:
|
if self.ready:
|
||||||
self.ready = False
|
self.ready = False
|
||||||
await self.flush()
|
await self.flush()
|
||||||
@@ -332,57 +342,61 @@ class Host(utils.EventEmitter):
|
|||||||
|
|
||||||
# Send a reset command unless a driver has already done so.
|
# Send a reset command unless a driver has already done so.
|
||||||
if reset_needed:
|
if reset_needed:
|
||||||
await self.send_command(hci.HCI_Reset_Command(), check_result=True)
|
await self.send_sync_command(hci.HCI_Reset_Command())
|
||||||
self.ready = True
|
self.ready = True
|
||||||
|
|
||||||
response = await self.send_command(
|
response1 = await self.send_sync_command(
|
||||||
hci.HCI_Read_Local_Supported_Commands_Command(), check_result=True
|
hci.HCI_Read_Local_Supported_Commands_Command()
|
||||||
)
|
)
|
||||||
self.local_supported_commands = int.from_bytes(
|
self.local_supported_commands = int.from_bytes(
|
||||||
response.return_parameters.supported_commands, 'little'
|
response1.supported_commands, 'little'
|
||||||
)
|
)
|
||||||
|
|
||||||
if self.supports_command(hci.HCI_LE_READ_LOCAL_SUPPORTED_FEATURES_COMMAND):
|
|
||||||
response = await self.send_command(
|
|
||||||
hci.HCI_LE_Read_Local_Supported_Features_Command(), check_result=True
|
|
||||||
)
|
|
||||||
self.local_le_features = struct.unpack(
|
|
||||||
'<Q', response.return_parameters.le_features
|
|
||||||
)[0]
|
|
||||||
|
|
||||||
if self.supports_command(hci.HCI_READ_LOCAL_VERSION_INFORMATION_COMMAND):
|
if self.supports_command(hci.HCI_READ_LOCAL_VERSION_INFORMATION_COMMAND):
|
||||||
response = await self.send_command(
|
self.local_version = await self.send_sync_command(
|
||||||
hci.HCI_Read_Local_Version_Information_Command(), check_result=True
|
hci.HCI_Read_Local_Version_Information_Command()
|
||||||
|
)
|
||||||
|
|
||||||
|
if self.supports_command(hci.HCI_LE_READ_ALL_LOCAL_SUPPORTED_FEATURES_COMMAND):
|
||||||
|
response2 = await self.send_sync_command(
|
||||||
|
hci.HCI_LE_Read_All_Local_Supported_Features_Command()
|
||||||
|
)
|
||||||
|
self.local_le_features = hci.LeFeatureMask(
|
||||||
|
int.from_bytes(response2.le_features, 'little')
|
||||||
|
)
|
||||||
|
elif self.supports_command(hci.HCI_LE_READ_LOCAL_SUPPORTED_FEATURES_COMMAND):
|
||||||
|
response3 = await self.send_sync_command(
|
||||||
|
hci.HCI_LE_Read_Local_Supported_Features_Command()
|
||||||
|
)
|
||||||
|
self.local_le_features = hci.LeFeatureMask(
|
||||||
|
int.from_bytes(response3.le_features, 'little')
|
||||||
)
|
)
|
||||||
self.local_version = response.return_parameters
|
|
||||||
|
|
||||||
if self.supports_command(hci.HCI_READ_LOCAL_EXTENDED_FEATURES_COMMAND):
|
if self.supports_command(hci.HCI_READ_LOCAL_EXTENDED_FEATURES_COMMAND):
|
||||||
max_page_number = 0
|
max_page_number = 0
|
||||||
page_number = 0
|
page_number = 0
|
||||||
lmp_features = 0
|
lmp_features = 0
|
||||||
while page_number <= max_page_number:
|
while page_number <= max_page_number:
|
||||||
response = await self.send_command(
|
response4 = await self.send_sync_command(
|
||||||
hci.HCI_Read_Local_Extended_Features_Command(
|
hci.HCI_Read_Local_Extended_Features_Command(
|
||||||
page_number=page_number
|
page_number=page_number
|
||||||
),
|
)
|
||||||
check_result=True,
|
|
||||||
)
|
)
|
||||||
lmp_features |= int.from_bytes(
|
lmp_features |= int.from_bytes(
|
||||||
response.return_parameters.extended_lmp_features, 'little'
|
response4.extended_lmp_features, 'little'
|
||||||
) << (64 * page_number)
|
) << (64 * page_number)
|
||||||
max_page_number = response.return_parameters.maximum_page_number
|
max_page_number = response4.maximum_page_number
|
||||||
page_number += 1
|
page_number += 1
|
||||||
self.local_lmp_features = hci.LmpFeatureMask(lmp_features)
|
self.local_lmp_features = hci.LmpFeatureMask(lmp_features)
|
||||||
|
|
||||||
elif self.supports_command(hci.HCI_READ_LOCAL_SUPPORTED_FEATURES_COMMAND):
|
elif self.supports_command(hci.HCI_READ_LOCAL_SUPPORTED_FEATURES_COMMAND):
|
||||||
response = await self.send_command(
|
response5 = await self.send_sync_command(
|
||||||
hci.HCI_Read_Local_Supported_Features_Command(), check_result=True
|
hci.HCI_Read_Local_Supported_Features_Command()
|
||||||
)
|
)
|
||||||
self.local_lmp_features = hci.LmpFeatureMask(
|
self.local_lmp_features = hci.LmpFeatureMask(
|
||||||
int.from_bytes(response.return_parameters.lmp_features, 'little')
|
int.from_bytes(response5.lmp_features, 'little')
|
||||||
)
|
)
|
||||||
|
|
||||||
await self.send_command(
|
await self.send_sync_command(
|
||||||
hci.HCI_Set_Event_Mask_Command(
|
hci.HCI_Set_Event_Mask_Command(
|
||||||
event_mask=hci.HCI_Set_Event_Mask_Command.mask(
|
event_mask=hci.HCI_Set_Event_Mask_Command.mask(
|
||||||
[
|
[
|
||||||
@@ -439,7 +453,7 @@ class Host(utils.EventEmitter):
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
if self.supports_command(hci.HCI_SET_EVENT_MASK_PAGE_2_COMMAND):
|
if self.supports_command(hci.HCI_SET_EVENT_MASK_PAGE_2_COMMAND):
|
||||||
await self.send_command(
|
await self.send_sync_command(
|
||||||
hci.HCI_Set_Event_Mask_Page_2_Command(
|
hci.HCI_Set_Event_Mask_Page_2_Command(
|
||||||
event_mask_page_2=hci.HCI_Set_Event_Mask_Page_2_Command.mask(
|
event_mask_page_2=hci.HCI_Set_Event_Mask_Page_2_Command.mask(
|
||||||
[hci.HCI_ENCRYPTION_CHANGE_V2_EVENT]
|
[hci.HCI_ENCRYPTION_CHANGE_V2_EVENT]
|
||||||
@@ -492,29 +506,28 @@ class Host(utils.EventEmitter):
|
|||||||
hci.HCI_LE_TRANSMIT_POWER_REPORTING_EVENT,
|
hci.HCI_LE_TRANSMIT_POWER_REPORTING_EVENT,
|
||||||
hci.HCI_LE_BIGINFO_ADVERTISING_REPORT_EVENT,
|
hci.HCI_LE_BIGINFO_ADVERTISING_REPORT_EVENT,
|
||||||
hci.HCI_LE_SUBRATE_CHANGE_EVENT,
|
hci.HCI_LE_SUBRATE_CHANGE_EVENT,
|
||||||
|
hci.HCI_LE_READ_ALL_REMOTE_FEATURES_COMPLETE_EVENT,
|
||||||
hci.HCI_LE_CS_READ_REMOTE_SUPPORTED_CAPABILITIES_COMPLETE_EVENT,
|
hci.HCI_LE_CS_READ_REMOTE_SUPPORTED_CAPABILITIES_COMPLETE_EVENT,
|
||||||
hci.HCI_LE_CS_PROCEDURE_ENABLE_COMPLETE_EVENT,
|
hci.HCI_LE_CS_PROCEDURE_ENABLE_COMPLETE_EVENT,
|
||||||
hci.HCI_LE_CS_SECURITY_ENABLE_COMPLETE_EVENT,
|
hci.HCI_LE_CS_SECURITY_ENABLE_COMPLETE_EVENT,
|
||||||
hci.HCI_LE_CS_CONFIG_COMPLETE_EVENT,
|
hci.HCI_LE_CS_CONFIG_COMPLETE_EVENT,
|
||||||
hci.HCI_LE_CS_SUBEVENT_RESULT_EVENT,
|
hci.HCI_LE_CS_SUBEVENT_RESULT_EVENT,
|
||||||
hci.HCI_LE_CS_SUBEVENT_RESULT_CONTINUE_EVENT,
|
hci.HCI_LE_CS_SUBEVENT_RESULT_CONTINUE_EVENT,
|
||||||
|
hci.HCI_LE_MONITORED_ADVERTISERS_REPORT_EVENT,
|
||||||
|
hci.HCI_LE_FRAME_SPACE_UPDATE_COMPLETE_EVENT,
|
||||||
|
hci.HCI_LE_UTP_RECEIVE_EVENT,
|
||||||
|
hci.HCI_LE_CONNECTION_RATE_CHANGE_EVENT,
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
|
|
||||||
await self.send_command(
|
await self.send_sync_command(
|
||||||
hci.HCI_LE_Set_Event_Mask_Command(le_event_mask=le_event_mask)
|
hci.HCI_LE_Set_Event_Mask_Command(le_event_mask=le_event_mask)
|
||||||
)
|
)
|
||||||
|
|
||||||
if self.supports_command(hci.HCI_READ_BUFFER_SIZE_COMMAND):
|
if self.supports_command(hci.HCI_READ_BUFFER_SIZE_COMMAND):
|
||||||
response = await self.send_command(
|
response6 = await self.send_sync_command(hci.HCI_Read_Buffer_Size_Command())
|
||||||
hci.HCI_Read_Buffer_Size_Command(), check_result=True
|
hc_acl_data_packet_length = response6.hc_acl_data_packet_length
|
||||||
)
|
hc_total_num_acl_data_packets = response6.hc_total_num_acl_data_packets
|
||||||
hc_acl_data_packet_length = (
|
|
||||||
response.return_parameters.hc_acl_data_packet_length
|
|
||||||
)
|
|
||||||
hc_total_num_acl_data_packets = (
|
|
||||||
response.return_parameters.hc_total_num_acl_data_packets
|
|
||||||
)
|
|
||||||
|
|
||||||
logger.debug(
|
logger.debug(
|
||||||
'HCI ACL flow control: '
|
'HCI ACL flow control: '
|
||||||
@@ -533,37 +546,27 @@ class Host(utils.EventEmitter):
|
|||||||
iso_data_packet_length = 0
|
iso_data_packet_length = 0
|
||||||
total_num_iso_data_packets = 0
|
total_num_iso_data_packets = 0
|
||||||
if self.supports_command(hci.HCI_LE_READ_BUFFER_SIZE_V2_COMMAND):
|
if self.supports_command(hci.HCI_LE_READ_BUFFER_SIZE_V2_COMMAND):
|
||||||
response = await self.send_command(
|
response7 = await self.send_sync_command(
|
||||||
hci.HCI_LE_Read_Buffer_Size_V2_Command(), check_result=True
|
hci.HCI_LE_Read_Buffer_Size_V2_Command()
|
||||||
)
|
|
||||||
le_acl_data_packet_length = (
|
|
||||||
response.return_parameters.le_acl_data_packet_length
|
|
||||||
)
|
|
||||||
total_num_le_acl_data_packets = (
|
|
||||||
response.return_parameters.total_num_le_acl_data_packets
|
|
||||||
)
|
|
||||||
iso_data_packet_length = response.return_parameters.iso_data_packet_length
|
|
||||||
total_num_iso_data_packets = (
|
|
||||||
response.return_parameters.total_num_iso_data_packets
|
|
||||||
)
|
)
|
||||||
|
le_acl_data_packet_length = response7.le_acl_data_packet_length
|
||||||
|
total_num_le_acl_data_packets = response7.total_num_le_acl_data_packets
|
||||||
|
iso_data_packet_length = response7.iso_data_packet_length
|
||||||
|
total_num_iso_data_packets = response7.total_num_iso_data_packets
|
||||||
|
|
||||||
logger.debug(
|
logger.debug(
|
||||||
'HCI LE flow control: '
|
'HCI LE flow control: '
|
||||||
f'le_acl_data_packet_length={le_acl_data_packet_length},'
|
f'le_acl_data_packet_length={le_acl_data_packet_length},'
|
||||||
f'total_num_le_acl_data_packets={total_num_le_acl_data_packets}'
|
f'total_num_le_acl_data_packets={total_num_le_acl_data_packets},'
|
||||||
f'iso_data_packet_length={iso_data_packet_length},'
|
f'iso_data_packet_length={iso_data_packet_length},'
|
||||||
f'total_num_iso_data_packets={total_num_iso_data_packets}'
|
f'total_num_iso_data_packets={total_num_iso_data_packets}'
|
||||||
)
|
)
|
||||||
elif self.supports_command(hci.HCI_LE_READ_BUFFER_SIZE_COMMAND):
|
elif self.supports_command(hci.HCI_LE_READ_BUFFER_SIZE_COMMAND):
|
||||||
response = await self.send_command(
|
response8 = await self.send_sync_command(
|
||||||
hci.HCI_LE_Read_Buffer_Size_Command(), check_result=True
|
hci.HCI_LE_Read_Buffer_Size_Command()
|
||||||
)
|
|
||||||
le_acl_data_packet_length = (
|
|
||||||
response.return_parameters.le_acl_data_packet_length
|
|
||||||
)
|
|
||||||
total_num_le_acl_data_packets = (
|
|
||||||
response.return_parameters.total_num_le_acl_data_packets
|
|
||||||
)
|
)
|
||||||
|
le_acl_data_packet_length = response8.le_acl_data_packet_length
|
||||||
|
total_num_le_acl_data_packets = response8.total_num_le_acl_data_packets
|
||||||
|
|
||||||
logger.debug(
|
logger.debug(
|
||||||
'HCI LE ACL flow control: '
|
'HCI LE ACL flow control: '
|
||||||
@@ -594,16 +597,16 @@ class Host(utils.EventEmitter):
|
|||||||
) and self.supports_command(
|
) and self.supports_command(
|
||||||
hci.HCI_LE_WRITE_SUGGESTED_DEFAULT_DATA_LENGTH_COMMAND
|
hci.HCI_LE_WRITE_SUGGESTED_DEFAULT_DATA_LENGTH_COMMAND
|
||||||
):
|
):
|
||||||
response = await self.send_command(
|
response9 = await self.send_sync_command(
|
||||||
hci.HCI_LE_Read_Suggested_Default_Data_Length_Command()
|
hci.HCI_LE_Read_Suggested_Default_Data_Length_Command()
|
||||||
)
|
)
|
||||||
suggested_max_tx_octets = response.return_parameters.suggested_max_tx_octets
|
suggested_max_tx_octets = response9.suggested_max_tx_octets
|
||||||
suggested_max_tx_time = response.return_parameters.suggested_max_tx_time
|
suggested_max_tx_time = response9.suggested_max_tx_time
|
||||||
if (
|
if (
|
||||||
suggested_max_tx_octets != self.suggested_max_tx_octets
|
suggested_max_tx_octets != self.suggested_max_tx_octets
|
||||||
or suggested_max_tx_time != self.suggested_max_tx_time
|
or suggested_max_tx_time != self.suggested_max_tx_time
|
||||||
):
|
):
|
||||||
await self.send_command(
|
await self.send_sync_command(
|
||||||
hci.HCI_LE_Write_Suggested_Default_Data_Length_Command(
|
hci.HCI_LE_Write_Suggested_Default_Data_Length_Command(
|
||||||
suggested_max_tx_octets=self.suggested_max_tx_octets,
|
suggested_max_tx_octets=self.suggested_max_tx_octets,
|
||||||
suggested_max_tx_time=self.suggested_max_tx_time,
|
suggested_max_tx_time=self.suggested_max_tx_time,
|
||||||
@@ -613,27 +616,31 @@ class Host(utils.EventEmitter):
|
|||||||
if self.supports_command(
|
if self.supports_command(
|
||||||
hci.HCI_LE_READ_NUMBER_OF_SUPPORTED_ADVERTISING_SETS_COMMAND
|
hci.HCI_LE_READ_NUMBER_OF_SUPPORTED_ADVERTISING_SETS_COMMAND
|
||||||
):
|
):
|
||||||
response = await self.send_command(
|
try:
|
||||||
hci.HCI_LE_Read_Number_Of_Supported_Advertising_Sets_Command(),
|
response10 = await self.send_sync_command(
|
||||||
check_result=True,
|
hci.HCI_LE_Read_Number_Of_Supported_Advertising_Sets_Command()
|
||||||
)
|
)
|
||||||
self.number_of_supported_advertising_sets = (
|
self.number_of_supported_advertising_sets = (
|
||||||
response.return_parameters.num_supported_advertising_sets
|
response10.num_supported_advertising_sets
|
||||||
)
|
)
|
||||||
|
except hci.HCI_Error:
|
||||||
|
logger.warning('Failed to read number of supported advertising sets')
|
||||||
|
|
||||||
if self.supports_command(
|
if self.supports_command(
|
||||||
hci.HCI_LE_READ_MAXIMUM_ADVERTISING_DATA_LENGTH_COMMAND
|
hci.HCI_LE_READ_MAXIMUM_ADVERTISING_DATA_LENGTH_COMMAND
|
||||||
):
|
):
|
||||||
response = await self.send_command(
|
try:
|
||||||
hci.HCI_LE_Read_Maximum_Advertising_Data_Length_Command(),
|
response11 = await self.send_sync_command(
|
||||||
check_result=True,
|
hci.HCI_LE_Read_Maximum_Advertising_Data_Length_Command()
|
||||||
)
|
)
|
||||||
self.maximum_advertising_data_length = (
|
self.maximum_advertising_data_length = (
|
||||||
response.return_parameters.max_advertising_data_length
|
response11.max_advertising_data_length
|
||||||
)
|
)
|
||||||
|
except hci.HCI_Error:
|
||||||
|
logger.warning('Failed to read maximum advertising data length')
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def controller(self) -> Optional[TransportSink]:
|
def controller(self) -> TransportSink | None:
|
||||||
return self.hci_sink
|
return self.hci_sink
|
||||||
|
|
||||||
@controller.setter
|
@controller.setter
|
||||||
@@ -642,7 +649,7 @@ class Host(utils.EventEmitter):
|
|||||||
if controller:
|
if controller:
|
||||||
self.set_packet_source(controller)
|
self.set_packet_source(controller)
|
||||||
|
|
||||||
def set_packet_sink(self, sink: Optional[TransportSink]) -> None:
|
def set_packet_sink(self, sink: TransportSink | None) -> None:
|
||||||
self.hci_sink = sink
|
self.hci_sink = sink
|
||||||
|
|
||||||
def set_packet_source(self, source: TransportSource) -> None:
|
def set_packet_source(self, source: TransportSource) -> None:
|
||||||
@@ -656,58 +663,175 @@ class Host(utils.EventEmitter):
|
|||||||
if self.hci_sink:
|
if self.hci_sink:
|
||||||
self.hci_sink.on_packet(bytes(packet))
|
self.hci_sink.on_packet(bytes(packet))
|
||||||
|
|
||||||
async def send_command(
|
async def _send_command(
|
||||||
self, command, check_result=False, response_timeout: Optional[int] = None
|
self,
|
||||||
):
|
command: hci.HCI_SyncCommand | hci.HCI_AsyncCommand,
|
||||||
|
response_timeout: float | None = None,
|
||||||
|
) -> hci.HCI_Command_Complete_Event | hci.HCI_Command_Status_Event:
|
||||||
# Wait until we can send (only one pending command at a time)
|
# Wait until we can send (only one pending command at a time)
|
||||||
async with self.command_semaphore:
|
await self.command_semaphore.acquire()
|
||||||
assert self.pending_command is None
|
|
||||||
assert self.pending_response is None
|
|
||||||
|
|
||||||
# Create a future value to hold the eventual response
|
# Create a future value to hold the eventual response
|
||||||
self.pending_response = asyncio.get_running_loop().create_future()
|
assert self.pending_command is None
|
||||||
self.pending_command = command
|
assert self.pending_response is None
|
||||||
|
self.pending_response = asyncio.get_running_loop().create_future()
|
||||||
|
self.pending_command = command
|
||||||
|
|
||||||
try:
|
response: (
|
||||||
self.send_hci_packet(command)
|
hci.HCI_Command_Complete_Event | hci.HCI_Command_Status_Event | None
|
||||||
await asyncio.wait_for(self.pending_response, timeout=response_timeout)
|
) = None
|
||||||
response = self.pending_response.result()
|
try:
|
||||||
|
self.send_hci_packet(command)
|
||||||
|
response = await asyncio.wait_for(
|
||||||
|
self.pending_response, timeout=response_timeout
|
||||||
|
)
|
||||||
|
return response
|
||||||
|
except Exception:
|
||||||
|
logger.exception(color("!!! Exception while sending command:", "red"))
|
||||||
|
raise
|
||||||
|
finally:
|
||||||
|
self.pending_command = None
|
||||||
|
self.pending_response = None
|
||||||
|
if response is None or (
|
||||||
|
response.num_hci_command_packets and self.command_semaphore.locked()
|
||||||
|
):
|
||||||
|
self.command_semaphore.release()
|
||||||
|
|
||||||
# Check the return parameters if required
|
@overload
|
||||||
if check_result:
|
async def send_command(
|
||||||
if isinstance(response, hci.HCI_Command_Status_Event):
|
self,
|
||||||
status = response.status # type: ignore[attr-defined]
|
command: hci.HCI_SyncCommand[_RP],
|
||||||
elif isinstance(response.return_parameters, int):
|
check_result: bool = False,
|
||||||
status = response.return_parameters
|
response_timeout: float | None = None,
|
||||||
elif isinstance(response.return_parameters, bytes):
|
) -> hci.HCI_Command_Complete_Event[_RP]: ...
|
||||||
# return parameters first field is a one byte status code
|
|
||||||
status = response.return_parameters[0]
|
|
||||||
else:
|
|
||||||
status = response.return_parameters.status
|
|
||||||
|
|
||||||
if status != hci.HCI_SUCCESS:
|
@overload
|
||||||
logger.warning(
|
async def send_command(
|
||||||
f'{command.name} failed '
|
self,
|
||||||
f'({hci.HCI_Constant.error_name(status)})'
|
command: hci.HCI_AsyncCommand,
|
||||||
)
|
check_result: bool = False,
|
||||||
raise hci.HCI_Error(status)
|
response_timeout: float | None = None,
|
||||||
|
) -> hci.HCI_Command_Status_Event: ...
|
||||||
|
|
||||||
return response
|
async def send_command(
|
||||||
except Exception:
|
self,
|
||||||
logger.exception(color("!!! Exception while sending command:", "red"))
|
command: hci.HCI_SyncCommand[_RP] | hci.HCI_AsyncCommand,
|
||||||
raise
|
check_result: bool = False,
|
||||||
finally:
|
response_timeout: float | None = None,
|
||||||
self.pending_command = None
|
) -> hci.HCI_Command_Complete_Event[_RP] | hci.HCI_Command_Status_Event:
|
||||||
self.pending_response = None
|
response = await self._send_command(command, response_timeout)
|
||||||
|
|
||||||
# Use this method to send a command from a task
|
# Check the return parameters if required
|
||||||
def send_command_sync(self, command: hci.HCI_Command) -> None:
|
if check_result:
|
||||||
async def send_command(command: hci.HCI_Command) -> None:
|
if isinstance(response, hci.HCI_Command_Status_Event):
|
||||||
await self.send_command(command)
|
status = response.status # type: ignore[attr-defined]
|
||||||
|
elif isinstance(response.return_parameters, int):
|
||||||
|
status = response.return_parameters
|
||||||
|
elif isinstance(response.return_parameters, bytes):
|
||||||
|
# return parameters first field is a one byte status code
|
||||||
|
status = response.return_parameters[0]
|
||||||
|
elif isinstance(
|
||||||
|
response.return_parameters, hci.HCI_GenericReturnParameters
|
||||||
|
):
|
||||||
|
# FIXME: temporary workaround
|
||||||
|
# NO STATUS
|
||||||
|
status = hci.HCI_SUCCESS
|
||||||
|
else:
|
||||||
|
status = response.return_parameters.status
|
||||||
|
|
||||||
asyncio.create_task(send_command(command))
|
if status != hci.HCI_SUCCESS:
|
||||||
|
logger.warning(
|
||||||
|
f'{command.name} failed ' f'({hci.HCI_Constant.error_name(status)})'
|
||||||
|
)
|
||||||
|
raise hci.HCI_Error(status)
|
||||||
|
|
||||||
def send_l2cap_pdu(self, connection_handle: int, cid: int, pdu: bytes) -> None:
|
return response
|
||||||
|
|
||||||
|
async def send_sync_command(
|
||||||
|
self, command: hci.HCI_SyncCommand[_RP], response_timeout: float | None = None
|
||||||
|
) -> _RP:
|
||||||
|
response = await self.send_sync_command_raw(command, response_timeout)
|
||||||
|
return_parameters = response.return_parameters
|
||||||
|
|
||||||
|
# Check the return parameters's status
|
||||||
|
if isinstance(return_parameters, hci.HCI_StatusReturnParameters):
|
||||||
|
status = return_parameters.status
|
||||||
|
elif isinstance(return_parameters, hci.HCI_GenericReturnParameters):
|
||||||
|
# if the payload has at least one byte, assume the first byte is the status
|
||||||
|
if not return_parameters.data:
|
||||||
|
raise RuntimeError('no status byte in return parameters')
|
||||||
|
status = hci.HCI_ErrorCode(return_parameters.data[0])
|
||||||
|
else:
|
||||||
|
raise RuntimeError(
|
||||||
|
f'unexpected return parameters type ({type(return_parameters)})'
|
||||||
|
)
|
||||||
|
if status != hci.HCI_ErrorCode.SUCCESS:
|
||||||
|
logger.warning(
|
||||||
|
f'{command.name} failed ' f'({hci.HCI_Constant.error_name(status)})'
|
||||||
|
)
|
||||||
|
raise hci.HCI_Error(status)
|
||||||
|
|
||||||
|
return return_parameters
|
||||||
|
|
||||||
|
async def send_sync_command_raw(
|
||||||
|
self,
|
||||||
|
command: hci.HCI_SyncCommand[_RP],
|
||||||
|
response_timeout: float | None = None,
|
||||||
|
) -> hci.HCI_Command_Complete_Event[_RP]:
|
||||||
|
response = await self._send_command(command, response_timeout)
|
||||||
|
|
||||||
|
# For unknown HCI commands, some controllers return Command Status instead of
|
||||||
|
# Command Complete.
|
||||||
|
if (
|
||||||
|
isinstance(response, hci.HCI_Command_Status_Event)
|
||||||
|
and response.status == hci.HCI_ErrorCode.UNKNOWN_HCI_COMMAND_ERROR
|
||||||
|
):
|
||||||
|
return hci.HCI_Command_Complete_Event(
|
||||||
|
num_hci_command_packets=response.num_hci_command_packets,
|
||||||
|
command_opcode=command.op_code,
|
||||||
|
return_parameters=hci.HCI_StatusReturnParameters(
|
||||||
|
status=hci.HCI_ErrorCode(response.status)
|
||||||
|
), # type: ignore
|
||||||
|
)
|
||||||
|
|
||||||
|
# Check that the response is of the expected type
|
||||||
|
assert isinstance(response, hci.HCI_Command_Complete_Event)
|
||||||
|
|
||||||
|
return response
|
||||||
|
|
||||||
|
async def send_async_command(
|
||||||
|
self,
|
||||||
|
command: hci.HCI_AsyncCommand,
|
||||||
|
check_status: bool = True,
|
||||||
|
response_timeout: float | None = None,
|
||||||
|
) -> hci.HCI_ErrorCode:
|
||||||
|
response = await self._send_command(command, response_timeout)
|
||||||
|
|
||||||
|
# For unknown HCI commands, some controllers return Command Complete instead of
|
||||||
|
# Command Status.
|
||||||
|
if isinstance(response, hci.HCI_Command_Complete_Event):
|
||||||
|
# Assume the first byte of the return parameters is the status
|
||||||
|
if (
|
||||||
|
status := hci.HCI_ErrorCode(response.parameters[3])
|
||||||
|
) != hci.HCI_ErrorCode.UNKNOWN_HCI_COMMAND_ERROR:
|
||||||
|
logger.warning(f'unexpected return paramerers status {status}')
|
||||||
|
else:
|
||||||
|
assert isinstance(response, hci.HCI_Command_Status_Event)
|
||||||
|
status = hci.HCI_ErrorCode(response.status)
|
||||||
|
|
||||||
|
# Check the status if required
|
||||||
|
if check_status:
|
||||||
|
if status != hci.HCI_CommandStatus.PENDING:
|
||||||
|
logger.warning(f'{command.name} failed ' f'({status.name})')
|
||||||
|
raise hci.HCI_Error(status)
|
||||||
|
|
||||||
|
return status
|
||||||
|
|
||||||
|
@utils.deprecated("Use utils.AsyncRunner.spawn() instead.")
|
||||||
|
def send_command_sync(self, command: hci.HCI_AsyncCommand) -> None:
|
||||||
|
utils.AsyncRunner.spawn(self.send_async_command(command))
|
||||||
|
|
||||||
|
def send_acl_sdu(self, connection_handle: int, sdu: bytes) -> None:
|
||||||
if not (connection := self.connections.get(connection_handle)):
|
if not (connection := self.connections.get(connection_handle)):
|
||||||
logger.warning(f'connection 0x{connection_handle:04X} not found')
|
logger.warning(f'connection 0x{connection_handle:04X} not found')
|
||||||
return
|
return
|
||||||
@@ -718,27 +842,36 @@ class Host(utils.EventEmitter):
|
|||||||
)
|
)
|
||||||
return
|
return
|
||||||
|
|
||||||
# Create a PDU
|
|
||||||
l2cap_pdu = bytes(L2CAP_PDU(cid, pdu))
|
|
||||||
|
|
||||||
# Send the data to the controller via ACL packets
|
# Send the data to the controller via ACL packets
|
||||||
bytes_remaining = len(l2cap_pdu)
|
max_packet_size = packet_queue.max_packet_size
|
||||||
offset = 0
|
for offset in range(0, len(sdu), max_packet_size):
|
||||||
pb_flag = 0
|
pdu = sdu[offset : offset + max_packet_size]
|
||||||
while bytes_remaining:
|
|
||||||
data_total_length = min(bytes_remaining, packet_queue.max_packet_size)
|
|
||||||
acl_packet = hci.HCI_AclDataPacket(
|
acl_packet = hci.HCI_AclDataPacket(
|
||||||
connection_handle=connection_handle,
|
connection_handle=connection_handle,
|
||||||
pb_flag=pb_flag,
|
pb_flag=1 if offset > 0 else 0,
|
||||||
bc_flag=0,
|
bc_flag=0,
|
||||||
data_total_length=data_total_length,
|
data_total_length=len(pdu),
|
||||||
data=l2cap_pdu[offset : offset + data_total_length],
|
data=pdu,
|
||||||
|
)
|
||||||
|
logger.debug(
|
||||||
|
'>>> ACL packet enqueue: (handle=0x%04X) %s',
|
||||||
|
connection_handle,
|
||||||
|
pdu.hex(),
|
||||||
)
|
)
|
||||||
logger.debug(f'>>> ACL packet enqueue: (CID={cid}) {acl_packet}')
|
|
||||||
packet_queue.enqueue(acl_packet, connection_handle)
|
packet_queue.enqueue(acl_packet, connection_handle)
|
||||||
pb_flag = 1
|
|
||||||
offset += data_total_length
|
def send_sco_sdu(self, connection_handle: int, sdu: bytes) -> None:
|
||||||
bytes_remaining -= data_total_length
|
self.send_hci_packet(
|
||||||
|
hci.HCI_SynchronousDataPacket(
|
||||||
|
connection_handle=connection_handle,
|
||||||
|
packet_status=0,
|
||||||
|
data_total_length=len(sdu),
|
||||||
|
data=sdu,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
def send_l2cap_pdu(self, connection_handle: int, cid: int, pdu: bytes) -> None:
|
||||||
|
self.send_acl_sdu(connection_handle, bytes(L2CAP_PDU(cid, pdu)))
|
||||||
|
|
||||||
def get_data_packet_queue(self, connection_handle: int) -> DataPacketQueue | None:
|
def get_data_packet_queue(self, connection_handle: int) -> DataPacketQueue | None:
|
||||||
if connection := self.connections.get(connection_handle):
|
if connection := self.connections.get(connection_handle):
|
||||||
@@ -821,16 +954,18 @@ class Host(utils.EventEmitter):
|
|||||||
if self.local_supported_commands & mask
|
if self.local_supported_commands & mask
|
||||||
)
|
)
|
||||||
|
|
||||||
def supports_le_features(self, feature: hci.LeFeatureMask) -> bool:
|
def supports_le_features(self, features: hci.LeFeatureMask) -> bool:
|
||||||
return (self.local_le_features & feature) == feature
|
return (self.local_le_features & features) == features
|
||||||
|
|
||||||
def supports_lmp_features(self, feature: hci.LmpFeatureMask) -> bool:
|
def supports_lmp_features(self, features: hci.LmpFeatureMask) -> bool:
|
||||||
return self.local_lmp_features & (feature) == feature
|
return self.local_lmp_features & (features) == features
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def supported_le_features(self):
|
def supported_le_features(self) -> list[hci.LeFeature]:
|
||||||
return [
|
return [
|
||||||
feature for feature in range(64) if self.local_le_features & (1 << feature)
|
feature
|
||||||
|
for feature in hci.LeFeature
|
||||||
|
if self.local_le_features & (1 << feature)
|
||||||
]
|
]
|
||||||
|
|
||||||
# Packet Sink protocol (packets coming from the controller via HCI)
|
# Packet Sink protocol (packets coming from the controller via HCI)
|
||||||
@@ -865,18 +1000,19 @@ class Host(utils.EventEmitter):
|
|||||||
self.snooper.snoop(bytes(packet), Snooper.Direction.CONTROLLER_TO_HOST)
|
self.snooper.snoop(bytes(packet), Snooper.Direction.CONTROLLER_TO_HOST)
|
||||||
|
|
||||||
# If the packet is a command, invoke the handler for this packet
|
# If the packet is a command, invoke the handler for this packet
|
||||||
if packet.hci_packet_type == hci.HCI_COMMAND_PACKET:
|
match packet:
|
||||||
self.on_hci_command_packet(cast(hci.HCI_Command, packet))
|
case hci.HCI_Command():
|
||||||
elif packet.hci_packet_type == hci.HCI_EVENT_PACKET:
|
self.on_hci_command_packet(packet)
|
||||||
self.on_hci_event_packet(cast(hci.HCI_Event, packet))
|
case hci.HCI_Event():
|
||||||
elif packet.hci_packet_type == hci.HCI_ACL_DATA_PACKET:
|
self.on_hci_event_packet(packet)
|
||||||
self.on_hci_acl_data_packet(cast(hci.HCI_AclDataPacket, packet))
|
case hci.HCI_AclDataPacket():
|
||||||
elif packet.hci_packet_type == hci.HCI_SYNCHRONOUS_DATA_PACKET:
|
self.on_hci_acl_data_packet(packet)
|
||||||
self.on_hci_sco_data_packet(cast(hci.HCI_SynchronousDataPacket, packet))
|
case hci.HCI_SynchronousDataPacket():
|
||||||
elif packet.hci_packet_type == hci.HCI_ISO_DATA_PACKET:
|
self.on_hci_sco_data_packet(packet)
|
||||||
self.on_hci_iso_data_packet(cast(hci.HCI_IsoDataPacket, packet))
|
case hci.HCI_IsoDataPacket():
|
||||||
else:
|
self.on_hci_iso_data_packet(packet)
|
||||||
logger.warning(f'!!! unknown packet type {packet.hci_packet_type}')
|
case _:
|
||||||
|
logger.warning(f'!!! unknown packet type {packet.hci_packet_type}')
|
||||||
|
|
||||||
def on_hci_command_packet(self, command: hci.HCI_Command) -> None:
|
def on_hci_command_packet(self, command: hci.HCI_Command) -> None:
|
||||||
logger.warning(f'!!! unexpected command packet: {command}')
|
logger.warning(f'!!! unexpected command packet: {command}')
|
||||||
@@ -903,7 +1039,7 @@ class Host(utils.EventEmitter):
|
|||||||
self.emit('l2cap_pdu', connection.handle, cid, pdu)
|
self.emit('l2cap_pdu', connection.handle, cid, pdu)
|
||||||
|
|
||||||
def on_command_processed(
|
def on_command_processed(
|
||||||
self, event: Union[hci.HCI_Command_Complete_Event, hci.HCI_Command_Status_Event]
|
self, event: hci.HCI_Command_Complete_Event | hci.HCI_Command_Status_Event
|
||||||
):
|
):
|
||||||
if self.pending_response:
|
if self.pending_response:
|
||||||
# Check that it is what we were expecting
|
# Check that it is what we were expecting
|
||||||
@@ -919,6 +1055,8 @@ class Host(utils.EventEmitter):
|
|||||||
self.pending_response.set_result(event)
|
self.pending_response.set_result(event)
|
||||||
else:
|
else:
|
||||||
logger.warning('!!! no pending response future to set')
|
logger.warning('!!! no pending response future to set')
|
||||||
|
if event.num_hci_command_packets and self.command_semaphore.locked():
|
||||||
|
self.command_semaphore.release()
|
||||||
|
|
||||||
############################################################
|
############################################################
|
||||||
# HCI handlers
|
# HCI handlers
|
||||||
@@ -930,7 +1068,13 @@ class Host(utils.EventEmitter):
|
|||||||
if event.command_opcode == 0:
|
if event.command_opcode == 0:
|
||||||
# This is used just for the Num_HCI_Command_Packets field, not related to
|
# This is used just for the Num_HCI_Command_Packets field, not related to
|
||||||
# an actual command
|
# an actual command
|
||||||
logger.debug('no-command event')
|
logger.debug('no-command event for flow control')
|
||||||
|
|
||||||
|
# Release the command semaphore if needed
|
||||||
|
if event.num_hci_command_packets and self.command_semaphore.locked():
|
||||||
|
logger.debug('command complete event releasing semaphore')
|
||||||
|
self.command_semaphore.release()
|
||||||
|
|
||||||
return
|
return
|
||||||
|
|
||||||
return self.on_command_processed(event)
|
return self.on_command_processed(event)
|
||||||
@@ -966,11 +1110,11 @@ class Host(utils.EventEmitter):
|
|||||||
|
|
||||||
def on_hci_le_connection_complete_event(
|
def on_hci_le_connection_complete_event(
|
||||||
self,
|
self,
|
||||||
event: Union[
|
event: (
|
||||||
hci.HCI_LE_Connection_Complete_Event,
|
hci.HCI_LE_Connection_Complete_Event
|
||||||
hci.HCI_LE_Enhanced_Connection_Complete_Event,
|
| hci.HCI_LE_Enhanced_Connection_Complete_Event
|
||||||
hci.HCI_LE_Enhanced_Connection_Complete_V2_Event,
|
| hci.HCI_LE_Enhanced_Connection_Complete_V2_Event
|
||||||
],
|
),
|
||||||
):
|
):
|
||||||
# Check if this is a cancellation
|
# Check if this is a cancellation
|
||||||
if event.status == hci.HCI_SUCCESS:
|
if event.status == hci.HCI_SUCCESS:
|
||||||
@@ -1015,10 +1159,10 @@ class Host(utils.EventEmitter):
|
|||||||
|
|
||||||
def on_hci_le_enhanced_connection_complete_event(
|
def on_hci_le_enhanced_connection_complete_event(
|
||||||
self,
|
self,
|
||||||
event: Union[
|
event: (
|
||||||
hci.HCI_LE_Enhanced_Connection_Complete_Event,
|
hci.HCI_LE_Enhanced_Connection_Complete_Event
|
||||||
hci.HCI_LE_Enhanced_Connection_Complete_V2_Event,
|
| hci.HCI_LE_Enhanced_Connection_Complete_V2_Event
|
||||||
],
|
),
|
||||||
):
|
):
|
||||||
# Just use the same implementation as for the non-enhanced event for now
|
# Just use the same implementation as for the non-enhanced event for now
|
||||||
self.on_hci_le_connection_complete_event(event)
|
self.on_hci_le_connection_complete_event(event)
|
||||||
@@ -1111,7 +1255,7 @@ class Host(utils.EventEmitter):
|
|||||||
self, event: hci.HCI_LE_Connection_Update_Complete_Event
|
self, event: hci.HCI_LE_Connection_Update_Complete_Event
|
||||||
):
|
):
|
||||||
if (connection := self.connections.get(event.connection_handle)) is None:
|
if (connection := self.connections.get(event.connection_handle)) is None:
|
||||||
logger.warning('!!! CONNECTION PARAMETERS UPDATE COMPLETE: unknown handle')
|
logger.warning('!!! CONNECTION UPDATE COMPLETE: unknown handle')
|
||||||
return
|
return
|
||||||
|
|
||||||
# Notify the client
|
# Notify the client
|
||||||
@@ -1128,6 +1272,29 @@ class Host(utils.EventEmitter):
|
|||||||
'connection_parameters_update_failure', connection.handle, event.status
|
'connection_parameters_update_failure', connection.handle, event.status
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def on_hci_le_connection_rate_change_event(
|
||||||
|
self, event: hci.HCI_LE_Connection_Rate_Change_Event
|
||||||
|
):
|
||||||
|
if (connection := self.connections.get(event.connection_handle)) is None:
|
||||||
|
logger.warning('!!! CONNECTION RATE CHANGE: unknown handle')
|
||||||
|
return
|
||||||
|
|
||||||
|
# Notify the client
|
||||||
|
if event.status == hci.HCI_SUCCESS:
|
||||||
|
self.emit(
|
||||||
|
'le_connection_rate_change',
|
||||||
|
connection.handle,
|
||||||
|
event.connection_interval,
|
||||||
|
event.subrate_factor,
|
||||||
|
event.peripheral_latency,
|
||||||
|
event.continuation_number,
|
||||||
|
event.supervision_timeout,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
self.emit(
|
||||||
|
'le_connection_rate_change_failure', connection.handle, event.status
|
||||||
|
)
|
||||||
|
|
||||||
def on_hci_le_phy_update_complete_event(
|
def on_hci_le_phy_update_complete_event(
|
||||||
self, event: hci.HCI_LE_PHY_Update_Complete_Event
|
self, event: hci.HCI_LE_PHY_Update_Complete_Event
|
||||||
):
|
):
|
||||||
@@ -1343,15 +1510,17 @@ class Host(utils.EventEmitter):
|
|||||||
|
|
||||||
# For now, just accept everything
|
# For now, just accept everything
|
||||||
# TODO: delegate the decision
|
# TODO: delegate the decision
|
||||||
self.send_command_sync(
|
utils.AsyncRunner.spawn(
|
||||||
hci.HCI_LE_Remote_Connection_Parameter_Request_Reply_Command(
|
self.send_sync_command(
|
||||||
connection_handle=event.connection_handle,
|
hci.HCI_LE_Remote_Connection_Parameter_Request_Reply_Command(
|
||||||
interval_min=event.interval_min,
|
connection_handle=event.connection_handle,
|
||||||
interval_max=event.interval_max,
|
interval_min=event.interval_min,
|
||||||
max_latency=event.max_latency,
|
interval_max=event.interval_max,
|
||||||
timeout=event.timeout,
|
max_latency=event.max_latency,
|
||||||
min_ce_length=0,
|
timeout=event.timeout,
|
||||||
max_ce_length=0,
|
min_ce_length=0,
|
||||||
|
max_ce_length=0,
|
||||||
|
)
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -1387,9 +1556,9 @@ class Host(utils.EventEmitter):
|
|||||||
connection_handle=event.connection_handle
|
connection_handle=event.connection_handle
|
||||||
)
|
)
|
||||||
|
|
||||||
await self.send_command(response)
|
await self.send_sync_command(response)
|
||||||
|
|
||||||
asyncio.create_task(send_long_term_key())
|
utils.AsyncRunner.spawn(send_long_term_key())
|
||||||
|
|
||||||
def on_hci_synchronous_connection_complete_event(
|
def on_hci_synchronous_connection_complete_event(
|
||||||
self, event: hci.HCI_Synchronous_Connection_Complete_Event
|
self, event: hci.HCI_Synchronous_Connection_Complete_Event
|
||||||
@@ -1397,8 +1566,7 @@ class Host(utils.EventEmitter):
|
|||||||
if event.status == hci.HCI_SUCCESS:
|
if event.status == hci.HCI_SUCCESS:
|
||||||
# Create/update the connection
|
# Create/update the connection
|
||||||
logger.debug(
|
logger.debug(
|
||||||
f'### SCO CONNECTION: [0x{event.connection_handle:04X}] '
|
f'### SCO CONNECTION: [0x{event.connection_handle:04X}] {event.bd_addr}'
|
||||||
f'{event.bd_addr}'
|
|
||||||
)
|
)
|
||||||
|
|
||||||
self.sco_links[event.connection_handle] = ScoLink(
|
self.sco_links[event.connection_handle] = ScoLink(
|
||||||
@@ -1450,7 +1618,7 @@ class Host(utils.EventEmitter):
|
|||||||
def on_hci_le_data_length_change_event(
|
def on_hci_le_data_length_change_event(
|
||||||
self, event: hci.HCI_LE_Data_Length_Change_Event
|
self, event: hci.HCI_LE_Data_Length_Change_Event
|
||||||
):
|
):
|
||||||
if (connection := self.connections.get(event.connection_handle)) is None:
|
if event.connection_handle not in self.connections:
|
||||||
logger.warning('!!! DATA LENGTH CHANGE: unknown handle')
|
logger.warning('!!! DATA LENGTH CHANGE: unknown handle')
|
||||||
return
|
return
|
||||||
|
|
||||||
@@ -1490,6 +1658,19 @@ class Host(utils.EventEmitter):
|
|||||||
'connection_encryption_failure', event.connection_handle, event.status
|
'connection_encryption_failure', event.connection_handle, event.status
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def on_hci_read_remote_supported_features_complete_event(
|
||||||
|
self, event: hci.HCI_Read_Remote_Supported_Features_Complete_Event
|
||||||
|
) -> None:
|
||||||
|
# Notify the client
|
||||||
|
self.emit(
|
||||||
|
'classic_remote_features',
|
||||||
|
event.connection_handle,
|
||||||
|
event.status,
|
||||||
|
int.from_bytes(event.lmp_features, 'little'),
|
||||||
|
0, # page number
|
||||||
|
0, # max page number
|
||||||
|
)
|
||||||
|
|
||||||
def on_hci_encryption_change_v2_event(
|
def on_hci_encryption_change_v2_event(
|
||||||
self, event: hci.HCI_Encryption_Change_V2_Event
|
self, event: hci.HCI_Encryption_Change_V2_Event
|
||||||
):
|
):
|
||||||
@@ -1589,9 +1770,9 @@ class Host(utils.EventEmitter):
|
|||||||
bd_addr=event.bd_addr
|
bd_addr=event.bd_addr
|
||||||
)
|
)
|
||||||
|
|
||||||
await self.send_command(response)
|
await self.send_sync_command(response)
|
||||||
|
|
||||||
asyncio.create_task(send_link_key())
|
utils.AsyncRunner.spawn(send_link_key())
|
||||||
|
|
||||||
def on_hci_io_capability_request_event(
|
def on_hci_io_capability_request_event(
|
||||||
self, event: hci.HCI_IO_Capability_Request_Event
|
self, event: hci.HCI_IO_Capability_Request_Event
|
||||||
@@ -1646,6 +1827,18 @@ class Host(utils.EventEmitter):
|
|||||||
rssi,
|
rssi,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def on_hci_read_remote_extended_features_complete_event(
|
||||||
|
self, event: hci.HCI_Read_Remote_Extended_Features_Complete_Event
|
||||||
|
):
|
||||||
|
self.emit(
|
||||||
|
'classic_remote_features',
|
||||||
|
event.connection_handle,
|
||||||
|
event.status,
|
||||||
|
int.from_bytes(event.extended_lmp_features, 'little'),
|
||||||
|
event.page_number,
|
||||||
|
event.maximum_page_number,
|
||||||
|
)
|
||||||
|
|
||||||
def on_hci_extended_inquiry_result_event(
|
def on_hci_extended_inquiry_result_event(
|
||||||
self, event: hci.HCI_Extended_Inquiry_Result_Event
|
self, event: hci.HCI_Extended_Inquiry_Result_Event
|
||||||
):
|
):
|
||||||
@@ -1686,12 +1879,13 @@ class Host(utils.EventEmitter):
|
|||||||
self.emit(
|
self.emit(
|
||||||
'le_remote_features_failure', event.connection_handle, event.status
|
'le_remote_features_failure', event.connection_handle, event.status
|
||||||
)
|
)
|
||||||
else:
|
return
|
||||||
self.emit(
|
|
||||||
'le_remote_features',
|
self.emit(
|
||||||
event.connection_handle,
|
'le_remote_features',
|
||||||
int.from_bytes(event.le_features, 'little'),
|
event.connection_handle,
|
||||||
)
|
hci.LeFeatureMask(int.from_bytes(event.le_features, 'little')),
|
||||||
|
)
|
||||||
|
|
||||||
def on_hci_le_cs_read_remote_supported_capabilities_complete_event(
|
def on_hci_le_cs_read_remote_supported_capabilities_complete_event(
|
||||||
self, event: hci.HCI_LE_CS_Read_Remote_Supported_Capabilities_Complete_Event
|
self, event: hci.HCI_LE_CS_Read_Remote_Supported_Capabilities_Complete_Event
|
||||||
@@ -1724,6 +1918,12 @@ class Host(utils.EventEmitter):
|
|||||||
self.emit('cs_subevent_result_continue', event)
|
self.emit('cs_subevent_result_continue', event)
|
||||||
|
|
||||||
def on_hci_le_subrate_change_event(self, event: hci.HCI_LE_Subrate_Change_Event):
|
def on_hci_le_subrate_change_event(self, event: hci.HCI_LE_Subrate_Change_Event):
|
||||||
|
if event.status != hci.HCI_SUCCESS:
|
||||||
|
self.emit(
|
||||||
|
'le_subrate_change_failure', event.connection_handle, event.status
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
self.emit(
|
self.emit(
|
||||||
'le_subrate_change',
|
'le_subrate_change',
|
||||||
event.connection_handle,
|
event.connection_handle,
|
||||||
|
|||||||
@@ -27,7 +27,8 @@ import dataclasses
|
|||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
from typing import TYPE_CHECKING, Any, Optional
|
import pathlib
|
||||||
|
from typing import TYPE_CHECKING, Any
|
||||||
|
|
||||||
from typing_extensions import Self
|
from typing_extensions import Self
|
||||||
|
|
||||||
@@ -51,8 +52,8 @@ class PairingKeys:
|
|||||||
class Key:
|
class Key:
|
||||||
value: bytes
|
value: bytes
|
||||||
authenticated: bool = False
|
authenticated: bool = False
|
||||||
ediv: Optional[int] = None
|
ediv: int | None = None
|
||||||
rand: Optional[bytes] = None
|
rand: bytes | None = None
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def from_dict(cls, key_dict: dict[str, Any]) -> PairingKeys.Key:
|
def from_dict(cls, key_dict: dict[str, Any]) -> PairingKeys.Key:
|
||||||
@@ -74,17 +75,17 @@ class PairingKeys:
|
|||||||
|
|
||||||
return key_dict
|
return key_dict
|
||||||
|
|
||||||
address_type: Optional[hci.AddressType] = None
|
address_type: hci.AddressType | None = None
|
||||||
ltk: Optional[Key] = None
|
ltk: Key | None = None
|
||||||
ltk_central: Optional[Key] = None
|
ltk_central: Key | None = None
|
||||||
ltk_peripheral: Optional[Key] = None
|
ltk_peripheral: Key | None = None
|
||||||
irk: Optional[Key] = None
|
irk: Key | None = None
|
||||||
csrk: Optional[Key] = None
|
csrk: Key | None = None
|
||||||
link_key: Optional[Key] = None # Classic
|
link_key: Key | None = None # Classic
|
||||||
link_key_type: Optional[int] = None # Classic
|
link_key_type: int | None = None # Classic
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def key_from_dict(cls, keys_dict: dict[str, Any], key_name: str) -> Optional[Key]:
|
def key_from_dict(cls, keys_dict: dict[str, Any], key_name: str) -> Key | None:
|
||||||
key_dict = keys_dict.get(key_name)
|
key_dict = keys_dict.get(key_name)
|
||||||
if key_dict is None:
|
if key_dict is None:
|
||||||
return None
|
return None
|
||||||
@@ -156,7 +157,7 @@ class KeyStore:
|
|||||||
async def update(self, name: str, keys: PairingKeys) -> None:
|
async def update(self, name: str, keys: PairingKeys) -> None:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
async def get(self, _name: str) -> Optional[PairingKeys]:
|
async def get(self, _name: str) -> PairingKeys | None:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
async def get_all(self) -> list[tuple[str, PairingKeys]]:
|
async def get_all(self) -> list[tuple[str, PairingKeys]]:
|
||||||
@@ -248,33 +249,30 @@ class JsonKeyStore(KeyStore):
|
|||||||
DEFAULT_NAMESPACE = '__DEFAULT__'
|
DEFAULT_NAMESPACE = '__DEFAULT__'
|
||||||
DEFAULT_BASE_NAME = "keys"
|
DEFAULT_BASE_NAME = "keys"
|
||||||
|
|
||||||
def __init__(self, namespace, filename=None):
|
def __init__(
|
||||||
self.namespace = namespace if namespace is not None else self.DEFAULT_NAMESPACE
|
self, namespace: str | None = None, filename: str | None = None
|
||||||
|
) -> None:
|
||||||
|
self.namespace = namespace or self.DEFAULT_NAMESPACE
|
||||||
|
|
||||||
if filename is None:
|
if filename:
|
||||||
# Use a default for the current user
|
self.filename = pathlib.Path(filename).resolve()
|
||||||
|
self.directory_name = self.filename.parent
|
||||||
# Import here because this may not exist on all platforms
|
|
||||||
# pylint: disable=import-outside-toplevel
|
|
||||||
import appdirs
|
|
||||||
|
|
||||||
self.directory_name = os.path.join(
|
|
||||||
appdirs.user_data_dir(self.APP_NAME, self.APP_AUTHOR), self.KEYS_DIR
|
|
||||||
)
|
|
||||||
base_name = self.DEFAULT_BASE_NAME if namespace is None else self.namespace
|
|
||||||
json_filename = (
|
|
||||||
f'{base_name}.json'.lower().replace(':', '-').replace('/p', '-p')
|
|
||||||
)
|
|
||||||
self.filename = os.path.join(self.directory_name, json_filename)
|
|
||||||
else:
|
else:
|
||||||
self.filename = filename
|
import platformdirs # Deferred import
|
||||||
self.directory_name = os.path.dirname(os.path.abspath(self.filename))
|
|
||||||
|
|
||||||
logger.debug(f'JSON keystore: {self.filename}')
|
base_dir = platformdirs.user_data_path(self.APP_NAME, self.APP_AUTHOR)
|
||||||
|
self.directory_name = base_dir / self.KEYS_DIR
|
||||||
|
|
||||||
|
base_name = self.namespace if namespace else self.DEFAULT_BASE_NAME
|
||||||
|
safe_name = base_name.lower().replace(':', '-').replace('/', '-')
|
||||||
|
|
||||||
|
self.filename = self.directory_name / f"{safe_name}.json"
|
||||||
|
|
||||||
|
logger.debug('JSON keystore: %s', self.filename)
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def from_device(
|
def from_device(
|
||||||
cls: type[Self], device: Device, filename: Optional[str] = None
|
cls: type[Self], device: Device, filename: str | None = None
|
||||||
) -> Self:
|
) -> Self:
|
||||||
if not filename:
|
if not filename:
|
||||||
# Extract the filename from the config if there is one
|
# Extract the filename from the config if there is one
|
||||||
@@ -293,11 +291,13 @@ class JsonKeyStore(KeyStore):
|
|||||||
|
|
||||||
return cls(namespace, filename)
|
return cls(namespace, filename)
|
||||||
|
|
||||||
async def load(self):
|
async def load(
|
||||||
|
self,
|
||||||
|
) -> tuple[dict[str, dict[str, dict[str, Any]]], dict[str, dict[str, Any]]]:
|
||||||
# Try to open the file, without failing. If the file does not exist, it
|
# Try to open the file, without failing. If the file does not exist, it
|
||||||
# will be created upon saving.
|
# will be created upon saving.
|
||||||
try:
|
try:
|
||||||
with open(self.filename, 'r', encoding='utf-8') as json_file:
|
with open(self.filename, encoding='utf-8') as json_file:
|
||||||
db = json.load(json_file)
|
db = json.load(json_file)
|
||||||
except FileNotFoundError:
|
except FileNotFoundError:
|
||||||
db = {}
|
db = {}
|
||||||
@@ -312,17 +312,17 @@ class JsonKeyStore(KeyStore):
|
|||||||
return next(iter(db.items()))
|
return next(iter(db.items()))
|
||||||
|
|
||||||
# Finally, just create an empty key map for the namespace
|
# Finally, just create an empty key map for the namespace
|
||||||
key_map = {}
|
key_map: dict[str, dict[str, Any]] = {}
|
||||||
db[self.namespace] = key_map
|
db[self.namespace] = key_map
|
||||||
return (db, key_map)
|
return (db, key_map)
|
||||||
|
|
||||||
async def save(self, db):
|
async def save(self, db: dict[str, dict[str, dict[str, Any]]]) -> None:
|
||||||
# Create the directory if it doesn't exist
|
# Create the directory if it doesn't exist
|
||||||
if not os.path.exists(self.directory_name):
|
if not self.directory_name.exists():
|
||||||
os.makedirs(self.directory_name, exist_ok=True)
|
self.directory_name.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
# Save to a temporary file
|
# Save to a temporary file
|
||||||
temp_filename = self.filename + '.tmp'
|
temp_filename = self.filename.with_name(self.filename.name + ".tmp")
|
||||||
with open(temp_filename, 'w', encoding='utf-8') as output:
|
with open(temp_filename, 'w', encoding='utf-8') as output:
|
||||||
json.dump(db, output, sort_keys=True, indent=4)
|
json.dump(db, output, sort_keys=True, indent=4)
|
||||||
|
|
||||||
@@ -334,21 +334,21 @@ class JsonKeyStore(KeyStore):
|
|||||||
del key_map[name]
|
del key_map[name]
|
||||||
await self.save(db)
|
await self.save(db)
|
||||||
|
|
||||||
async def update(self, name, keys):
|
async def update(self, name: str, keys: PairingKeys) -> None:
|
||||||
db, key_map = await self.load()
|
db, key_map = await self.load()
|
||||||
key_map.setdefault(name, {}).update(keys.to_dict())
|
key_map.setdefault(name, {}).update(keys.to_dict())
|
||||||
await self.save(db)
|
await self.save(db)
|
||||||
|
|
||||||
async def get_all(self):
|
async def get_all(self) -> list[tuple[str, PairingKeys]]:
|
||||||
_, key_map = await self.load()
|
_, key_map = await self.load()
|
||||||
return [(name, PairingKeys.from_dict(keys)) for (name, keys) in key_map.items()]
|
return [(name, PairingKeys.from_dict(keys)) for (name, keys) in key_map.items()]
|
||||||
|
|
||||||
async def delete_all(self):
|
async def delete_all(self) -> None:
|
||||||
db, key_map = await self.load()
|
db, key_map = await self.load()
|
||||||
key_map.clear()
|
key_map.clear()
|
||||||
await self.save(db)
|
await self.save(db)
|
||||||
|
|
||||||
async def get(self, name: str) -> Optional[PairingKeys]:
|
async def get(self, name: str) -> PairingKeys | None:
|
||||||
_, key_map = await self.load()
|
_, key_map = await self.load()
|
||||||
if name not in key_map:
|
if name not in key_map:
|
||||||
return None
|
return None
|
||||||
@@ -370,7 +370,7 @@ class MemoryKeyStore(KeyStore):
|
|||||||
async def update(self, name: str, keys: PairingKeys) -> None:
|
async def update(self, name: str, keys: PairingKeys) -> None:
|
||||||
self.all_keys[name] = keys
|
self.all_keys[name] = keys
|
||||||
|
|
||||||
async def get(self, name: str) -> Optional[PairingKeys]:
|
async def get(self, name: str) -> PairingKeys | None:
|
||||||
return self.all_keys.get(name)
|
return self.all_keys.get(name)
|
||||||
|
|
||||||
async def get_all(self) -> list[tuple[str, PairingKeys]]:
|
async def get_all(self) -> list[tuple[str, PairingKeys]]:
|
||||||
|
|||||||
1317
bumble/l2cap.py
1317
bumble/l2cap.py
File diff suppressed because it is too large
Load Diff
340
bumble/link.py
340
bumble/link.py
@@ -11,6 +11,7 @@
|
|||||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
# See the License for the specific language governing permissions and
|
# See the License for the specific language governing permissions and
|
||||||
# limitations under the License.
|
# limitations under the License.
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
|
|
||||||
@@ -18,18 +19,12 @@ import asyncio
|
|||||||
# Imports
|
# Imports
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
import logging
|
import logging
|
||||||
from typing import Optional
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
from bumble import controller, core
|
from bumble import core, hci, ll, lmp
|
||||||
from bumble.hci import (
|
|
||||||
HCI_CONNECTION_ACCEPT_TIMEOUT_ERROR,
|
if TYPE_CHECKING:
|
||||||
HCI_PAGE_TIMEOUT_ERROR,
|
from bumble import controller
|
||||||
HCI_SUCCESS,
|
|
||||||
HCI_UNKNOWN_CONNECTION_IDENTIFIER_ERROR,
|
|
||||||
Address,
|
|
||||||
HCI_Connection_Complete_Event,
|
|
||||||
Role,
|
|
||||||
)
|
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
# Logging
|
# Logging
|
||||||
@@ -37,18 +32,6 @@ from bumble.hci import (
|
|||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
|
||||||
# Utils
|
|
||||||
# -----------------------------------------------------------------------------
|
|
||||||
def parse_parameters(params_str):
|
|
||||||
result = {}
|
|
||||||
for param_str in params_str.split(','):
|
|
||||||
if '=' in param_str:
|
|
||||||
key, value = param_str.split('=')
|
|
||||||
result[key] = value
|
|
||||||
return result
|
|
||||||
|
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
# TODO: add more support for various LL exchanges
|
# TODO: add more support for various LL exchanges
|
||||||
# (see Vol 6, Part B - 2.4 DATA CHANNEL PDU)
|
# (see Vol 6, Part B - 2.4 DATA CHANNEL PDU)
|
||||||
@@ -62,37 +45,34 @@ class LocalLink:
|
|||||||
|
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
self.controllers = set()
|
self.controllers = set()
|
||||||
self.pending_connection = None
|
|
||||||
self.pending_classic_connection = None
|
self.pending_classic_connection = None
|
||||||
|
|
||||||
############################################################
|
############################################################
|
||||||
# Common utils
|
# Common utils
|
||||||
############################################################
|
############################################################
|
||||||
|
|
||||||
def add_controller(self, controller):
|
def add_controller(self, controller: controller.Controller):
|
||||||
logger.debug(f'new controller: {controller}')
|
logger.debug(f'new controller: {controller}')
|
||||||
self.controllers.add(controller)
|
self.controllers.add(controller)
|
||||||
|
|
||||||
def remove_controller(self, controller):
|
def remove_controller(self, controller: controller.Controller):
|
||||||
self.controllers.remove(controller)
|
self.controllers.remove(controller)
|
||||||
|
|
||||||
def find_controller(self, address):
|
def find_le_controller(self, address: hci.Address) -> controller.Controller | None:
|
||||||
for controller in self.controllers:
|
for controller in self.controllers:
|
||||||
if controller.random_address == address:
|
for connection in controller.le_connections.values():
|
||||||
return controller
|
if connection.self_address == address:
|
||||||
|
return controller
|
||||||
return None
|
return None
|
||||||
|
|
||||||
def find_classic_controller(
|
def find_classic_controller(
|
||||||
self, address: Address
|
self, address: hci.Address
|
||||||
) -> Optional[controller.Controller]:
|
) -> controller.Controller | None:
|
||||||
for controller in self.controllers:
|
for controller in self.controllers:
|
||||||
if controller.public_address == address:
|
if controller.public_address == address:
|
||||||
return controller
|
return controller
|
||||||
return None
|
return None
|
||||||
|
|
||||||
def get_pending_connection(self):
|
|
||||||
return self.pending_connection
|
|
||||||
|
|
||||||
############################################################
|
############################################################
|
||||||
# LE handlers
|
# LE handlers
|
||||||
############################################################
|
############################################################
|
||||||
@@ -100,16 +80,16 @@ class LocalLink:
|
|||||||
def on_address_changed(self, controller):
|
def on_address_changed(self, controller):
|
||||||
pass
|
pass
|
||||||
|
|
||||||
def send_advertising_data(self, sender_address, data):
|
def send_acl_data(
|
||||||
# Send the advertising data to all controllers, except the sender
|
self,
|
||||||
for controller in self.controllers:
|
sender_controller: controller.Controller,
|
||||||
if controller.random_address != sender_address:
|
destination_address: hci.Address,
|
||||||
controller.on_link_advertising_data(sender_address, data)
|
transport: core.PhysicalTransport,
|
||||||
|
data: bytes,
|
||||||
def send_acl_data(self, sender_controller, destination_address, transport, data):
|
):
|
||||||
# Send the data to the first controller with a matching address
|
# Send the data to the first controller with a matching address
|
||||||
if transport == core.PhysicalTransport.LE:
|
if transport == core.PhysicalTransport.LE:
|
||||||
destination_controller = self.find_controller(destination_address)
|
destination_controller = self.find_le_controller(destination_address)
|
||||||
source_address = sender_controller.random_address
|
source_address = sender_controller.random_address
|
||||||
elif transport == core.PhysicalTransport.BR_EDR:
|
elif transport == core.PhysicalTransport.BR_EDR:
|
||||||
destination_controller = self.find_classic_controller(destination_address)
|
destination_controller = self.find_classic_controller(destination_address)
|
||||||
@@ -118,262 +98,52 @@ class LocalLink:
|
|||||||
raise ValueError("unsupported transport type")
|
raise ValueError("unsupported transport type")
|
||||||
|
|
||||||
if destination_controller is not None:
|
if destination_controller is not None:
|
||||||
destination_controller.on_link_acl_data(source_address, transport, data)
|
asyncio.get_running_loop().call_soon(
|
||||||
|
lambda: destination_controller.on_link_acl_data(
|
||||||
def on_connection_complete(self):
|
source_address, transport, data
|
||||||
# Check that we expect this call
|
)
|
||||||
if not self.pending_connection:
|
|
||||||
logger.warning('on_connection_complete with no pending connection')
|
|
||||||
return
|
|
||||||
|
|
||||||
central_address, le_create_connection_command = self.pending_connection
|
|
||||||
self.pending_connection = None
|
|
||||||
|
|
||||||
# Find the controller that initiated the connection
|
|
||||||
if not (central_controller := self.find_controller(central_address)):
|
|
||||||
logger.warning('!!! Initiating controller not found')
|
|
||||||
return
|
|
||||||
|
|
||||||
# Connect to the first controller with a matching address
|
|
||||||
if peripheral_controller := self.find_controller(
|
|
||||||
le_create_connection_command.peer_address
|
|
||||||
):
|
|
||||||
central_controller.on_link_peripheral_connection_complete(
|
|
||||||
le_create_connection_command, HCI_SUCCESS
|
|
||||||
)
|
)
|
||||||
peripheral_controller.on_link_central_connected(central_address)
|
|
||||||
return
|
|
||||||
|
|
||||||
# No peripheral found
|
def send_advertising_pdu(
|
||||||
central_controller.on_link_peripheral_connection_complete(
|
self,
|
||||||
le_create_connection_command, HCI_CONNECTION_ACCEPT_TIMEOUT_ERROR
|
sender_controller: controller.Controller,
|
||||||
)
|
packet: ll.AdvertisingPdu,
|
||||||
|
|
||||||
def connect(self, central_address, le_create_connection_command):
|
|
||||||
logger.debug(
|
|
||||||
f'$$$ CONNECTION {central_address} -> '
|
|
||||||
f'{le_create_connection_command.peer_address}'
|
|
||||||
)
|
|
||||||
self.pending_connection = (central_address, le_create_connection_command)
|
|
||||||
asyncio.get_running_loop().call_soon(self.on_connection_complete)
|
|
||||||
|
|
||||||
def on_disconnection_complete(
|
|
||||||
self, initiating_address, target_address, disconnect_command
|
|
||||||
):
|
):
|
||||||
# Find the controller that initiated the disconnection
|
loop = asyncio.get_running_loop()
|
||||||
if not (initiating_controller := self.find_controller(initiating_address)):
|
for c in self.controllers:
|
||||||
logger.warning('!!! Initiating controller not found')
|
if c != sender_controller:
|
||||||
return
|
loop.call_soon(c.on_ll_advertising_pdu, packet)
|
||||||
|
|
||||||
# Disconnect from the first controller with a matching address
|
def send_ll_control_pdu(
|
||||||
if target_controller := self.find_controller(target_address):
|
self,
|
||||||
target_controller.on_link_disconnected(
|
sender_address: hci.Address,
|
||||||
initiating_address, disconnect_command.reason
|
receiver_address: hci.Address,
|
||||||
)
|
packet: ll.ControlPdu,
|
||||||
|
|
||||||
initiating_controller.on_link_disconnection_complete(
|
|
||||||
disconnect_command, HCI_SUCCESS
|
|
||||||
)
|
|
||||||
|
|
||||||
def disconnect(self, initiating_address, target_address, disconnect_command):
|
|
||||||
logger.debug(
|
|
||||||
f'$$$ DISCONNECTION {initiating_address} -> '
|
|
||||||
f'{target_address}: reason = {disconnect_command.reason}'
|
|
||||||
)
|
|
||||||
args = [initiating_address, target_address, disconnect_command]
|
|
||||||
asyncio.get_running_loop().call_soon(self.on_disconnection_complete, *args)
|
|
||||||
|
|
||||||
# pylint: disable=too-many-arguments
|
|
||||||
def on_connection_encrypted(
|
|
||||||
self, central_address, peripheral_address, rand, ediv, ltk
|
|
||||||
):
|
):
|
||||||
logger.debug(f'*** ENCRYPTION {central_address} -> {peripheral_address}')
|
if not (receiver_controller := self.find_le_controller(receiver_address)):
|
||||||
|
raise core.InvalidArgumentError(
|
||||||
if central_controller := self.find_controller(central_address):
|
f"Unable to find controller for address {receiver_address}"
|
||||||
central_controller.on_link_encrypted(peripheral_address, rand, ediv, ltk)
|
)
|
||||||
|
asyncio.get_running_loop().call_soon(
|
||||||
if peripheral_controller := self.find_controller(peripheral_address):
|
lambda: receiver_controller.on_ll_control_pdu(sender_address, packet)
|
||||||
peripheral_controller.on_link_encrypted(central_address, rand, ediv, ltk)
|
|
||||||
|
|
||||||
def create_cis(
|
|
||||||
self,
|
|
||||||
central_controller: controller.Controller,
|
|
||||||
peripheral_address: Address,
|
|
||||||
cig_id: int,
|
|
||||||
cis_id: int,
|
|
||||||
) -> None:
|
|
||||||
logger.debug(
|
|
||||||
f'$$$ CIS Request {central_controller.random_address} -> {peripheral_address}'
|
|
||||||
)
|
)
|
||||||
if peripheral_controller := self.find_controller(peripheral_address):
|
|
||||||
asyncio.get_running_loop().call_soon(
|
|
||||||
peripheral_controller.on_link_cis_request,
|
|
||||||
central_controller.random_address,
|
|
||||||
cig_id,
|
|
||||||
cis_id,
|
|
||||||
)
|
|
||||||
|
|
||||||
def accept_cis(
|
|
||||||
self,
|
|
||||||
peripheral_controller: controller.Controller,
|
|
||||||
central_address: Address,
|
|
||||||
cig_id: int,
|
|
||||||
cis_id: int,
|
|
||||||
) -> None:
|
|
||||||
logger.debug(
|
|
||||||
f'$$$ CIS Accept {peripheral_controller.random_address} -> {central_address}'
|
|
||||||
)
|
|
||||||
if central_controller := self.find_controller(central_address):
|
|
||||||
asyncio.get_running_loop().call_soon(
|
|
||||||
central_controller.on_link_cis_established, cig_id, cis_id
|
|
||||||
)
|
|
||||||
asyncio.get_running_loop().call_soon(
|
|
||||||
peripheral_controller.on_link_cis_established, cig_id, cis_id
|
|
||||||
)
|
|
||||||
|
|
||||||
def disconnect_cis(
|
|
||||||
self,
|
|
||||||
initiator_controller: controller.Controller,
|
|
||||||
peer_address: Address,
|
|
||||||
cig_id: int,
|
|
||||||
cis_id: int,
|
|
||||||
) -> None:
|
|
||||||
logger.debug(
|
|
||||||
f'$$$ CIS Disconnect {initiator_controller.random_address} -> {peer_address}'
|
|
||||||
)
|
|
||||||
if peer_controller := self.find_controller(peer_address):
|
|
||||||
asyncio.get_running_loop().call_soon(
|
|
||||||
initiator_controller.on_link_cis_disconnected, cig_id, cis_id
|
|
||||||
)
|
|
||||||
asyncio.get_running_loop().call_soon(
|
|
||||||
peer_controller.on_link_cis_disconnected, cig_id, cis_id
|
|
||||||
)
|
|
||||||
|
|
||||||
############################################################
|
############################################################
|
||||||
# Classic handlers
|
# Classic handlers
|
||||||
############################################################
|
############################################################
|
||||||
|
|
||||||
def classic_connect(self, initiator_controller, responder_address):
|
def send_lmp_packet(
|
||||||
logger.debug(
|
|
||||||
f'[Classic] {initiator_controller.public_address} connects to {responder_address}'
|
|
||||||
)
|
|
||||||
responder_controller = self.find_classic_controller(responder_address)
|
|
||||||
if responder_controller is None:
|
|
||||||
initiator_controller.on_classic_connection_complete(
|
|
||||||
responder_address, HCI_PAGE_TIMEOUT_ERROR
|
|
||||||
)
|
|
||||||
return
|
|
||||||
self.pending_classic_connection = (initiator_controller, responder_controller)
|
|
||||||
|
|
||||||
responder_controller.on_classic_connection_request(
|
|
||||||
initiator_controller.public_address,
|
|
||||||
HCI_Connection_Complete_Event.LinkType.ACL,
|
|
||||||
)
|
|
||||||
|
|
||||||
def classic_accept_connection(
|
|
||||||
self, responder_controller, initiator_address, responder_role
|
|
||||||
):
|
|
||||||
logger.debug(
|
|
||||||
f'[Classic] {responder_controller.public_address} accepts to connect {initiator_address}'
|
|
||||||
)
|
|
||||||
initiator_controller = self.find_classic_controller(initiator_address)
|
|
||||||
if initiator_controller is None:
|
|
||||||
responder_controller.on_classic_connection_complete(
|
|
||||||
responder_controller.public_address, HCI_PAGE_TIMEOUT_ERROR
|
|
||||||
)
|
|
||||||
return
|
|
||||||
|
|
||||||
async def task():
|
|
||||||
if responder_role != Role.PERIPHERAL:
|
|
||||||
initiator_controller.on_classic_role_change(
|
|
||||||
responder_controller.public_address, int(not (responder_role))
|
|
||||||
)
|
|
||||||
initiator_controller.on_classic_connection_complete(
|
|
||||||
responder_controller.public_address, HCI_SUCCESS
|
|
||||||
)
|
|
||||||
|
|
||||||
asyncio.create_task(task())
|
|
||||||
responder_controller.on_classic_role_change(
|
|
||||||
initiator_controller.public_address, responder_role
|
|
||||||
)
|
|
||||||
responder_controller.on_classic_connection_complete(
|
|
||||||
initiator_controller.public_address, HCI_SUCCESS
|
|
||||||
)
|
|
||||||
self.pending_classic_connection = None
|
|
||||||
|
|
||||||
def classic_disconnect(self, initiator_controller, responder_address, reason):
|
|
||||||
logger.debug(
|
|
||||||
f'[Classic] {initiator_controller.public_address} disconnects {responder_address}'
|
|
||||||
)
|
|
||||||
responder_controller = self.find_classic_controller(responder_address)
|
|
||||||
|
|
||||||
async def task():
|
|
||||||
initiator_controller.on_classic_disconnected(responder_address, reason)
|
|
||||||
|
|
||||||
asyncio.create_task(task())
|
|
||||||
responder_controller.on_classic_disconnected(
|
|
||||||
initiator_controller.public_address, reason
|
|
||||||
)
|
|
||||||
|
|
||||||
def classic_switch_role(
|
|
||||||
self, initiator_controller, responder_address, initiator_new_role
|
|
||||||
):
|
|
||||||
responder_controller = self.find_classic_controller(responder_address)
|
|
||||||
if responder_controller is None:
|
|
||||||
return
|
|
||||||
|
|
||||||
async def task():
|
|
||||||
initiator_controller.on_classic_role_change(
|
|
||||||
responder_address, initiator_new_role
|
|
||||||
)
|
|
||||||
|
|
||||||
asyncio.create_task(task())
|
|
||||||
responder_controller.on_classic_role_change(
|
|
||||||
initiator_controller.public_address, int(not (initiator_new_role))
|
|
||||||
)
|
|
||||||
|
|
||||||
def classic_sco_connect(
|
|
||||||
self,
|
self,
|
||||||
initiator_controller: controller.Controller,
|
sender_controller: controller.Controller,
|
||||||
responder_address: Address,
|
receiver_address: hci.Address,
|
||||||
link_type: int,
|
packet: lmp.Packet,
|
||||||
):
|
):
|
||||||
logger.debug(
|
if not (receiver_controller := self.find_classic_controller(receiver_address)):
|
||||||
f'[Classic] {initiator_controller.public_address} connects SCO to {responder_address}'
|
raise core.InvalidArgumentError(
|
||||||
)
|
f"Unable to find controller for address {receiver_address}"
|
||||||
responder_controller = self.find_classic_controller(responder_address)
|
|
||||||
# Initiator controller should handle it.
|
|
||||||
assert responder_controller
|
|
||||||
|
|
||||||
responder_controller.on_classic_connection_request(
|
|
||||||
initiator_controller.public_address,
|
|
||||||
link_type,
|
|
||||||
)
|
|
||||||
|
|
||||||
def classic_accept_sco_connection(
|
|
||||||
self,
|
|
||||||
responder_controller: controller.Controller,
|
|
||||||
initiator_address: Address,
|
|
||||||
link_type: int,
|
|
||||||
):
|
|
||||||
logger.debug(
|
|
||||||
f'[Classic] {responder_controller.public_address} accepts to connect SCO {initiator_address}'
|
|
||||||
)
|
|
||||||
initiator_controller = self.find_classic_controller(initiator_address)
|
|
||||||
if initiator_controller is None:
|
|
||||||
responder_controller.on_classic_sco_connection_complete(
|
|
||||||
responder_controller.public_address,
|
|
||||||
HCI_UNKNOWN_CONNECTION_IDENTIFIER_ERROR,
|
|
||||||
link_type,
|
|
||||||
)
|
)
|
||||||
return
|
asyncio.get_running_loop().call_soon(
|
||||||
|
lambda: receiver_controller.on_lmp_packet(
|
||||||
async def task():
|
sender_controller.public_address, packet
|
||||||
initiator_controller.on_classic_sco_connection_complete(
|
|
||||||
responder_controller.public_address, HCI_SUCCESS, link_type
|
|
||||||
)
|
)
|
||||||
|
|
||||||
asyncio.create_task(task())
|
|
||||||
responder_controller.on_classic_sco_connection_complete(
|
|
||||||
initiator_controller.public_address, HCI_SUCCESS, link_type
|
|
||||||
)
|
)
|
||||||
|
|||||||
221
bumble/ll.py
Normal file
221
bumble/ll.py
Normal file
@@ -0,0 +1,221 @@
|
|||||||
|
# Copyright 2021-2025 Google LLC
|
||||||
|
#
|
||||||
|
# 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
|
||||||
|
#
|
||||||
|
# https://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.
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
# Imports
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import dataclasses
|
||||||
|
from typing import ClassVar
|
||||||
|
|
||||||
|
from bumble import hci
|
||||||
|
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
# Advertising PDU
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
class AdvertisingPdu:
|
||||||
|
"""Base Advertising Physical Channel PDU class.
|
||||||
|
|
||||||
|
See Core Spec 6.0, Volume 6, Part B, 2.3. Advertising physical channel PDU.
|
||||||
|
|
||||||
|
Currently these messages don't really follow the LL spec, because LL protocol is
|
||||||
|
context-aware and we don't have real physical transport.
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
@dataclasses.dataclass
|
||||||
|
class ConnectInd(AdvertisingPdu):
|
||||||
|
initiator_address: hci.Address
|
||||||
|
advertiser_address: hci.Address
|
||||||
|
interval: int
|
||||||
|
latency: int
|
||||||
|
timeout: int
|
||||||
|
|
||||||
|
|
||||||
|
@dataclasses.dataclass
|
||||||
|
class AdvInd(AdvertisingPdu):
|
||||||
|
advertiser_address: hci.Address
|
||||||
|
data: bytes
|
||||||
|
|
||||||
|
|
||||||
|
@dataclasses.dataclass
|
||||||
|
class AdvDirectInd(AdvertisingPdu):
|
||||||
|
advertiser_address: hci.Address
|
||||||
|
target_address: hci.Address
|
||||||
|
|
||||||
|
|
||||||
|
@dataclasses.dataclass
|
||||||
|
class AdvNonConnInd(AdvertisingPdu):
|
||||||
|
advertiser_address: hci.Address
|
||||||
|
data: bytes
|
||||||
|
|
||||||
|
|
||||||
|
@dataclasses.dataclass
|
||||||
|
class AdvExtInd(AdvertisingPdu):
|
||||||
|
advertiser_address: hci.Address
|
||||||
|
data: bytes
|
||||||
|
|
||||||
|
target_address: hci.Address | None = None
|
||||||
|
adi: int | None = None
|
||||||
|
tx_power: int | None = None
|
||||||
|
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
# LL Control PDU
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
class ControlPdu:
|
||||||
|
"""Base LL Control PDU Class.
|
||||||
|
|
||||||
|
See Core Spec 6.0, Volume 6, Part B, 2.4.2. LL Control PDU.
|
||||||
|
|
||||||
|
Currently these messages don't really follow the LL spec, because LL protocol is
|
||||||
|
context-aware and we don't have real physical transport.
|
||||||
|
"""
|
||||||
|
|
||||||
|
class Opcode(hci.SpecableEnum):
|
||||||
|
LL_CONNECTION_UPDATE_IND = 0x00
|
||||||
|
LL_CHANNEL_MAP_IND = 0x01
|
||||||
|
LL_TERMINATE_IND = 0x02
|
||||||
|
LL_ENC_REQ = 0x03
|
||||||
|
LL_ENC_RSP = 0x04
|
||||||
|
LL_START_ENC_REQ = 0x05
|
||||||
|
LL_START_ENC_RSP = 0x06
|
||||||
|
LL_UNKNOWN_RSP = 0x07
|
||||||
|
LL_FEATURE_REQ = 0x08
|
||||||
|
LL_FEATURE_RSP = 0x09
|
||||||
|
LL_PAUSE_ENC_REQ = 0x0A
|
||||||
|
LL_PAUSE_ENC_RSP = 0x0B
|
||||||
|
LL_VERSION_IND = 0x0C
|
||||||
|
LL_REJECT_IND = 0x0D
|
||||||
|
LL_PERIPHERAL_FEATURE_REQ = 0x0E
|
||||||
|
LL_CONNECTION_PARAM_REQ = 0x0F
|
||||||
|
LL_CONNECTION_PARAM_RSP = 0x10
|
||||||
|
LL_REJECT_EXT_IND = 0x11
|
||||||
|
LL_PING_REQ = 0x12
|
||||||
|
LL_PING_RSP = 0x13
|
||||||
|
LL_LENGTH_REQ = 0x14
|
||||||
|
LL_LENGTH_RSP = 0x15
|
||||||
|
LL_PHY_REQ = 0x16
|
||||||
|
LL_PHY_RSP = 0x17
|
||||||
|
LL_PHY_UPDATE_IND = 0x18
|
||||||
|
LL_MIN_USED_CHANNELS_IND = 0x19
|
||||||
|
LL_CTE_REQ = 0x1A
|
||||||
|
LL_CTE_RSP = 0x1B
|
||||||
|
LL_PERIODIC_SYNC_IND = 0x1C
|
||||||
|
LL_CLOCK_ACCURACY_REQ = 0x1D
|
||||||
|
LL_CLOCK_ACCURACY_RSP = 0x1E
|
||||||
|
LL_CIS_REQ = 0x1F
|
||||||
|
LL_CIS_RSP = 0x20
|
||||||
|
LL_CIS_IND = 0x21
|
||||||
|
LL_CIS_TERMINATE_IND = 0x22
|
||||||
|
LL_POWER_CONTROL_REQ = 0x23
|
||||||
|
LL_POWER_CONTROL_RSP = 0x24
|
||||||
|
LL_POWER_CHANGE_IND = 0x25
|
||||||
|
LL_SUBRATE_REQ = 0x26
|
||||||
|
LL_SUBRATE_IND = 0x27
|
||||||
|
LL_CHANNEL_REPORTING_IND = 0x28
|
||||||
|
LL_CHANNEL_STATUS_IND = 0x29
|
||||||
|
LL_PERIODIC_SYNC_WR_IND = 0x2A
|
||||||
|
LL_FEATURE_EXT_REQ = 0x2B
|
||||||
|
LL_FEATURE_EXT_RSP = 0x2C
|
||||||
|
LL_CS_SEC_RSP = 0x2D
|
||||||
|
LL_CS_CAPABILITIES_REQ = 0x2E
|
||||||
|
LL_CS_CAPABILITIES_RSP = 0x2F
|
||||||
|
LL_CS_CONFIG_REQ = 0x30
|
||||||
|
LL_CS_CONFIG_RSP = 0x31
|
||||||
|
LL_CS_REQ = 0x32
|
||||||
|
LL_CS_RSP = 0x33
|
||||||
|
LL_CS_IND = 0x34
|
||||||
|
LL_CS_TERMINATE_REQ = 0x35
|
||||||
|
LL_CS_FAE_REQ = 0x36
|
||||||
|
LL_CS_FAE_RSP = 0x37
|
||||||
|
LL_CS_CHANNEL_MAP_IND = 0x38
|
||||||
|
LL_CS_SEC_REQ = 0x39
|
||||||
|
LL_CS_TERMINATE_RSP = 0x3A
|
||||||
|
LL_FRAME_SPACE_REQ = 0x3B
|
||||||
|
LL_FRAME_SPACE_RSP = 0x3C
|
||||||
|
|
||||||
|
opcode: ClassVar[Opcode]
|
||||||
|
|
||||||
|
|
||||||
|
@dataclasses.dataclass
|
||||||
|
class TerminateInd(ControlPdu):
|
||||||
|
opcode = ControlPdu.Opcode.LL_TERMINATE_IND
|
||||||
|
|
||||||
|
error_code: int
|
||||||
|
|
||||||
|
|
||||||
|
@dataclasses.dataclass
|
||||||
|
class EncReq(ControlPdu):
|
||||||
|
opcode = ControlPdu.Opcode.LL_ENC_REQ
|
||||||
|
|
||||||
|
rand: bytes
|
||||||
|
ediv: int
|
||||||
|
ltk: bytes
|
||||||
|
|
||||||
|
|
||||||
|
@dataclasses.dataclass
|
||||||
|
class CisReq(ControlPdu):
|
||||||
|
opcode = ControlPdu.Opcode.LL_CIS_REQ
|
||||||
|
|
||||||
|
cig_id: int
|
||||||
|
cis_id: int
|
||||||
|
|
||||||
|
|
||||||
|
@dataclasses.dataclass
|
||||||
|
class CisRsp(ControlPdu):
|
||||||
|
opcode = ControlPdu.Opcode.LL_CIS_REQ
|
||||||
|
|
||||||
|
cig_id: int
|
||||||
|
cis_id: int
|
||||||
|
|
||||||
|
|
||||||
|
@dataclasses.dataclass
|
||||||
|
class CisInd(ControlPdu):
|
||||||
|
opcode = ControlPdu.Opcode.LL_CIS_REQ
|
||||||
|
|
||||||
|
cig_id: int
|
||||||
|
cis_id: int
|
||||||
|
|
||||||
|
|
||||||
|
@dataclasses.dataclass
|
||||||
|
class CisTerminateInd(ControlPdu):
|
||||||
|
opcode = ControlPdu.Opcode.LL_CIS_TERMINATE_IND
|
||||||
|
|
||||||
|
cig_id: int
|
||||||
|
cis_id: int
|
||||||
|
error_code: int
|
||||||
|
|
||||||
|
|
||||||
|
@dataclasses.dataclass
|
||||||
|
class FeatureReq(ControlPdu):
|
||||||
|
opcode = ControlPdu.Opcode.LL_FEATURE_REQ
|
||||||
|
|
||||||
|
feature_set: bytes
|
||||||
|
|
||||||
|
|
||||||
|
@dataclasses.dataclass
|
||||||
|
class FeatureRsp(ControlPdu):
|
||||||
|
opcode = ControlPdu.Opcode.LL_FEATURE_RSP
|
||||||
|
|
||||||
|
feature_set: bytes
|
||||||
|
|
||||||
|
|
||||||
|
@dataclasses.dataclass
|
||||||
|
class PeripheralFeatureReq(ControlPdu):
|
||||||
|
opcode = ControlPdu.Opcode.LL_PERIPHERAL_FEATURE_REQ
|
||||||
|
|
||||||
|
feature_set: bytes
|
||||||
359
bumble/lmp.py
Normal file
359
bumble/lmp.py
Normal file
@@ -0,0 +1,359 @@
|
|||||||
|
# Copyright 2021-2025 Google LLC
|
||||||
|
#
|
||||||
|
# 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
|
||||||
|
#
|
||||||
|
# https://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.
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
# Imports
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import struct
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from typing import TypeVar
|
||||||
|
|
||||||
|
from bumble import hci, utils
|
||||||
|
|
||||||
|
|
||||||
|
class Opcode(utils.OpenIntEnum):
|
||||||
|
'''
|
||||||
|
See Bluetooth spec @ Vol 2, Part C - 5.1 PDU summary.
|
||||||
|
|
||||||
|
Follow the alphabetical order defined there.
|
||||||
|
'''
|
||||||
|
|
||||||
|
# fmt: off
|
||||||
|
LMP_ACCEPTED = 3
|
||||||
|
LMP_ACCEPTED_EXT = 127 << 8 + 1
|
||||||
|
LMP_AU_RAND = 11
|
||||||
|
LMP_AUTO_RATE = 35
|
||||||
|
LMP_CHANNEL_CLASSIFICATION = 127 << 8 + 17
|
||||||
|
LMP_CHANNEL_CLASSIFICATION_REQ = 127 << 8 + 16
|
||||||
|
LMP_CLK_ADJ = 127 << 8 + 5
|
||||||
|
LMP_CLK_ADJ_ACK = 127 << 8 + 6
|
||||||
|
LMP_CLK_ADJ_REQ = 127 << 8 + 7
|
||||||
|
LMP_CLKOFFSET_REQ = 5
|
||||||
|
LMP_CLKOFFSET_RES = 6
|
||||||
|
LMP_COMB_KEY = 9
|
||||||
|
LMP_DECR_POWER_REQ = 32
|
||||||
|
LMP_DETACH = 7
|
||||||
|
LMP_DHKEY_CHECK = 65
|
||||||
|
LMP_ENCAPSULATED_HEADER = 61
|
||||||
|
LMP_ENCAPSULATED_PAYLOAD = 62
|
||||||
|
LMP_ENCRYPTION_KEY_SIZE_MASK_REQ= 58
|
||||||
|
LMP_ENCRYPTION_KEY_SIZE_MASK_RES= 59
|
||||||
|
LMP_ENCRYPTION_KEY_SIZE_REQ = 16
|
||||||
|
LMP_ENCRYPTION_MODE_REQ = 15
|
||||||
|
LMP_ESCO_LINK_REQ = 127 << 8 + 12
|
||||||
|
LMP_FEATURES_REQ = 39
|
||||||
|
LMP_FEATURES_REQ_EXT = 127 << 8 + 3
|
||||||
|
LMP_FEATURES_RES = 40
|
||||||
|
LMP_FEATURES_RES_EXT = 127 << 8 + 4
|
||||||
|
LMP_HOLD = 20
|
||||||
|
LMP_HOLD_REQ = 21
|
||||||
|
LMP_HOST_CONNECTION_REQ = 51
|
||||||
|
LMP_IN_RAND = 8
|
||||||
|
LMP_INCR_POWER_REQ = 31
|
||||||
|
LMP_IO_CAPABILITY_REQ = 127 << 8 + 25
|
||||||
|
LMP_IO_CAPABILITY_RES = 127 << 8 + 26
|
||||||
|
LMP_KEYPRESS_NOTIFICATION = 127 << 8 + 30
|
||||||
|
LMP_MAX_POWER = 33
|
||||||
|
LMP_MAX_SLOT = 45
|
||||||
|
LMP_MAX_SLOT_REQ = 46
|
||||||
|
LMP_MIN_POWER = 34
|
||||||
|
LMP_NAME_REQ = 1
|
||||||
|
LMP_NAME_RES = 2
|
||||||
|
LMP_NOT_ACCEPTED = 4
|
||||||
|
LMP_NOT_ACCEPTED_EXT = 127 << 8 + 2
|
||||||
|
LMP_NUMERIC_COMPARISON_FAILED = 127 << 8 + 27
|
||||||
|
LMP_OOB_FAILED = 127 << 8 + 29
|
||||||
|
LMP_PACKET_TYPE_TABLE_REQ = 127 << 8 + 11
|
||||||
|
LMP_PAGE_MODE_REQ = 53
|
||||||
|
LMP_PAGE_SCAN_MODE_REQ = 54
|
||||||
|
LMP_PASSKEY_FAILED = 127 << 8 + 28
|
||||||
|
LMP_PAUSE_ENCRYPTION_AES_REQ = 66
|
||||||
|
LMP_PAUSE_ENCRYPTION_REQ = 127 << 8 + 23
|
||||||
|
LMP_PING_REQ = 127 << 8 + 33
|
||||||
|
LMP_PING_RES = 127 << 8 + 34
|
||||||
|
LMP_POWER_CONTROL_REQ = 127 << 8 + 31
|
||||||
|
LMP_POWER_CONTROL_RES = 127 << 8 + 32
|
||||||
|
LMP_PREFERRED_RATE = 36
|
||||||
|
LMP_QUALITY_OF_SERVICE = 41
|
||||||
|
LMP_QUALITY_OF_SERVICE_REQ = 42
|
||||||
|
LMP_REMOVE_ESCO_LINK_REQ = 127 << 8 + 13
|
||||||
|
LMP_REMOVE_SCO_LINK_REQ = 44
|
||||||
|
LMP_RESUME_ENCRYPTION_REQ = 127 << 8 + 24
|
||||||
|
LMP_SAM_DEFINE_MAP = 127 << 8 + 36
|
||||||
|
LMP_SAM_SET_TYPE0 = 127 << 8 + 35
|
||||||
|
LMP_SAM_SWITCH = 127 << 8 + 37
|
||||||
|
LMP_SCO_LINK_REQ = 43
|
||||||
|
LMP_SET_AFH = 60
|
||||||
|
LMP_SETUP_COMPLETE = 49
|
||||||
|
LMP_SIMPLE_PAIRING_CONFIRM = 63
|
||||||
|
LMP_SIMPLE_PAIRING_NUMBER = 64
|
||||||
|
LMP_SLOT_OFFSET = 52
|
||||||
|
LMP_SNIFF_REQ = 23
|
||||||
|
LMP_SNIFF_SUBRATING_REQ = 127 << 8 + 21
|
||||||
|
LMP_SNIFF_SUBRATING_RES = 127 << 8 + 22
|
||||||
|
LMP_SRES = 12
|
||||||
|
LMP_START_ENCRYPTION_REQ = 17
|
||||||
|
LMP_STOP_ENCRYPTION_REQ = 18
|
||||||
|
LMP_SUPERVISION_TIMEOUT = 55
|
||||||
|
LMP_SWITCH_REQ = 19
|
||||||
|
LMP_TEMP_KEY = 14
|
||||||
|
LMP_TEMP_RAND = 13
|
||||||
|
LMP_TEST_ACTIVATE = 56
|
||||||
|
LMP_TEST_CONTROL = 57
|
||||||
|
LMP_TIMING_ACCURACY_REQ = 47
|
||||||
|
LMP_TIMING_ACCURACY_RES = 48
|
||||||
|
LMP_UNIT_KEY = 10
|
||||||
|
LMP_UNSNIFF_REQ = 24
|
||||||
|
LMP_USE_SEMI_PERMANENT_KEY = 50
|
||||||
|
LMP_VERSION_REQ = 37
|
||||||
|
LMP_VERSION_RES = 38
|
||||||
|
# fmt: on
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def parse_from(cls, data: bytes, offset: int = 0) -> tuple[int, Opcode]:
|
||||||
|
opcode = data[offset]
|
||||||
|
if opcode in (124, 127):
|
||||||
|
opcode = struct.unpack('>H', data)[0]
|
||||||
|
return offset + 2, Opcode(opcode)
|
||||||
|
return offset + 1, Opcode(opcode)
|
||||||
|
|
||||||
|
def __bytes__(self) -> bytes:
|
||||||
|
if self.value >> 8:
|
||||||
|
return struct.pack('>H', self.value)
|
||||||
|
return bytes([self.value])
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def type_metadata(cls):
|
||||||
|
return hci.metadata(
|
||||||
|
{
|
||||||
|
'serializer': bytes,
|
||||||
|
'parser': lambda data, offset: (Opcode.parse_from(data, offset)),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class Packet:
|
||||||
|
'''
|
||||||
|
See Bluetooth spec @ Vol 2, Part C - 5.1 PDU summary
|
||||||
|
'''
|
||||||
|
|
||||||
|
subclasses: dict[int, type[Packet]] = {}
|
||||||
|
opcode: Opcode
|
||||||
|
fields: hci.Fields = ()
|
||||||
|
_payload: bytes = b''
|
||||||
|
|
||||||
|
_Packet = TypeVar("_Packet", bound="Packet")
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def subclass(cls, subclass: type[_Packet]) -> type[_Packet]:
|
||||||
|
# Register a factory for this class
|
||||||
|
cls.subclasses[subclass.opcode] = subclass
|
||||||
|
subclass.fields = hci.HCI_Object.fields_from_dataclass(subclass)
|
||||||
|
|
||||||
|
return subclass
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_bytes(cls, data: bytes) -> Packet:
|
||||||
|
offset, opcode = Opcode.parse_from(data)
|
||||||
|
if not (subclass := cls.subclasses.get(opcode)):
|
||||||
|
instance = Packet()
|
||||||
|
instance.opcode = opcode
|
||||||
|
else:
|
||||||
|
instance = subclass(
|
||||||
|
**hci.HCI_Object.dict_from_bytes(data, offset, subclass.fields)
|
||||||
|
)
|
||||||
|
instance.payload = data[offset:]
|
||||||
|
return instance
|
||||||
|
|
||||||
|
@property
|
||||||
|
def payload(self) -> bytes:
|
||||||
|
if self._payload is None:
|
||||||
|
self._payload = hci.HCI_Object.dict_to_bytes(self.__dict__, self.fields)
|
||||||
|
return self._payload
|
||||||
|
|
||||||
|
@payload.setter
|
||||||
|
def payload(self, value: bytes) -> None:
|
||||||
|
self._payload = value
|
||||||
|
|
||||||
|
def __bytes__(self) -> bytes:
|
||||||
|
return bytes(self.opcode) + self.payload
|
||||||
|
|
||||||
|
|
||||||
|
@Packet.subclass
|
||||||
|
@dataclass
|
||||||
|
class LmpAccepted(Packet):
|
||||||
|
opcode = Opcode.LMP_ACCEPTED
|
||||||
|
|
||||||
|
response_opcode: Opcode = field(metadata=Opcode.type_metadata())
|
||||||
|
|
||||||
|
|
||||||
|
@Packet.subclass
|
||||||
|
@dataclass
|
||||||
|
class LmpNotAccepted(Packet):
|
||||||
|
opcode = Opcode.LMP_NOT_ACCEPTED
|
||||||
|
|
||||||
|
response_opcode: Opcode = field(metadata=Opcode.type_metadata())
|
||||||
|
error_code: int = field(metadata=hci.metadata(1))
|
||||||
|
|
||||||
|
|
||||||
|
@Packet.subclass
|
||||||
|
@dataclass
|
||||||
|
class LmpAcceptedExt(Packet):
|
||||||
|
opcode = Opcode.LMP_ACCEPTED_EXT
|
||||||
|
|
||||||
|
response_opcode: Opcode = field(metadata=Opcode.type_metadata())
|
||||||
|
|
||||||
|
|
||||||
|
@Packet.subclass
|
||||||
|
@dataclass
|
||||||
|
class LmpNotAcceptedExt(Packet):
|
||||||
|
opcode = Opcode.LMP_NOT_ACCEPTED_EXT
|
||||||
|
|
||||||
|
response_opcode: Opcode = field(metadata=Opcode.type_metadata())
|
||||||
|
error_code: int = field(metadata=hci.metadata(1))
|
||||||
|
|
||||||
|
|
||||||
|
@Packet.subclass
|
||||||
|
@dataclass
|
||||||
|
class LmpAuRand(Packet):
|
||||||
|
opcode = Opcode.LMP_AU_RAND
|
||||||
|
|
||||||
|
random_number: bytes = field(metadata=hci.metadata(16))
|
||||||
|
|
||||||
|
|
||||||
|
@Packet.subclass
|
||||||
|
@dataclass
|
||||||
|
class LmpDetach(Packet):
|
||||||
|
opcode = Opcode.LMP_DETACH
|
||||||
|
|
||||||
|
error_code: int = field(metadata=hci.metadata(1))
|
||||||
|
|
||||||
|
|
||||||
|
@Packet.subclass
|
||||||
|
@dataclass
|
||||||
|
class LmpEscoLinkReq(Packet):
|
||||||
|
opcode = Opcode.LMP_ESCO_LINK_REQ
|
||||||
|
|
||||||
|
esco_handle: int = field(metadata=hci.metadata(1))
|
||||||
|
esco_lt_addr: int = field(metadata=hci.metadata(1))
|
||||||
|
timing_control_flags: int = field(metadata=hci.metadata(1))
|
||||||
|
d_esco: int = field(metadata=hci.metadata(1))
|
||||||
|
t_esco: int = field(metadata=hci.metadata(1))
|
||||||
|
w_esco: int = field(metadata=hci.metadata(1))
|
||||||
|
esco_packet_type_c_to_p: int = field(metadata=hci.metadata(1))
|
||||||
|
esco_packet_type_p_to_c: int = field(metadata=hci.metadata(1))
|
||||||
|
packet_length_c_to_p: int = field(metadata=hci.metadata(2))
|
||||||
|
packet_length_p_to_c: int = field(metadata=hci.metadata(2))
|
||||||
|
air_mode: int = field(metadata=hci.metadata(1))
|
||||||
|
negotiation_state: int = field(metadata=hci.metadata(1))
|
||||||
|
|
||||||
|
|
||||||
|
@Packet.subclass
|
||||||
|
@dataclass
|
||||||
|
class LmpHostConnectionReq(Packet):
|
||||||
|
opcode = Opcode.LMP_HOST_CONNECTION_REQ
|
||||||
|
|
||||||
|
|
||||||
|
@Packet.subclass
|
||||||
|
@dataclass
|
||||||
|
class LmpRemoveEscoLinkReq(Packet):
|
||||||
|
opcode = Opcode.LMP_REMOVE_ESCO_LINK_REQ
|
||||||
|
|
||||||
|
esco_handle: int = field(metadata=hci.metadata(1))
|
||||||
|
error_code: int = field(metadata=hci.metadata(1))
|
||||||
|
|
||||||
|
|
||||||
|
@Packet.subclass
|
||||||
|
@dataclass
|
||||||
|
class LmpRemoveScoLinkReq(Packet):
|
||||||
|
opcode = Opcode.LMP_REMOVE_SCO_LINK_REQ
|
||||||
|
|
||||||
|
sco_handle: int = field(metadata=hci.metadata(1))
|
||||||
|
error_code: int = field(metadata=hci.metadata(1))
|
||||||
|
|
||||||
|
|
||||||
|
@Packet.subclass
|
||||||
|
@dataclass
|
||||||
|
class LmpScoLinkReq(Packet):
|
||||||
|
opcode = Opcode.LMP_SCO_LINK_REQ
|
||||||
|
|
||||||
|
sco_handle: int = field(metadata=hci.metadata(1))
|
||||||
|
timing_control_flags: int = field(metadata=hci.metadata(1))
|
||||||
|
d_sco: int = field(metadata=hci.metadata(1))
|
||||||
|
t_sco: int = field(metadata=hci.metadata(1))
|
||||||
|
sco_packet: int = field(metadata=hci.metadata(1))
|
||||||
|
air_mode: int = field(metadata=hci.metadata(1))
|
||||||
|
|
||||||
|
|
||||||
|
@Packet.subclass
|
||||||
|
@dataclass
|
||||||
|
class LmpSwitchReq(Packet):
|
||||||
|
opcode = Opcode.LMP_SWITCH_REQ
|
||||||
|
|
||||||
|
switch_instant: int = field(metadata=hci.metadata(4), default=0)
|
||||||
|
|
||||||
|
|
||||||
|
@Packet.subclass
|
||||||
|
@dataclass
|
||||||
|
class LmpNameReq(Packet):
|
||||||
|
opcode = Opcode.LMP_NAME_REQ
|
||||||
|
|
||||||
|
name_offset: int = field(metadata=hci.metadata(2))
|
||||||
|
|
||||||
|
|
||||||
|
@Packet.subclass
|
||||||
|
@dataclass
|
||||||
|
class LmpNameRes(Packet):
|
||||||
|
opcode = Opcode.LMP_NAME_RES
|
||||||
|
|
||||||
|
name_offset: int = field(metadata=hci.metadata(2))
|
||||||
|
name_length: int = field(metadata=hci.metadata(3))
|
||||||
|
name_fregment: bytes = field(metadata=hci.metadata('*'))
|
||||||
|
|
||||||
|
|
||||||
|
@Packet.subclass
|
||||||
|
@dataclass
|
||||||
|
class LmpFeaturesReq(Packet):
|
||||||
|
opcode = Opcode.LMP_FEATURES_REQ
|
||||||
|
|
||||||
|
features: bytes = field(metadata=hci.metadata(8))
|
||||||
|
|
||||||
|
|
||||||
|
@Packet.subclass
|
||||||
|
@dataclass
|
||||||
|
class LmpFeaturesRes(Packet):
|
||||||
|
opcode = Opcode.LMP_FEATURES_RES
|
||||||
|
|
||||||
|
features: bytes = field(metadata=hci.metadata(8))
|
||||||
|
|
||||||
|
|
||||||
|
@Packet.subclass
|
||||||
|
@dataclass
|
||||||
|
class LmpFeaturesReqExt(Packet):
|
||||||
|
opcode = Opcode.LMP_FEATURES_REQ_EXT
|
||||||
|
|
||||||
|
features_page: int = field(metadata=hci.metadata(1))
|
||||||
|
features: bytes = field(metadata=hci.metadata(8))
|
||||||
|
|
||||||
|
|
||||||
|
@Packet.subclass
|
||||||
|
@dataclass
|
||||||
|
class LmpFeaturesResExt(Packet):
|
||||||
|
opcode = Opcode.LMP_FEATURES_RES_EXT
|
||||||
|
|
||||||
|
features_page: int = field(metadata=hci.metadata(1))
|
||||||
|
max_features_page: int = field(metadata=hci.metadata(1))
|
||||||
|
features: bytes = field(metadata=hci.metadata(8))
|
||||||
@@ -20,20 +20,10 @@ from __future__ import annotations
|
|||||||
import enum
|
import enum
|
||||||
import secrets
|
import secrets
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from typing import Optional
|
|
||||||
|
|
||||||
from bumble import hci
|
from bumble import hci, smp
|
||||||
from bumble.core import AdvertisingData, LeRole
|
from bumble.core import AdvertisingData, LeRole
|
||||||
from bumble.smp import (
|
from bumble.smp import (
|
||||||
SMP_DISPLAY_ONLY_IO_CAPABILITY,
|
|
||||||
SMP_DISPLAY_YES_NO_IO_CAPABILITY,
|
|
||||||
SMP_ENC_KEY_DISTRIBUTION_FLAG,
|
|
||||||
SMP_ID_KEY_DISTRIBUTION_FLAG,
|
|
||||||
SMP_KEYBOARD_DISPLAY_IO_CAPABILITY,
|
|
||||||
SMP_KEYBOARD_ONLY_IO_CAPABILITY,
|
|
||||||
SMP_LINK_KEY_DISTRIBUTION_FLAG,
|
|
||||||
SMP_NO_INPUT_NO_OUTPUT_IO_CAPABILITY,
|
|
||||||
SMP_SIGN_KEY_DISTRIBUTION_FLAG,
|
|
||||||
OobContext,
|
OobContext,
|
||||||
OobLegacyContext,
|
OobLegacyContext,
|
||||||
OobSharedData,
|
OobSharedData,
|
||||||
@@ -45,16 +35,16 @@ from bumble.smp import (
|
|||||||
class OobData:
|
class OobData:
|
||||||
"""OOB data that can be sent from one device to another."""
|
"""OOB data that can be sent from one device to another."""
|
||||||
|
|
||||||
address: Optional[hci.Address] = None
|
address: hci.Address | None = None
|
||||||
role: Optional[LeRole] = None
|
role: LeRole | None = None
|
||||||
shared_data: Optional[OobSharedData] = None
|
shared_data: OobSharedData | None = None
|
||||||
legacy_context: Optional[OobLegacyContext] = None
|
legacy_context: OobLegacyContext | None = None
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def from_ad(cls, ad: AdvertisingData) -> OobData:
|
def from_ad(cls, ad: AdvertisingData) -> OobData:
|
||||||
instance = cls()
|
instance = cls()
|
||||||
shared_data_c: Optional[bytes] = None
|
shared_data_c: bytes | None = None
|
||||||
shared_data_r: Optional[bytes] = None
|
shared_data_r: bytes | None = None
|
||||||
for ad_type, ad_data in ad.ad_structures:
|
for ad_type, ad_data in ad.ad_structures:
|
||||||
if ad_type == AdvertisingData.LE_BLUETOOTH_DEVICE_ADDRESS:
|
if ad_type == AdvertisingData.LE_BLUETOOTH_DEVICE_ADDRESS:
|
||||||
instance.address = hci.Address(ad_data)
|
instance.address = hci.Address(ad_data)
|
||||||
@@ -97,11 +87,11 @@ class PairingDelegate:
|
|||||||
# These are defined abstractly, and can be mapped to specific Classic pairing
|
# These are defined abstractly, and can be mapped to specific Classic pairing
|
||||||
# and/or SMP constants.
|
# and/or SMP constants.
|
||||||
class IoCapability(enum.IntEnum):
|
class IoCapability(enum.IntEnum):
|
||||||
NO_OUTPUT_NO_INPUT = SMP_NO_INPUT_NO_OUTPUT_IO_CAPABILITY
|
NO_OUTPUT_NO_INPUT = smp.IoCapability.NO_INPUT_NO_OUTPUT
|
||||||
KEYBOARD_INPUT_ONLY = SMP_KEYBOARD_ONLY_IO_CAPABILITY
|
KEYBOARD_INPUT_ONLY = smp.IoCapability.KEYBOARD_ONLY
|
||||||
DISPLAY_OUTPUT_ONLY = SMP_DISPLAY_ONLY_IO_CAPABILITY
|
DISPLAY_OUTPUT_ONLY = smp.IoCapability.DISPLAY_ONLY
|
||||||
DISPLAY_OUTPUT_AND_YES_NO_INPUT = SMP_DISPLAY_YES_NO_IO_CAPABILITY
|
DISPLAY_OUTPUT_AND_YES_NO_INPUT = smp.IoCapability.DISPLAY_YES_NO
|
||||||
DISPLAY_OUTPUT_AND_KEYBOARD_INPUT = SMP_KEYBOARD_DISPLAY_IO_CAPABILITY
|
DISPLAY_OUTPUT_AND_KEYBOARD_INPUT = smp.IoCapability.KEYBOARD_DISPLAY
|
||||||
|
|
||||||
# Direct names for backward compatibility.
|
# Direct names for backward compatibility.
|
||||||
NO_OUTPUT_NO_INPUT = IoCapability.NO_OUTPUT_NO_INPUT
|
NO_OUTPUT_NO_INPUT = IoCapability.NO_OUTPUT_NO_INPUT
|
||||||
@@ -112,10 +102,10 @@ class PairingDelegate:
|
|||||||
|
|
||||||
# Key Distribution [LE only]
|
# Key Distribution [LE only]
|
||||||
class KeyDistribution(enum.IntFlag):
|
class KeyDistribution(enum.IntFlag):
|
||||||
DISTRIBUTE_ENCRYPTION_KEY = SMP_ENC_KEY_DISTRIBUTION_FLAG
|
DISTRIBUTE_ENCRYPTION_KEY = smp.KeyDistribution.ENC_KEY
|
||||||
DISTRIBUTE_IDENTITY_KEY = SMP_ID_KEY_DISTRIBUTION_FLAG
|
DISTRIBUTE_IDENTITY_KEY = smp.KeyDistribution.ID_KEY
|
||||||
DISTRIBUTE_SIGNING_KEY = SMP_SIGN_KEY_DISTRIBUTION_FLAG
|
DISTRIBUTE_SIGNING_KEY = smp.KeyDistribution.SIGN_KEY
|
||||||
DISTRIBUTE_LINK_KEY = SMP_LINK_KEY_DISTRIBUTION_FLAG
|
DISTRIBUTE_LINK_KEY = smp.KeyDistribution.LINK_KEY
|
||||||
|
|
||||||
DEFAULT_KEY_DISTRIBUTION: KeyDistribution = (
|
DEFAULT_KEY_DISTRIBUTION: KeyDistribution = (
|
||||||
KeyDistribution.DISTRIBUTE_ENCRYPTION_KEY
|
KeyDistribution.DISTRIBUTE_ENCRYPTION_KEY
|
||||||
@@ -181,14 +171,14 @@ class PairingDelegate:
|
|||||||
"""Compare two numbers."""
|
"""Compare two numbers."""
|
||||||
return True
|
return True
|
||||||
|
|
||||||
async def get_number(self) -> Optional[int]:
|
async def get_number(self) -> int | None:
|
||||||
"""
|
"""
|
||||||
Return an optional number as an answer to a passkey request.
|
Return an optional number as an answer to a passkey request.
|
||||||
Returning `None` will result in a negative reply.
|
Returning `None` will result in a negative reply.
|
||||||
"""
|
"""
|
||||||
return 0
|
return 0
|
||||||
|
|
||||||
async def get_string(self, max_length: int) -> Optional[str]:
|
async def get_string(self, max_length: int) -> str | None:
|
||||||
"""
|
"""
|
||||||
Return a string whose utf-8 encoding is up to max_length bytes.
|
Return a string whose utf-8 encoding is up to max_length bytes.
|
||||||
"""
|
"""
|
||||||
@@ -239,18 +229,18 @@ class PairingConfig:
|
|||||||
class OobConfig:
|
class OobConfig:
|
||||||
"""Config for OOB pairing."""
|
"""Config for OOB pairing."""
|
||||||
|
|
||||||
our_context: Optional[OobContext]
|
our_context: OobContext | None
|
||||||
peer_data: Optional[OobSharedData]
|
peer_data: OobSharedData | None
|
||||||
legacy_context: Optional[OobLegacyContext]
|
legacy_context: OobLegacyContext | None
|
||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
sc: bool = True,
|
sc: bool = True,
|
||||||
mitm: bool = True,
|
mitm: bool = True,
|
||||||
bonding: bool = True,
|
bonding: bool = True,
|
||||||
delegate: Optional[PairingDelegate] = None,
|
delegate: PairingDelegate | None = None,
|
||||||
identity_address_type: Optional[AddressType] = None,
|
identity_address_type: AddressType | None = None,
|
||||||
oob: Optional[OobConfig] = None,
|
oob: OobConfig | None = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
self.sc = sc
|
self.sc = sc
|
||||||
self.mitm = mitm
|
self.mitm = mitm
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ This module implement the Pandora Bluetooth test APIs for the Bumble stack.
|
|||||||
|
|
||||||
__version__ = "0.0.1"
|
__version__ = "0.0.1"
|
||||||
|
|
||||||
from typing import Callable, List, Optional
|
from collections.abc import Callable
|
||||||
|
|
||||||
import grpc
|
import grpc
|
||||||
import grpc.aio
|
import grpc.aio
|
||||||
@@ -58,7 +58,7 @@ def register_servicer_hook(
|
|||||||
async def serve(
|
async def serve(
|
||||||
bumble: PandoraDevice,
|
bumble: PandoraDevice,
|
||||||
config: Config = Config(),
|
config: Config = Config(),
|
||||||
grpc_server: Optional[grpc.aio.Server] = None,
|
grpc_server: grpc.aio.Server | None = None,
|
||||||
port: int = 0,
|
port: int = 0,
|
||||||
) -> None:
|
) -> None:
|
||||||
# initialize a gRPC server if not provided.
|
# initialize a gRPC server if not provided.
|
||||||
|
|||||||
@@ -16,7 +16,7 @@
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from typing import Any, Optional
|
from typing import Any
|
||||||
|
|
||||||
from bumble import transport
|
from bumble import transport
|
||||||
from bumble.core import (
|
from bumble.core import (
|
||||||
@@ -54,7 +54,7 @@ class PandoraDevice:
|
|||||||
|
|
||||||
# HCI transport name & instance.
|
# HCI transport name & instance.
|
||||||
_hci_name: str
|
_hci_name: str
|
||||||
_hci: Optional[transport.Transport] # type: ignore[name-defined]
|
_hci: transport.Transport | None # type: ignore[name-defined]
|
||||||
|
|
||||||
def __init__(self, config: dict[str, Any]) -> None:
|
def __init__(self, config: dict[str, Any]) -> None:
|
||||||
self.config = config
|
self.config = config
|
||||||
@@ -74,7 +74,9 @@ class PandoraDevice:
|
|||||||
|
|
||||||
# open HCI transport & set device host.
|
# open HCI transport & set device host.
|
||||||
self._hci = await transport.open_transport(self._hci_name)
|
self._hci = await transport.open_transport(self._hci_name)
|
||||||
self.device.host = Host(controller_source=self._hci.source, controller_sink=self._hci.sink) # type: ignore[no-untyped-call]
|
self.device.host = Host(
|
||||||
|
controller_source=self._hci.source, controller_sink=self._hci.sink
|
||||||
|
) # type: ignore[no-untyped-call]
|
||||||
|
|
||||||
# power-on.
|
# power-on.
|
||||||
await self.device.power_on()
|
await self.device.power_on()
|
||||||
@@ -96,7 +98,7 @@ class PandoraDevice:
|
|||||||
await self.close()
|
await self.close()
|
||||||
await self.open()
|
await self.open()
|
||||||
|
|
||||||
def info(self) -> Optional[dict[str, str]]:
|
def info(self) -> dict[str, str] | None:
|
||||||
return {
|
return {
|
||||||
'public_bd_address': str(self.device.public_address),
|
'public_bd_address': str(self.device.public_address),
|
||||||
'random_address': str(self.device.random_address),
|
'random_address': str(self.device.random_address),
|
||||||
|
|||||||
@@ -17,12 +17,15 @@ from __future__ import annotations
|
|||||||
import asyncio
|
import asyncio
|
||||||
import logging
|
import logging
|
||||||
import struct
|
import struct
|
||||||
from typing import AsyncGenerator, Optional, cast
|
from collections.abc import AsyncGenerator
|
||||||
|
from typing import cast
|
||||||
|
|
||||||
import grpc
|
import grpc
|
||||||
import grpc.aio
|
import grpc.aio
|
||||||
from google.protobuf import any_pb2 # pytype: disable=pyi-error
|
from google.protobuf import (
|
||||||
from google.protobuf import empty_pb2 # pytype: disable=pyi-error
|
any_pb2, # pytype: disable=pyi-error
|
||||||
|
empty_pb2, # pytype: disable=pyi-error
|
||||||
|
)
|
||||||
from pandora import host_pb2
|
from pandora import host_pb2
|
||||||
from pandora.host_grpc_aio import HostServicer
|
from pandora.host_grpc_aio import HostServicer
|
||||||
from pandora.host_pb2 import (
|
from pandora.host_pb2 import (
|
||||||
@@ -302,7 +305,9 @@ class HostService(HostServicer):
|
|||||||
await disconnection_future
|
await disconnection_future
|
||||||
self.log.debug("Disconnected")
|
self.log.debug("Disconnected")
|
||||||
finally:
|
finally:
|
||||||
connection.remove_listener(connection.EVENT_DISCONNECTION, on_disconnection) # type: ignore
|
connection.remove_listener(
|
||||||
|
connection.EVENT_DISCONNECTION, on_disconnection
|
||||||
|
) # type: ignore
|
||||||
|
|
||||||
return empty_pb2.Empty()
|
return empty_pb2.Empty()
|
||||||
|
|
||||||
@@ -539,7 +544,7 @@ class HostService(HostServicer):
|
|||||||
await bumble.utils.cancel_on_event(
|
await bumble.utils.cancel_on_event(
|
||||||
self.device, 'flush', self.device.stop_advertising()
|
self.device, 'flush', self.device.stop_advertising()
|
||||||
)
|
)
|
||||||
except:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
@utils.rpc
|
@utils.rpc
|
||||||
@@ -609,7 +614,7 @@ class HostService(HostServicer):
|
|||||||
await bumble.utils.cancel_on_event(
|
await bumble.utils.cancel_on_event(
|
||||||
self.device, 'flush', self.device.stop_scanning()
|
self.device, 'flush', self.device.stop_scanning()
|
||||||
)
|
)
|
||||||
except:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
@utils.rpc
|
@utils.rpc
|
||||||
@@ -619,7 +624,7 @@ class HostService(HostServicer):
|
|||||||
self.log.debug('Inquiry')
|
self.log.debug('Inquiry')
|
||||||
|
|
||||||
inquiry_queue: asyncio.Queue[
|
inquiry_queue: asyncio.Queue[
|
||||||
Optional[tuple[Address, int, AdvertisingData, int]]
|
tuple[Address, int, AdvertisingData, int] | None
|
||||||
] = asyncio.Queue()
|
] = asyncio.Queue()
|
||||||
complete_handler = self.device.on(
|
complete_handler = self.device.on(
|
||||||
self.device.EVENT_INQUIRY_COMPLETE, lambda: inquiry_queue.put_nowait(None)
|
self.device.EVENT_INQUIRY_COMPLETE, lambda: inquiry_queue.put_nowait(None)
|
||||||
@@ -644,14 +649,18 @@ class HostService(HostServicer):
|
|||||||
)
|
)
|
||||||
|
|
||||||
finally:
|
finally:
|
||||||
self.device.remove_listener(self.device.EVENT_INQUIRY_COMPLETE, complete_handler) # type: ignore
|
self.device.remove_listener(
|
||||||
self.device.remove_listener(self.device.EVENT_INQUIRY_RESULT, result_handler) # type: ignore
|
self.device.EVENT_INQUIRY_COMPLETE, complete_handler
|
||||||
|
) # type: ignore
|
||||||
|
self.device.remove_listener(
|
||||||
|
self.device.EVENT_INQUIRY_RESULT, result_handler
|
||||||
|
) # type: ignore
|
||||||
try:
|
try:
|
||||||
self.log.debug('Stop inquiry')
|
self.log.debug('Stop inquiry')
|
||||||
await bumble.utils.cancel_on_event(
|
await bumble.utils.cancel_on_event(
|
||||||
self.device, 'flush', self.device.stop_discovery()
|
self.device, 'flush', self.device.stop_discovery()
|
||||||
)
|
)
|
||||||
except:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
@utils.rpc
|
@utils.rpc
|
||||||
|
|||||||
@@ -18,15 +18,15 @@ import json
|
|||||||
import logging
|
import logging
|
||||||
from asyncio import Future
|
from asyncio import Future
|
||||||
from asyncio import Queue as AsyncQueue
|
from asyncio import Queue as AsyncQueue
|
||||||
|
from collections.abc import AsyncGenerator
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from typing import AsyncGenerator, Optional, Union
|
|
||||||
|
|
||||||
import grpc
|
import grpc
|
||||||
from google.protobuf import any_pb2, empty_pb2 # pytype: disable=pyi-error
|
from google.protobuf import any_pb2, empty_pb2 # pytype: disable=pyi-error
|
||||||
from pandora.l2cap_grpc_aio import L2CAPServicer # pytype: disable=pyi-error
|
from pandora.l2cap_grpc_aio import L2CAPServicer # pytype: disable=pyi-error
|
||||||
from pandora.l2cap_pb2 import COMMAND_NOT_UNDERSTOOD, INVALID_CID_IN_REQUEST
|
|
||||||
from pandora.l2cap_pb2 import Channel as PandoraChannel # pytype: disable=pyi-error
|
|
||||||
from pandora.l2cap_pb2 import (
|
from pandora.l2cap_pb2 import (
|
||||||
|
COMMAND_NOT_UNDERSTOOD,
|
||||||
|
INVALID_CID_IN_REQUEST,
|
||||||
ConnectRequest,
|
ConnectRequest,
|
||||||
ConnectResponse,
|
ConnectResponse,
|
||||||
CreditBasedChannelRequest,
|
CreditBasedChannelRequest,
|
||||||
@@ -41,6 +41,7 @@ from pandora.l2cap_pb2 import (
|
|||||||
WaitDisconnectionRequest,
|
WaitDisconnectionRequest,
|
||||||
WaitDisconnectionResponse,
|
WaitDisconnectionResponse,
|
||||||
)
|
)
|
||||||
|
from pandora.l2cap_pb2 import Channel as PandoraChannel # pytype: disable=pyi-error
|
||||||
|
|
||||||
from bumble.core import InvalidArgumentError, OutOfResourcesError
|
from bumble.core import InvalidArgumentError, OutOfResourcesError
|
||||||
from bumble.device import Device
|
from bumble.device import Device
|
||||||
@@ -55,7 +56,7 @@ from bumble.l2cap import (
|
|||||||
from bumble.pandora import utils
|
from bumble.pandora import utils
|
||||||
from bumble.pandora.config import Config
|
from bumble.pandora.config import Config
|
||||||
|
|
||||||
L2capChannel = Union[ClassicChannel, LeCreditBasedChannel]
|
L2capChannel = ClassicChannel | LeCreditBasedChannel
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
@@ -106,10 +107,8 @@ class L2CAPService(L2CAPServicer):
|
|||||||
oneof = request.WhichOneof('type')
|
oneof = request.WhichOneof('type')
|
||||||
self.log.debug(f'WaitConnection channel request type: {oneof}.')
|
self.log.debug(f'WaitConnection channel request type: {oneof}.')
|
||||||
channel_type = getattr(request, oneof)
|
channel_type = getattr(request, oneof)
|
||||||
spec: Optional[Union[ClassicChannelSpec, LeCreditBasedChannelSpec]] = None
|
spec: ClassicChannelSpec | LeCreditBasedChannelSpec | None = None
|
||||||
l2cap_server: Optional[
|
l2cap_server: ClassicChannelServer | LeCreditBasedChannelServer | None = None
|
||||||
Union[ClassicChannelServer, LeCreditBasedChannelServer]
|
|
||||||
] = None
|
|
||||||
if isinstance(channel_type, CreditBasedChannelRequest):
|
if isinstance(channel_type, CreditBasedChannelRequest):
|
||||||
spec = LeCreditBasedChannelSpec(
|
spec = LeCreditBasedChannelSpec(
|
||||||
psm=channel_type.spsm,
|
psm=channel_type.spsm,
|
||||||
@@ -216,7 +215,7 @@ class L2CAPService(L2CAPServicer):
|
|||||||
oneof = request.WhichOneof('type')
|
oneof = request.WhichOneof('type')
|
||||||
self.log.debug(f'Channel request type: {oneof}.')
|
self.log.debug(f'Channel request type: {oneof}.')
|
||||||
channel_type = getattr(request, oneof)
|
channel_type = getattr(request, oneof)
|
||||||
spec: Optional[Union[ClassicChannelSpec, LeCreditBasedChannelSpec]] = None
|
spec: ClassicChannelSpec | LeCreditBasedChannelSpec | None = None
|
||||||
if isinstance(channel_type, CreditBasedChannelRequest):
|
if isinstance(channel_type, CreditBasedChannelRequest):
|
||||||
spec = LeCreditBasedChannelSpec(
|
spec = LeCreditBasedChannelSpec(
|
||||||
psm=channel_type.spsm,
|
psm=channel_type.spsm,
|
||||||
@@ -279,7 +278,7 @@ class L2CAPService(L2CAPServicer):
|
|||||||
if not l2cap_channel:
|
if not l2cap_channel:
|
||||||
return SendResponse(error=COMMAND_NOT_UNDERSTOOD)
|
return SendResponse(error=COMMAND_NOT_UNDERSTOOD)
|
||||||
if isinstance(l2cap_channel, ClassicChannel):
|
if isinstance(l2cap_channel, ClassicChannel):
|
||||||
l2cap_channel.send_pdu(request.data)
|
l2cap_channel.write(request.data)
|
||||||
else:
|
else:
|
||||||
l2cap_channel.write(request.data)
|
l2cap_channel.write(request.data)
|
||||||
return SendResponse(success=empty_pb2.Empty())
|
return SendResponse(success=empty_pb2.Empty())
|
||||||
|
|||||||
@@ -17,13 +17,15 @@ from __future__ import annotations
|
|||||||
import asyncio
|
import asyncio
|
||||||
import contextlib
|
import contextlib
|
||||||
import logging
|
import logging
|
||||||
from collections.abc import Awaitable
|
from collections.abc import AsyncGenerator, AsyncIterator, Awaitable, Callable
|
||||||
from typing import Any, AsyncGenerator, AsyncIterator, Callable, Optional, Union
|
from typing import Any
|
||||||
|
|
||||||
import grpc
|
import grpc
|
||||||
from google.protobuf import any_pb2 # pytype: disable=pyi-error
|
from google.protobuf import (
|
||||||
from google.protobuf import empty_pb2 # pytype: disable=pyi-error
|
any_pb2, # pytype: disable=pyi-error
|
||||||
from google.protobuf import wrappers_pb2 # pytype: disable=pyi-error
|
empty_pb2, # pytype: disable=pyi-error
|
||||||
|
wrappers_pb2, # pytype: disable=pyi-error
|
||||||
|
)
|
||||||
from pandora.host_pb2 import Connection
|
from pandora.host_pb2 import Connection
|
||||||
from pandora.security_grpc_aio import SecurityServicer, SecurityStorageServicer
|
from pandora.security_grpc_aio import SecurityServicer, SecurityStorageServicer
|
||||||
from pandora.security_pb2 import (
|
from pandora.security_pb2 import (
|
||||||
@@ -64,7 +66,7 @@ class PairingDelegate(BasePairingDelegate):
|
|||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
connection: BumbleConnection,
|
connection: BumbleConnection,
|
||||||
service: "SecurityService",
|
service: SecurityService,
|
||||||
io_capability: BasePairingDelegate.IoCapability = BasePairingDelegate.NO_OUTPUT_NO_INPUT,
|
io_capability: BasePairingDelegate.IoCapability = BasePairingDelegate.NO_OUTPUT_NO_INPUT,
|
||||||
local_initiator_key_distribution: BasePairingDelegate.KeyDistribution = BasePairingDelegate.DEFAULT_KEY_DISTRIBUTION,
|
local_initiator_key_distribution: BasePairingDelegate.KeyDistribution = BasePairingDelegate.DEFAULT_KEY_DISTRIBUTION,
|
||||||
local_responder_key_distribution: BasePairingDelegate.KeyDistribution = BasePairingDelegate.DEFAULT_KEY_DISTRIBUTION,
|
local_responder_key_distribution: BasePairingDelegate.KeyDistribution = BasePairingDelegate.DEFAULT_KEY_DISTRIBUTION,
|
||||||
@@ -130,7 +132,7 @@ class PairingDelegate(BasePairingDelegate):
|
|||||||
assert answer.answer_variant() == 'confirm' and answer.confirm is not None
|
assert answer.answer_variant() == 'confirm' and answer.confirm is not None
|
||||||
return answer.confirm
|
return answer.confirm
|
||||||
|
|
||||||
async def get_number(self) -> Optional[int]:
|
async def get_number(self) -> int | None:
|
||||||
self.log.debug(
|
self.log.debug(
|
||||||
f"Pairing event: `passkey_entry_request` (io_capability: {self.io_capability})"
|
f"Pairing event: `passkey_entry_request` (io_capability: {self.io_capability})"
|
||||||
)
|
)
|
||||||
@@ -147,7 +149,7 @@ class PairingDelegate(BasePairingDelegate):
|
|||||||
assert answer.answer_variant() == 'passkey'
|
assert answer.answer_variant() == 'passkey'
|
||||||
return answer.passkey
|
return answer.passkey
|
||||||
|
|
||||||
async def get_string(self, max_length: int) -> Optional[str]:
|
async def get_string(self, max_length: int) -> str | None:
|
||||||
self.log.debug(
|
self.log.debug(
|
||||||
f"Pairing event: `pin_code_request` (io_capability: {self.io_capability})"
|
f"Pairing event: `pin_code_request` (io_capability: {self.io_capability})"
|
||||||
)
|
)
|
||||||
@@ -195,8 +197,8 @@ class SecurityService(SecurityServicer):
|
|||||||
self.log = utils.BumbleServerLoggerAdapter(
|
self.log = utils.BumbleServerLoggerAdapter(
|
||||||
logging.getLogger(), {'service_name': 'Security', 'device': device}
|
logging.getLogger(), {'service_name': 'Security', 'device': device}
|
||||||
)
|
)
|
||||||
self.event_queue: Optional[asyncio.Queue[PairingEvent]] = None
|
self.event_queue: asyncio.Queue[PairingEvent] | None = None
|
||||||
self.event_answer: Optional[AsyncIterator[PairingEventAnswer]] = None
|
self.event_answer: AsyncIterator[PairingEventAnswer] | None = None
|
||||||
self.device = device
|
self.device = device
|
||||||
self.config = config
|
self.config = config
|
||||||
|
|
||||||
@@ -231,7 +233,7 @@ class SecurityService(SecurityServicer):
|
|||||||
if level == LEVEL2:
|
if level == LEVEL2:
|
||||||
return connection.encryption != 0 and connection.authenticated
|
return connection.encryption != 0 and connection.authenticated
|
||||||
|
|
||||||
link_key_type: Optional[int] = None
|
link_key_type: int | None = None
|
||||||
if (keystore := connection.device.keystore) and (
|
if (keystore := connection.device.keystore) and (
|
||||||
keys := await keystore.get(str(connection.peer_address))
|
keys := await keystore.get(str(connection.peer_address))
|
||||||
):
|
):
|
||||||
@@ -410,8 +412,8 @@ class SecurityService(SecurityServicer):
|
|||||||
wait_for_security: asyncio.Future[str] = (
|
wait_for_security: asyncio.Future[str] = (
|
||||||
asyncio.get_running_loop().create_future()
|
asyncio.get_running_loop().create_future()
|
||||||
)
|
)
|
||||||
authenticate_task: Optional[asyncio.Future[None]] = None
|
authenticate_task: asyncio.Future[None] | None = None
|
||||||
pair_task: Optional[asyncio.Future[None]] = None
|
pair_task: asyncio.Future[None] | None = None
|
||||||
|
|
||||||
async def authenticate() -> None:
|
async def authenticate() -> None:
|
||||||
if (encryption := connection.encryption) != 0:
|
if (encryption := connection.encryption) != 0:
|
||||||
@@ -455,9 +457,9 @@ class SecurityService(SecurityServicer):
|
|||||||
|
|
||||||
def pair(*_: Any) -> None:
|
def pair(*_: Any) -> None:
|
||||||
if self.need_pairing(connection, level):
|
if self.need_pairing(connection, level):
|
||||||
pair_task = asyncio.create_task(connection.pair())
|
bumble.utils.AsyncRunner.spawn(connection.pair())
|
||||||
|
|
||||||
listeners: dict[str, Callable[..., Union[None, Awaitable[None]]]] = {
|
listeners: dict[str, Callable[..., None | Awaitable[None]]] = {
|
||||||
'disconnection': set_failure('connection_died'),
|
'disconnection': set_failure('connection_died'),
|
||||||
'pairing_failure': set_failure('pairing_failure'),
|
'pairing_failure': set_failure('pairing_failure'),
|
||||||
'connection_authentication_failure': set_failure('authentication_failure'),
|
'connection_authentication_failure': set_failure('authentication_failure'),
|
||||||
@@ -500,7 +502,7 @@ class SecurityService(SecurityServicer):
|
|||||||
return WaitSecurityResponse(**kwargs)
|
return WaitSecurityResponse(**kwargs)
|
||||||
|
|
||||||
async def reached_security_level(
|
async def reached_security_level(
|
||||||
self, connection: BumbleConnection, level: Union[SecurityLevel, LESecurityLevel]
|
self, connection: BumbleConnection, level: SecurityLevel | LESecurityLevel
|
||||||
) -> bool:
|
) -> bool:
|
||||||
self.log.debug(
|
self.log.debug(
|
||||||
str(
|
str(
|
||||||
|
|||||||
@@ -18,7 +18,8 @@ import contextlib
|
|||||||
import functools
|
import functools
|
||||||
import inspect
|
import inspect
|
||||||
import logging
|
import logging
|
||||||
from typing import Any, Generator, MutableMapping, Optional
|
from collections.abc import Generator, MutableMapping
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
import grpc
|
import grpc
|
||||||
from google.protobuf.message import Message # pytype: disable=pyi-error
|
from google.protobuf.message import Message # pytype: disable=pyi-error
|
||||||
@@ -34,7 +35,7 @@ ADDRESS_TYPES: dict[str, AddressType] = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
def address_from_request(request: Message, field: Optional[str]) -> Address:
|
def address_from_request(request: Message, field: str | None) -> Address:
|
||||||
if field is None:
|
if field is None:
|
||||||
return Address.ANY
|
return Address.ANY
|
||||||
return Address(bytes(reversed(getattr(request, field))), ADDRESS_TYPES[field])
|
return Address(bytes(reversed(getattr(request, field))), ADDRESS_TYPES[field])
|
||||||
@@ -95,8 +96,7 @@ def rpc(func: Any) -> Any:
|
|||||||
@functools.wraps(func)
|
@functools.wraps(func)
|
||||||
def gen_wrapper(self: Any, request: Any, context: grpc.ServicerContext) -> Any:
|
def gen_wrapper(self: Any, request: Any, context: grpc.ServicerContext) -> Any:
|
||||||
with exception_to_rpc_error(context):
|
with exception_to_rpc_error(context):
|
||||||
for v in func(self, request, context):
|
yield from func(self, request, context)
|
||||||
yield v
|
|
||||||
|
|
||||||
@functools.wraps(func)
|
@functools.wraps(func)
|
||||||
def wrapper(self: Any, request: Any, context: grpc.ServicerContext) -> Any:
|
def wrapper(self: Any, request: Any, context: grpc.ServicerContext) -> Any:
|
||||||
|
|||||||
@@ -22,7 +22,6 @@ from __future__ import annotations
|
|||||||
import logging
|
import logging
|
||||||
import struct
|
import struct
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from typing import Optional
|
|
||||||
|
|
||||||
from bumble import utils
|
from bumble import utils
|
||||||
from bumble.att import ATT_Error
|
from bumble.att import ATT_Error
|
||||||
@@ -129,7 +128,7 @@ class AudioInputState:
|
|||||||
mute: Mute = Mute.NOT_MUTED
|
mute: Mute = Mute.NOT_MUTED
|
||||||
gain_mode: GainMode = GainMode.MANUAL
|
gain_mode: GainMode = GainMode.MANUAL
|
||||||
change_counter: int = 0
|
change_counter: int = 0
|
||||||
attribute: Optional[Attribute] = None
|
attribute: Attribute | None = None
|
||||||
|
|
||||||
def __bytes__(self) -> bytes:
|
def __bytes__(self) -> bytes:
|
||||||
return bytes(
|
return bytes(
|
||||||
@@ -199,7 +198,6 @@ class AudioInputControlPoint:
|
|||||||
gain_settings_properties: GainSettingsProperties
|
gain_settings_properties: GainSettingsProperties
|
||||||
|
|
||||||
async def on_write(self, connection: Connection, value: bytes) -> None:
|
async def on_write(self, connection: Connection, value: bytes) -> None:
|
||||||
|
|
||||||
opcode = AudioInputControlPointOpCode(value[0])
|
opcode = AudioInputControlPointOpCode(value[0])
|
||||||
|
|
||||||
if opcode == AudioInputControlPointOpCode.SET_GAIN_SETTING:
|
if opcode == AudioInputControlPointOpCode.SET_GAIN_SETTING:
|
||||||
@@ -317,7 +315,7 @@ class AudioInputDescription:
|
|||||||
'''
|
'''
|
||||||
|
|
||||||
audio_input_description: str = "Bluetooth"
|
audio_input_description: str = "Bluetooth"
|
||||||
attribute: Optional[Attribute] = None
|
attribute: Attribute | None = None
|
||||||
|
|
||||||
def on_read(self, _connection: Connection) -> str:
|
def on_read(self, _connection: Connection) -> str:
|
||||||
return self.audio_input_description
|
return self.audio_input_description
|
||||||
@@ -340,11 +338,11 @@ class AICSService(TemplateService):
|
|||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
audio_input_state: Optional[AudioInputState] = None,
|
audio_input_state: AudioInputState | None = None,
|
||||||
gain_settings_properties: Optional[GainSettingsProperties] = None,
|
gain_settings_properties: GainSettingsProperties | None = None,
|
||||||
audio_input_type: str = "local",
|
audio_input_type: str = "local",
|
||||||
audio_input_status: Optional[AudioInputStatus] = None,
|
audio_input_status: AudioInputStatus | None = None,
|
||||||
audio_input_description: Optional[AudioInputDescription] = None,
|
audio_input_description: AudioInputDescription | None = None,
|
||||||
):
|
):
|
||||||
self.audio_input_state = (
|
self.audio_input_state = (
|
||||||
AudioInputState() if audio_input_state is None else audio_input_state
|
AudioInputState() if audio_input_state is None else audio_input_state
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ import asyncio
|
|||||||
import dataclasses
|
import dataclasses
|
||||||
import enum
|
import enum
|
||||||
import logging
|
import logging
|
||||||
from typing import Iterable, Optional, Union
|
from collections.abc import Iterable
|
||||||
|
|
||||||
from bumble import utils
|
from bumble import utils
|
||||||
from bumble.device import Peer
|
from bumble.device import Peer
|
||||||
@@ -230,7 +230,7 @@ class AmsClient(utils.EventEmitter):
|
|||||||
self.supported_commands = set()
|
self.supported_commands = set()
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
async def for_peer(cls, peer: Peer) -> Optional[AmsClient]:
|
async def for_peer(cls, peer: Peer) -> AmsClient | None:
|
||||||
ams_proxy = await peer.discover_service_and_create_proxy(AmsProxy)
|
ams_proxy = await peer.discover_service_and_create_proxy(AmsProxy)
|
||||||
if ams_proxy is None:
|
if ams_proxy is None:
|
||||||
return None
|
return None
|
||||||
@@ -263,9 +263,7 @@ class AmsClient(utils.EventEmitter):
|
|||||||
async def observe(
|
async def observe(
|
||||||
self,
|
self,
|
||||||
entity: EntityId,
|
entity: EntityId,
|
||||||
attributes: Iterable[
|
attributes: Iterable[PlayerAttributeId | QueueAttributeId | TrackAttributeId],
|
||||||
Union[PlayerAttributeId, QueueAttributeId, TrackAttributeId]
|
|
||||||
],
|
|
||||||
) -> None:
|
) -> None:
|
||||||
await self._ams_proxy.entity_update.write_value(
|
await self._ams_proxy.entity_update.write_value(
|
||||||
bytes([entity] + list(attributes)), with_response=True
|
bytes([entity] + list(attributes)), with_response=True
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ import datetime
|
|||||||
import enum
|
import enum
|
||||||
import logging
|
import logging
|
||||||
import struct
|
import struct
|
||||||
from typing import Optional, Sequence, Union
|
from collections.abc import Sequence
|
||||||
|
|
||||||
from bumble import utils
|
from bumble import utils
|
||||||
from bumble.att import ATT_Error
|
from bumble.att import ATT_Error
|
||||||
@@ -116,7 +116,7 @@ class NotificationAttributeId(utils.OpenIntEnum):
|
|||||||
@dataclasses.dataclass
|
@dataclasses.dataclass
|
||||||
class NotificationAttribute:
|
class NotificationAttribute:
|
||||||
attribute_id: NotificationAttributeId
|
attribute_id: NotificationAttributeId
|
||||||
value: Union[str, int, datetime.datetime]
|
value: str | int | datetime.datetime
|
||||||
|
|
||||||
|
|
||||||
@dataclasses.dataclass
|
@dataclasses.dataclass
|
||||||
@@ -242,10 +242,10 @@ class AncsProxy(ProfileServiceProxy):
|
|||||||
|
|
||||||
|
|
||||||
class AncsClient(utils.EventEmitter):
|
class AncsClient(utils.EventEmitter):
|
||||||
_expected_response_command_id: Optional[CommandId]
|
_expected_response_command_id: CommandId | None
|
||||||
_expected_response_notification_uid: Optional[int]
|
_expected_response_notification_uid: int | None
|
||||||
_expected_response_app_identifier: Optional[str]
|
_expected_response_app_identifier: str | None
|
||||||
_expected_app_identifier: Optional[str]
|
_expected_app_identifier: str | None
|
||||||
_expected_response_tuples: int
|
_expected_response_tuples: int
|
||||||
_response_accumulator: bytes
|
_response_accumulator: bytes
|
||||||
|
|
||||||
@@ -255,12 +255,12 @@ class AncsClient(utils.EventEmitter):
|
|||||||
super().__init__()
|
super().__init__()
|
||||||
self._ancs_proxy = ancs_proxy
|
self._ancs_proxy = ancs_proxy
|
||||||
self._command_semaphore = asyncio.Semaphore()
|
self._command_semaphore = asyncio.Semaphore()
|
||||||
self._response: Optional[asyncio.Future] = None
|
self._response: asyncio.Future | None = None
|
||||||
self._reset_response()
|
self._reset_response()
|
||||||
self._started = False
|
self._started = False
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
async def for_peer(cls, peer: Peer) -> Optional[AncsClient]:
|
async def for_peer(cls, peer: Peer) -> AncsClient | None:
|
||||||
ancs_proxy = await peer.discover_service_and_create_proxy(AncsProxy)
|
ancs_proxy = await peer.discover_service_and_create_proxy(AncsProxy)
|
||||||
if ancs_proxy is None:
|
if ancs_proxy is None:
|
||||||
return None
|
return None
|
||||||
@@ -316,7 +316,7 @@ class AncsClient(utils.EventEmitter):
|
|||||||
# Not enough data yet.
|
# Not enough data yet.
|
||||||
return
|
return
|
||||||
|
|
||||||
attributes: list[Union[NotificationAttribute, AppAttribute]] = []
|
attributes: list[NotificationAttribute | AppAttribute] = []
|
||||||
|
|
||||||
if command_id == CommandId.GET_NOTIFICATION_ATTRIBUTES:
|
if command_id == CommandId.GET_NOTIFICATION_ATTRIBUTES:
|
||||||
(notification_uid,) = struct.unpack_from(
|
(notification_uid,) = struct.unpack_from(
|
||||||
@@ -342,7 +342,7 @@ class AncsClient(utils.EventEmitter):
|
|||||||
str_value = attribute_data[3 : 3 + attribute_data_length].decode(
|
str_value = attribute_data[3 : 3 + attribute_data_length].decode(
|
||||||
"utf-8"
|
"utf-8"
|
||||||
)
|
)
|
||||||
value: Union[str, int, datetime.datetime]
|
value: str | int | datetime.datetime
|
||||||
if attribute_id == NotificationAttributeId.MESSAGE_SIZE:
|
if attribute_id == NotificationAttributeId.MESSAGE_SIZE:
|
||||||
value = int(str_value)
|
value = int(str_value)
|
||||||
elif attribute_id == NotificationAttributeId.DATE:
|
elif attribute_id == NotificationAttributeId.DATE:
|
||||||
@@ -415,7 +415,7 @@ class AncsClient(utils.EventEmitter):
|
|||||||
self,
|
self,
|
||||||
notification_uid: int,
|
notification_uid: int,
|
||||||
attributes: Sequence[
|
attributes: Sequence[
|
||||||
Union[NotificationAttributeId, tuple[NotificationAttributeId, int]]
|
NotificationAttributeId | tuple[NotificationAttributeId, int]
|
||||||
],
|
],
|
||||||
) -> list[NotificationAttribute]:
|
) -> list[NotificationAttribute]:
|
||||||
if not self._started:
|
if not self._started:
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ import logging
|
|||||||
import struct
|
import struct
|
||||||
from collections.abc import Sequence
|
from collections.abc import Sequence
|
||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
from typing import Any, Optional, TypeVar, Union
|
from typing import Any, TypeVar
|
||||||
|
|
||||||
from bumble import colors, device, gatt, gatt_client, hci, utils
|
from bumble import colors, device, gatt, gatt_client, hci, utils
|
||||||
from bumble.profiles import le_audio
|
from bumble.profiles import le_audio
|
||||||
@@ -49,7 +49,7 @@ class ASE_Operation:
|
|||||||
classes: dict[int, type[ASE_Operation]] = {}
|
classes: dict[int, type[ASE_Operation]] = {}
|
||||||
op_code: Opcode
|
op_code: Opcode
|
||||||
name: str
|
name: str
|
||||||
fields: Optional[Sequence[Any]] = None
|
fields: Sequence[Any] | None = None
|
||||||
ase_id: Sequence[int]
|
ase_id: Sequence[int]
|
||||||
|
|
||||||
class Opcode(enum.IntEnum):
|
class Opcode(enum.IntEnum):
|
||||||
@@ -278,7 +278,7 @@ class AseStateMachine(gatt.Characteristic):
|
|||||||
|
|
||||||
EVENT_STATE_CHANGE = "state_change"
|
EVENT_STATE_CHANGE = "state_change"
|
||||||
|
|
||||||
cis_link: Optional[device.CisLink] = None
|
cis_link: device.CisLink | None = None
|
||||||
|
|
||||||
# Additional parameters in CODEC_CONFIGURED State
|
# Additional parameters in CODEC_CONFIGURED State
|
||||||
preferred_framing = 0 # Unframed PDU supported
|
preferred_framing = 0 # Unframed PDU supported
|
||||||
@@ -290,7 +290,7 @@ class AseStateMachine(gatt.Characteristic):
|
|||||||
preferred_presentation_delay_min = 0
|
preferred_presentation_delay_min = 0
|
||||||
preferred_presentation_delay_max = 0
|
preferred_presentation_delay_max = 0
|
||||||
codec_id = hci.CodingFormat(hci.CodecID.LC3)
|
codec_id = hci.CodingFormat(hci.CodecID.LC3)
|
||||||
codec_specific_configuration: Union[CodecSpecificConfiguration, bytes] = b''
|
codec_specific_configuration: CodecSpecificConfiguration | bytes = b''
|
||||||
|
|
||||||
# Additional parameters in QOS_CONFIGURED State
|
# Additional parameters in QOS_CONFIGURED State
|
||||||
cig_id = 0
|
cig_id = 0
|
||||||
@@ -610,7 +610,7 @@ class AudioStreamControlService(gatt.TemplateService):
|
|||||||
|
|
||||||
ase_state_machines: dict[int, AseStateMachine]
|
ase_state_machines: dict[int, AseStateMachine]
|
||||||
ase_control_point: gatt.Characteristic[bytes]
|
ase_control_point: gatt.Characteristic[bytes]
|
||||||
_active_client: Optional[device.Connection] = None
|
_active_client: device.Connection | None = None
|
||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
@@ -664,46 +664,44 @@ class AudioStreamControlService(gatt.TemplateService):
|
|||||||
responses = []
|
responses = []
|
||||||
logger.debug(f'*** ASCS Write {operation} ***')
|
logger.debug(f'*** ASCS Write {operation} ***')
|
||||||
|
|
||||||
if isinstance(operation, ASE_Config_Codec):
|
match operation:
|
||||||
for ase_id, *args in zip(
|
case ASE_Config_Codec():
|
||||||
operation.ase_id,
|
for ase_id, *args in zip(
|
||||||
operation.target_latency,
|
operation.ase_id,
|
||||||
operation.target_phy,
|
operation.target_latency,
|
||||||
operation.codec_id,
|
operation.target_phy,
|
||||||
operation.codec_specific_configuration,
|
operation.codec_id,
|
||||||
|
operation.codec_specific_configuration,
|
||||||
|
):
|
||||||
|
responses.append(self.on_operation(operation.op_code, ase_id, args))
|
||||||
|
case ASE_Config_QOS():
|
||||||
|
for ase_id, *args in zip(
|
||||||
|
operation.ase_id,
|
||||||
|
operation.cig_id,
|
||||||
|
operation.cis_id,
|
||||||
|
operation.sdu_interval,
|
||||||
|
operation.framing,
|
||||||
|
operation.phy,
|
||||||
|
operation.max_sdu,
|
||||||
|
operation.retransmission_number,
|
||||||
|
operation.max_transport_latency,
|
||||||
|
operation.presentation_delay,
|
||||||
|
):
|
||||||
|
responses.append(self.on_operation(operation.op_code, ase_id, args))
|
||||||
|
case ASE_Enable() | ASE_Update_Metadata():
|
||||||
|
for ase_id, *args in zip(
|
||||||
|
operation.ase_id,
|
||||||
|
operation.metadata,
|
||||||
|
):
|
||||||
|
responses.append(self.on_operation(operation.op_code, ase_id, args))
|
||||||
|
case (
|
||||||
|
ASE_Receiver_Start_Ready()
|
||||||
|
| ASE_Disable()
|
||||||
|
| ASE_Receiver_Stop_Ready()
|
||||||
|
| ASE_Release()
|
||||||
):
|
):
|
||||||
responses.append(self.on_operation(operation.op_code, ase_id, args))
|
for ase_id in operation.ase_id:
|
||||||
elif isinstance(operation, ASE_Config_QOS):
|
responses.append(self.on_operation(operation.op_code, ase_id, []))
|
||||||
for ase_id, *args in zip(
|
|
||||||
operation.ase_id,
|
|
||||||
operation.cig_id,
|
|
||||||
operation.cis_id,
|
|
||||||
operation.sdu_interval,
|
|
||||||
operation.framing,
|
|
||||||
operation.phy,
|
|
||||||
operation.max_sdu,
|
|
||||||
operation.retransmission_number,
|
|
||||||
operation.max_transport_latency,
|
|
||||||
operation.presentation_delay,
|
|
||||||
):
|
|
||||||
responses.append(self.on_operation(operation.op_code, ase_id, args))
|
|
||||||
elif isinstance(operation, (ASE_Enable, ASE_Update_Metadata)):
|
|
||||||
for ase_id, *args in zip(
|
|
||||||
operation.ase_id,
|
|
||||||
operation.metadata,
|
|
||||||
):
|
|
||||||
responses.append(self.on_operation(operation.op_code, ase_id, args))
|
|
||||||
elif isinstance(
|
|
||||||
operation,
|
|
||||||
(
|
|
||||||
ASE_Receiver_Start_Ready,
|
|
||||||
ASE_Disable,
|
|
||||||
ASE_Receiver_Stop_Ready,
|
|
||||||
ASE_Release,
|
|
||||||
),
|
|
||||||
):
|
|
||||||
for ase_id in operation.ase_id:
|
|
||||||
responses.append(self.on_operation(operation.op_code, ase_id, []))
|
|
||||||
|
|
||||||
control_point_notification = bytes(
|
control_point_notification = bytes(
|
||||||
[operation.op_code, len(responses)]
|
[operation.op_code, len(responses)]
|
||||||
|
|||||||
@@ -19,7 +19,8 @@
|
|||||||
import enum
|
import enum
|
||||||
import logging
|
import logging
|
||||||
import struct
|
import struct
|
||||||
from typing import Any, Callable, Optional, Union
|
from collections.abc import Callable
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
from bumble import data_types, gatt, gatt_client, l2cap, utils
|
from bumble import data_types, gatt, gatt_client, l2cap, utils
|
||||||
from bumble.core import AdvertisingData
|
from bumble.core import AdvertisingData
|
||||||
@@ -90,20 +91,20 @@ class AshaService(gatt.TemplateService):
|
|||||||
EVENT_DISCONNECTED = "disconnected"
|
EVENT_DISCONNECTED = "disconnected"
|
||||||
EVENT_VOLUME_CHANGED = "volume_changed"
|
EVENT_VOLUME_CHANGED = "volume_changed"
|
||||||
|
|
||||||
audio_sink: Optional[Callable[[bytes], Any]]
|
audio_sink: Callable[[bytes], Any] | None
|
||||||
active_codec: Optional[Codec] = None
|
active_codec: Codec | None = None
|
||||||
audio_type: Optional[AudioType] = None
|
audio_type: AudioType | None = None
|
||||||
volume: Optional[int] = None
|
volume: int | None = None
|
||||||
other_state: Optional[int] = None
|
other_state: int | None = None
|
||||||
connection: Optional[Connection] = None
|
connection: Connection | None = None
|
||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
capability: int,
|
capability: int,
|
||||||
hisyncid: Union[list[int], bytes],
|
hisyncid: list[int] | bytes,
|
||||||
device: Device,
|
device: Device,
|
||||||
psm: int = 0,
|
psm: int = 0,
|
||||||
audio_sink: Optional[Callable[[bytes], Any]] = None,
|
audio_sink: Callable[[bytes], Any] | None = None,
|
||||||
feature_map: int = FeatureMap.LE_COC_AUDIO_OUTPUT_STREAMING_SUPPORTED,
|
feature_map: int = FeatureMap.LE_COC_AUDIO_OUTPUT_STREAMING_SUPPORTED,
|
||||||
protocol_version: int = 0x01,
|
protocol_version: int = 0x01,
|
||||||
render_delay_milliseconds: int = 0,
|
render_delay_milliseconds: int = 0,
|
||||||
|
|||||||
@@ -333,17 +333,18 @@ class CodecSpecificCapabilities:
|
|||||||
value = int.from_bytes(data[offset : offset + length - 1], 'little')
|
value = int.from_bytes(data[offset : offset + length - 1], 'little')
|
||||||
offset += length - 1
|
offset += length - 1
|
||||||
|
|
||||||
if type == CodecSpecificCapabilities.Type.SAMPLING_FREQUENCY:
|
match type:
|
||||||
supported_sampling_frequencies = SupportedSamplingFrequency(value)
|
case CodecSpecificCapabilities.Type.SAMPLING_FREQUENCY:
|
||||||
elif type == CodecSpecificCapabilities.Type.FRAME_DURATION:
|
supported_sampling_frequencies = SupportedSamplingFrequency(value)
|
||||||
supported_frame_durations = SupportedFrameDuration(value)
|
case CodecSpecificCapabilities.Type.FRAME_DURATION:
|
||||||
elif type == CodecSpecificCapabilities.Type.AUDIO_CHANNEL_COUNT:
|
supported_frame_durations = SupportedFrameDuration(value)
|
||||||
supported_audio_channel_count = bits_to_channel_counts(value)
|
case CodecSpecificCapabilities.Type.AUDIO_CHANNEL_COUNT:
|
||||||
elif type == CodecSpecificCapabilities.Type.OCTETS_PER_FRAME:
|
supported_audio_channel_count = bits_to_channel_counts(value)
|
||||||
min_octets_per_sample = value & 0xFFFF
|
case CodecSpecificCapabilities.Type.OCTETS_PER_FRAME:
|
||||||
max_octets_per_sample = value >> 16
|
min_octets_per_sample = value & 0xFFFF
|
||||||
elif type == CodecSpecificCapabilities.Type.CODEC_FRAMES_PER_SDU:
|
max_octets_per_sample = value >> 16
|
||||||
supported_max_codec_frames_per_sdu = value
|
case CodecSpecificCapabilities.Type.CODEC_FRAMES_PER_SDU:
|
||||||
|
supported_max_codec_frames_per_sdu = value
|
||||||
|
|
||||||
# It is expected here that if some fields are missing, an error should be raised.
|
# It is expected here that if some fields are missing, an error should be raised.
|
||||||
# pylint: disable=possibly-used-before-assignment,used-before-assignment
|
# pylint: disable=possibly-used-before-assignment,used-before-assignment
|
||||||
|
|||||||
@@ -21,7 +21,8 @@ from __future__ import annotations
|
|||||||
import dataclasses
|
import dataclasses
|
||||||
import logging
|
import logging
|
||||||
import struct
|
import struct
|
||||||
from typing import ClassVar, Optional, Sequence
|
from collections.abc import Sequence
|
||||||
|
from typing import ClassVar
|
||||||
|
|
||||||
from bumble import core, device, gatt, gatt_adapters, gatt_client, hci, utils
|
from bumble import core, device, gatt, gatt_adapters, gatt_client, hci, utils
|
||||||
|
|
||||||
@@ -337,7 +338,12 @@ class BroadcastAudioScanService(gatt.TemplateService):
|
|||||||
b"12", # TEST
|
b"12", # TEST
|
||||||
)
|
)
|
||||||
|
|
||||||
super().__init__([self.battery_level_characteristic])
|
super().__init__(
|
||||||
|
[
|
||||||
|
self.broadcast_audio_scan_control_point_characteristic,
|
||||||
|
self.broadcast_receive_state_characteristic,
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
def on_broadcast_audio_scan_control_point_write(
|
def on_broadcast_audio_scan_control_point_write(
|
||||||
self, connection: device.Connection, value: bytes
|
self, connection: device.Connection, value: bytes
|
||||||
@@ -351,7 +357,7 @@ class BroadcastAudioScanServiceProxy(gatt_client.ProfileServiceProxy):
|
|||||||
|
|
||||||
broadcast_audio_scan_control_point: gatt_client.CharacteristicProxy[bytes]
|
broadcast_audio_scan_control_point: gatt_client.CharacteristicProxy[bytes]
|
||||||
broadcast_receive_states: list[
|
broadcast_receive_states: list[
|
||||||
gatt_client.CharacteristicProxy[Optional[BroadcastReceiveState]]
|
gatt_client.CharacteristicProxy[BroadcastReceiveState | None]
|
||||||
]
|
]
|
||||||
|
|
||||||
def __init__(self, service_proxy: gatt_client.ServiceProxy):
|
def __init__(self, service_proxy: gatt_client.ServiceProxy):
|
||||||
|
|||||||
@@ -16,36 +16,28 @@
|
|||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
# Imports
|
# Imports
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
from typing import Optional
|
from collections.abc import Callable
|
||||||
|
|
||||||
from bumble.gatt import (
|
from bumble import device, gatt, gatt_adapters, gatt_client
|
||||||
GATT_BATTERY_LEVEL_CHARACTERISTIC,
|
|
||||||
GATT_BATTERY_SERVICE,
|
|
||||||
Characteristic,
|
|
||||||
CharacteristicValue,
|
|
||||||
TemplateService,
|
|
||||||
)
|
|
||||||
from bumble.gatt_adapters import (
|
|
||||||
PackedCharacteristicAdapter,
|
|
||||||
PackedCharacteristicProxyAdapter,
|
|
||||||
)
|
|
||||||
from bumble.gatt_client import CharacteristicProxy, ProfileServiceProxy
|
|
||||||
|
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
class BatteryService(TemplateService):
|
class BatteryService(gatt.TemplateService):
|
||||||
UUID = GATT_BATTERY_SERVICE
|
UUID = gatt.GATT_BATTERY_SERVICE
|
||||||
BATTERY_LEVEL_FORMAT = 'B'
|
BATTERY_LEVEL_FORMAT = 'B'
|
||||||
|
|
||||||
battery_level_characteristic: Characteristic[int]
|
battery_level_characteristic: gatt.Characteristic[int]
|
||||||
|
|
||||||
def __init__(self, read_battery_level):
|
def __init__(self, read_battery_level: Callable[[device.Connection], int]) -> None:
|
||||||
self.battery_level_characteristic = PackedCharacteristicAdapter(
|
self.battery_level_characteristic = gatt_adapters.PackedCharacteristicAdapter(
|
||||||
Characteristic(
|
gatt.Characteristic(
|
||||||
GATT_BATTERY_LEVEL_CHARACTERISTIC,
|
gatt.GATT_BATTERY_LEVEL_CHARACTERISTIC,
|
||||||
Characteristic.Properties.READ | Characteristic.Properties.NOTIFY,
|
properties=(
|
||||||
Characteristic.READABLE,
|
gatt.Characteristic.Properties.READ
|
||||||
CharacteristicValue(read=read_battery_level),
|
| gatt.Characteristic.Properties.NOTIFY
|
||||||
|
),
|
||||||
|
permissions=gatt.Characteristic.READABLE,
|
||||||
|
value=gatt.CharacteristicValue(read=read_battery_level),
|
||||||
),
|
),
|
||||||
pack_format=BatteryService.BATTERY_LEVEL_FORMAT,
|
pack_format=BatteryService.BATTERY_LEVEL_FORMAT,
|
||||||
)
|
)
|
||||||
@@ -53,19 +45,17 @@ class BatteryService(TemplateService):
|
|||||||
|
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
class BatteryServiceProxy(ProfileServiceProxy):
|
class BatteryServiceProxy(gatt_client.ProfileServiceProxy):
|
||||||
SERVICE_CLASS = BatteryService
|
SERVICE_CLASS = BatteryService
|
||||||
|
|
||||||
battery_level: Optional[CharacteristicProxy[int]]
|
battery_level: gatt_client.CharacteristicProxy[int]
|
||||||
|
|
||||||
def __init__(self, service_proxy):
|
def __init__(self, service_proxy: gatt_client.ServiceProxy) -> None:
|
||||||
self.service_proxy = service_proxy
|
self.service_proxy = service_proxy
|
||||||
|
|
||||||
if characteristics := service_proxy.get_characteristics_by_uuid(
|
self.battery_level = gatt_adapters.PackedCharacteristicProxyAdapter(
|
||||||
GATT_BATTERY_LEVEL_CHARACTERISTIC
|
service_proxy.get_required_characteristic_by_uuid(
|
||||||
):
|
gatt.GATT_BATTERY_LEVEL_CHARACTERISTIC
|
||||||
self.battery_level = PackedCharacteristicProxyAdapter(
|
),
|
||||||
characteristics[0], pack_format=BatteryService.BATTERY_LEVEL_FORMAT
|
pack_format=BatteryService.BATTERY_LEVEL_FORMAT,
|
||||||
)
|
)
|
||||||
else:
|
|
||||||
self.battery_level = None
|
|
||||||
|
|||||||
@@ -20,7 +20,6 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import enum
|
import enum
|
||||||
import struct
|
import struct
|
||||||
from typing import Optional
|
|
||||||
|
|
||||||
from bumble import core, crypto, device, gatt, gatt_client
|
from bumble import core, crypto, device, gatt, gatt_client
|
||||||
|
|
||||||
@@ -96,17 +95,17 @@ class CoordinatedSetIdentificationService(gatt.TemplateService):
|
|||||||
|
|
||||||
set_identity_resolving_key: bytes
|
set_identity_resolving_key: bytes
|
||||||
set_identity_resolving_key_characteristic: gatt.Characteristic[bytes]
|
set_identity_resolving_key_characteristic: gatt.Characteristic[bytes]
|
||||||
coordinated_set_size_characteristic: Optional[gatt.Characteristic[bytes]] = None
|
coordinated_set_size_characteristic: gatt.Characteristic[bytes] | None = None
|
||||||
set_member_lock_characteristic: Optional[gatt.Characteristic[bytes]] = None
|
set_member_lock_characteristic: gatt.Characteristic[bytes] | None = None
|
||||||
set_member_rank_characteristic: Optional[gatt.Characteristic[bytes]] = None
|
set_member_rank_characteristic: gatt.Characteristic[bytes] | None = None
|
||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
set_identity_resolving_key: bytes,
|
set_identity_resolving_key: bytes,
|
||||||
set_identity_resolving_key_type: SirkType,
|
set_identity_resolving_key_type: SirkType,
|
||||||
coordinated_set_size: Optional[int] = None,
|
coordinated_set_size: int | None = None,
|
||||||
set_member_lock: Optional[MemberLock] = None,
|
set_member_lock: MemberLock | None = None,
|
||||||
set_member_rank: Optional[int] = None,
|
set_member_rank: int | None = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
if len(set_identity_resolving_key) != SET_IDENTITY_RESOLVING_KEY_LENGTH:
|
if len(set_identity_resolving_key) != SET_IDENTITY_RESOLVING_KEY_LENGTH:
|
||||||
raise core.InvalidArgumentError(
|
raise core.InvalidArgumentError(
|
||||||
@@ -198,9 +197,9 @@ class CoordinatedSetIdentificationProxy(gatt_client.ProfileServiceProxy):
|
|||||||
SERVICE_CLASS = CoordinatedSetIdentificationService
|
SERVICE_CLASS = CoordinatedSetIdentificationService
|
||||||
|
|
||||||
set_identity_resolving_key: gatt_client.CharacteristicProxy[bytes]
|
set_identity_resolving_key: gatt_client.CharacteristicProxy[bytes]
|
||||||
coordinated_set_size: Optional[gatt_client.CharacteristicProxy[bytes]] = None
|
coordinated_set_size: gatt_client.CharacteristicProxy[bytes] | None = None
|
||||||
set_member_lock: Optional[gatt_client.CharacteristicProxy[bytes]] = None
|
set_member_lock: gatt_client.CharacteristicProxy[bytes] | None = None
|
||||||
set_member_rank: Optional[gatt_client.CharacteristicProxy[bytes]] = None
|
set_member_rank: gatt_client.CharacteristicProxy[bytes] | None = None
|
||||||
|
|
||||||
def __init__(self, service_proxy: gatt_client.ServiceProxy) -> None:
|
def __init__(self, service_proxy: gatt_client.ServiceProxy) -> None:
|
||||||
self.service_proxy = service_proxy
|
self.service_proxy = service_proxy
|
||||||
|
|||||||
@@ -17,7 +17,6 @@
|
|||||||
# Imports
|
# Imports
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
import struct
|
import struct
|
||||||
from typing import Optional
|
|
||||||
|
|
||||||
from bumble.gatt import (
|
from bumble.gatt import (
|
||||||
GATT_DEVICE_INFORMATION_SERVICE,
|
GATT_DEVICE_INFORMATION_SERVICE,
|
||||||
@@ -54,14 +53,14 @@ class DeviceInformationService(TemplateService):
|
|||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
manufacturer_name: Optional[str] = None,
|
manufacturer_name: str | None = None,
|
||||||
model_number: Optional[str] = None,
|
model_number: str | None = None,
|
||||||
serial_number: Optional[str] = None,
|
serial_number: str | None = None,
|
||||||
hardware_revision: Optional[str] = None,
|
hardware_revision: str | None = None,
|
||||||
firmware_revision: Optional[str] = None,
|
firmware_revision: str | None = None,
|
||||||
software_revision: Optional[str] = None,
|
software_revision: str | None = None,
|
||||||
system_id: Optional[tuple[int, int]] = None, # (OUI, Manufacturer ID)
|
system_id: tuple[int, int] | None = None, # (OUI, Manufacturer ID)
|
||||||
ieee_regulatory_certification_data_list: Optional[bytes] = None,
|
ieee_regulatory_certification_data_list: bytes | None = None,
|
||||||
# TODO: pnp_id
|
# TODO: pnp_id
|
||||||
):
|
):
|
||||||
characteristics: list[Characteristic[bytes]] = [
|
characteristics: list[Characteristic[bytes]] = [
|
||||||
@@ -109,14 +108,14 @@ class DeviceInformationService(TemplateService):
|
|||||||
class DeviceInformationServiceProxy(ProfileServiceProxy):
|
class DeviceInformationServiceProxy(ProfileServiceProxy):
|
||||||
SERVICE_CLASS = DeviceInformationService
|
SERVICE_CLASS = DeviceInformationService
|
||||||
|
|
||||||
manufacturer_name: Optional[CharacteristicProxy[str]]
|
manufacturer_name: CharacteristicProxy[str] | None
|
||||||
model_number: Optional[CharacteristicProxy[str]]
|
model_number: CharacteristicProxy[str] | None
|
||||||
serial_number: Optional[CharacteristicProxy[str]]
|
serial_number: CharacteristicProxy[str] | None
|
||||||
hardware_revision: Optional[CharacteristicProxy[str]]
|
hardware_revision: CharacteristicProxy[str] | None
|
||||||
firmware_revision: Optional[CharacteristicProxy[str]]
|
firmware_revision: CharacteristicProxy[str] | None
|
||||||
software_revision: Optional[CharacteristicProxy[str]]
|
software_revision: CharacteristicProxy[str] | None
|
||||||
system_id: Optional[CharacteristicProxy[tuple[int, int]]]
|
system_id: CharacteristicProxy[tuple[int, int]] | None
|
||||||
ieee_regulatory_certification_data_list: Optional[CharacteristicProxy[bytes]]
|
ieee_regulatory_certification_data_list: CharacteristicProxy[bytes] | None
|
||||||
|
|
||||||
def __init__(self, service_proxy: ServiceProxy):
|
def __init__(self, service_proxy: ServiceProxy):
|
||||||
self.service_proxy = service_proxy
|
self.service_proxy = service_proxy
|
||||||
|
|||||||
@@ -19,7 +19,6 @@
|
|||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
import logging
|
import logging
|
||||||
import struct
|
import struct
|
||||||
from typing import Optional, Union
|
|
||||||
|
|
||||||
from bumble.core import Appearance
|
from bumble.core import Appearance
|
||||||
from bumble.gatt import (
|
from bumble.gatt import (
|
||||||
@@ -54,16 +53,17 @@ class GenericAccessService(TemplateService):
|
|||||||
appearance_characteristic: Characteristic[bytes]
|
appearance_characteristic: Characteristic[bytes]
|
||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
self, device_name: str, appearance: Union[Appearance, tuple[int, int], int] = 0
|
self, device_name: str, appearance: Appearance | tuple[int, int] | int = 0
|
||||||
):
|
):
|
||||||
if isinstance(appearance, int):
|
match appearance:
|
||||||
appearance_int = appearance
|
case int():
|
||||||
elif isinstance(appearance, tuple):
|
appearance_int = appearance
|
||||||
appearance_int = (appearance[0] << 6) | appearance[1]
|
case tuple():
|
||||||
elif isinstance(appearance, Appearance):
|
appearance_int = (appearance[0] << 6) | appearance[1]
|
||||||
appearance_int = int(appearance)
|
case Appearance():
|
||||||
else:
|
appearance_int = int(appearance)
|
||||||
raise TypeError()
|
case _:
|
||||||
|
raise TypeError()
|
||||||
|
|
||||||
self.device_name_characteristic = Characteristic(
|
self.device_name_characteristic = Characteristic(
|
||||||
GATT_DEVICE_NAME_CHARACTERISTIC,
|
GATT_DEVICE_NAME_CHARACTERISTIC,
|
||||||
@@ -88,8 +88,8 @@ class GenericAccessService(TemplateService):
|
|||||||
class GenericAccessServiceProxy(ProfileServiceProxy):
|
class GenericAccessServiceProxy(ProfileServiceProxy):
|
||||||
SERVICE_CLASS = GenericAccessService
|
SERVICE_CLASS = GenericAccessService
|
||||||
|
|
||||||
device_name: Optional[CharacteristicProxy[str]]
|
device_name: CharacteristicProxy[str] | None
|
||||||
appearance: Optional[CharacteristicProxy[Appearance]]
|
appearance: CharacteristicProxy[Appearance] | None
|
||||||
|
|
||||||
def __init__(self, service_proxy: ServiceProxy):
|
def __init__(self, service_proxy: ServiceProxy):
|
||||||
self.service_proxy = service_proxy
|
self.service_proxy = service_proxy
|
||||||
|
|||||||
@@ -40,7 +40,6 @@ class GenericAttributeProfileService(gatt.TemplateService):
|
|||||||
database_hash_enabled: bool = True,
|
database_hash_enabled: bool = True,
|
||||||
service_change_enabled: bool = True,
|
service_change_enabled: bool = True,
|
||||||
) -> None:
|
) -> None:
|
||||||
|
|
||||||
if server_supported_features is not None:
|
if server_supported_features is not None:
|
||||||
self.server_supported_features_characteristic = gatt.Characteristic(
|
self.server_supported_features_characteristic = gatt.Characteristic(
|
||||||
uuid=gatt.GATT_SERVER_SUPPORTED_FEATURES_CHARACTERISTIC,
|
uuid=gatt.GATT_SERVER_SUPPORTED_FEATURES_CHARACTERISTIC,
|
||||||
|
|||||||
@@ -19,7 +19,6 @@
|
|||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
import struct
|
import struct
|
||||||
from enum import IntFlag
|
from enum import IntFlag
|
||||||
from typing import Optional
|
|
||||||
|
|
||||||
from bumble.gatt import (
|
from bumble.gatt import (
|
||||||
GATT_BGR_FEATURES_CHARACTERISTIC,
|
GATT_BGR_FEATURES_CHARACTERISTIC,
|
||||||
@@ -77,18 +76,18 @@ class GamingAudioService(TemplateService):
|
|||||||
UUID = GATT_GAMING_AUDIO_SERVICE
|
UUID = GATT_GAMING_AUDIO_SERVICE
|
||||||
|
|
||||||
gmap_role: Characteristic
|
gmap_role: Characteristic
|
||||||
ugg_features: Optional[Characteristic] = None
|
ugg_features: Characteristic | None = None
|
||||||
ugt_features: Optional[Characteristic] = None
|
ugt_features: Characteristic | None = None
|
||||||
bgs_features: Optional[Characteristic] = None
|
bgs_features: Characteristic | None = None
|
||||||
bgr_features: Optional[Characteristic] = None
|
bgr_features: Characteristic | None = None
|
||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
gmap_role: GmapRole,
|
gmap_role: GmapRole,
|
||||||
ugg_features: Optional[UggFeatures] = None,
|
ugg_features: UggFeatures | None = None,
|
||||||
ugt_features: Optional[UgtFeatures] = None,
|
ugt_features: UgtFeatures | None = None,
|
||||||
bgs_features: Optional[BgsFeatures] = None,
|
bgs_features: BgsFeatures | None = None,
|
||||||
bgr_features: Optional[BgrFeatures] = None,
|
bgr_features: BgrFeatures | None = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
characteristics = []
|
characteristics = []
|
||||||
|
|
||||||
@@ -150,10 +149,10 @@ class GamingAudioService(TemplateService):
|
|||||||
class GamingAudioServiceProxy(ProfileServiceProxy):
|
class GamingAudioServiceProxy(ProfileServiceProxy):
|
||||||
SERVICE_CLASS = GamingAudioService
|
SERVICE_CLASS = GamingAudioService
|
||||||
|
|
||||||
ugg_features: Optional[CharacteristicProxy[UggFeatures]] = None
|
ugg_features: CharacteristicProxy[UggFeatures] | None = None
|
||||||
ugt_features: Optional[CharacteristicProxy[UgtFeatures]] = None
|
ugt_features: CharacteristicProxy[UgtFeatures] | None = None
|
||||||
bgs_features: Optional[CharacteristicProxy[BgsFeatures]] = None
|
bgs_features: CharacteristicProxy[BgsFeatures] | None = None
|
||||||
bgr_features: Optional[CharacteristicProxy[BgrFeatures]] = None
|
bgr_features: CharacteristicProxy[BgrFeatures] | None = None
|
||||||
|
|
||||||
def __init__(self, service_proxy: ServiceProxy) -> None:
|
def __init__(self, service_proxy: ServiceProxy) -> None:
|
||||||
self.service_proxy = service_proxy
|
self.service_proxy = service_proxy
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ from __future__ import annotations
|
|||||||
import asyncio
|
import asyncio
|
||||||
import logging
|
import logging
|
||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
from typing import Any, Optional, Union
|
from typing import Any
|
||||||
|
|
||||||
from bumble import att, gatt, gatt_adapters, gatt_client, utils
|
from bumble import att, gatt, gatt_adapters, gatt_client, utils
|
||||||
from bumble.core import InvalidArgumentError, InvalidStateError
|
from bumble.core import InvalidArgumentError, InvalidStateError
|
||||||
@@ -145,7 +145,7 @@ class PresetChangedOperation:
|
|||||||
return bytes([self.prev_index]) + bytes(self.preset_record)
|
return bytes([self.prev_index]) + bytes(self.preset_record)
|
||||||
|
|
||||||
change_id: ChangeId
|
change_id: ChangeId
|
||||||
additional_parameters: Union[Generic, int]
|
additional_parameters: Generic | int
|
||||||
|
|
||||||
def to_bytes(self, is_last: bool) -> bytes:
|
def to_bytes(self, is_last: bool) -> bytes:
|
||||||
if isinstance(self.additional_parameters, PresetChangedOperation.Generic):
|
if isinstance(self.additional_parameters, PresetChangedOperation.Generic):
|
||||||
@@ -235,7 +235,7 @@ class HearingAccessService(gatt.TemplateService):
|
|||||||
preset_records: dict[int, PresetRecord] # key is the preset index
|
preset_records: dict[int, PresetRecord] # key is the preset index
|
||||||
read_presets_request_in_progress: bool
|
read_presets_request_in_progress: bool
|
||||||
|
|
||||||
other_server_in_binaural_set: Optional[HearingAccessService] = None
|
other_server_in_binaural_set: HearingAccessService | None = None
|
||||||
|
|
||||||
preset_changed_operations_history_per_device: dict[
|
preset_changed_operations_history_per_device: dict[
|
||||||
Address, list[PresetChangedOperation]
|
Address, list[PresetChangedOperation]
|
||||||
@@ -273,12 +273,19 @@ class HearingAccessService(gatt.TemplateService):
|
|||||||
def on_disconnection(_reason) -> None:
|
def on_disconnection(_reason) -> None:
|
||||||
self.currently_connected_clients.discard(connection)
|
self.currently_connected_clients.discard(connection)
|
||||||
|
|
||||||
|
@connection.on(connection.EVENT_CONNECTION_ATT_MTU_UPDATE)
|
||||||
|
def on_mtu_update(*_: Any) -> None:
|
||||||
|
self.on_incoming_connection(connection)
|
||||||
|
|
||||||
|
@connection.on(connection.EVENT_CONNECTION_ENCRYPTION_CHANGE)
|
||||||
|
def on_encryption_change(*_: Any) -> None:
|
||||||
|
self.on_incoming_connection(connection)
|
||||||
|
|
||||||
@connection.on(connection.EVENT_PAIRING)
|
@connection.on(connection.EVENT_PAIRING)
|
||||||
def on_pairing(*_: Any) -> None:
|
def on_pairing(*_: Any) -> None:
|
||||||
self.on_incoming_paired_connection(connection)
|
self.on_incoming_connection(connection)
|
||||||
|
|
||||||
if connection.peer_resolvable_address:
|
self.on_incoming_connection(connection)
|
||||||
self.on_incoming_paired_connection(connection)
|
|
||||||
|
|
||||||
self.hearing_aid_features_characteristic = gatt.Characteristic(
|
self.hearing_aid_features_characteristic = gatt.Characteristic(
|
||||||
uuid=gatt.GATT_HEARING_AID_FEATURES_CHARACTERISTIC,
|
uuid=gatt.GATT_HEARING_AID_FEATURES_CHARACTERISTIC,
|
||||||
@@ -315,9 +322,30 @@ class HearingAccessService(gatt.TemplateService):
|
|||||||
]
|
]
|
||||||
)
|
)
|
||||||
|
|
||||||
def on_incoming_paired_connection(self, connection: Connection):
|
def on_incoming_connection(self, connection: Connection):
|
||||||
'''Setup initial operations to handle a remote bonded HAP device'''
|
'''Setup initial operations to handle a remote bonded HAP device'''
|
||||||
# TODO Should we filter on HAP device only ?
|
# TODO Should we filter on HAP device only ?
|
||||||
|
|
||||||
|
if not connection.is_encrypted:
|
||||||
|
logging.debug(f'HAS: {connection.peer_address} is not encrypted')
|
||||||
|
return
|
||||||
|
|
||||||
|
if not connection.peer_resolvable_address:
|
||||||
|
logging.debug(f'HAS: {connection.peer_address} is not paired')
|
||||||
|
return
|
||||||
|
|
||||||
|
if connection.att_mtu < 49:
|
||||||
|
logging.debug(
|
||||||
|
f'HAS: {connection.peer_address} invalid MTU={connection.att_mtu}'
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
if connection.peer_address in self.currently_connected_clients:
|
||||||
|
logging.debug(
|
||||||
|
f'HAS: Already connected to {connection.peer_address} nothing to do'
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
self.currently_connected_clients.add(connection)
|
self.currently_connected_clients.add(connection)
|
||||||
if (
|
if (
|
||||||
connection.peer_address
|
connection.peer_address
|
||||||
@@ -457,6 +485,7 @@ class HearingAccessService(gatt.TemplateService):
|
|||||||
connection,
|
connection,
|
||||||
self.hearing_aid_preset_control_point,
|
self.hearing_aid_preset_control_point,
|
||||||
value=op_list[0].to_bytes(len(op_list) == 1),
|
value=op_list[0].to_bytes(len(op_list) == 1),
|
||||||
|
force=True, # TODO GATT notification subscription should be persistent
|
||||||
)
|
)
|
||||||
# Remove item once sent, and keep the non sent item in the list
|
# Remove item once sent, and keep the non sent item in the list
|
||||||
op_list.pop(0)
|
op_list.pop(0)
|
||||||
|
|||||||
@@ -18,41 +18,30 @@
|
|||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import dataclasses
|
||||||
|
import enum
|
||||||
import struct
|
import struct
|
||||||
from enum import IntEnum
|
from collections.abc import Callable, Sequence
|
||||||
from typing import Optional
|
from typing import Any
|
||||||
|
|
||||||
from bumble import core
|
from typing_extensions import Self
|
||||||
from bumble.att import ATT_Error
|
|
||||||
from bumble.gatt import (
|
from bumble import att, core, device, gatt, gatt_adapters, gatt_client, utils
|
||||||
GATT_BODY_SENSOR_LOCATION_CHARACTERISTIC,
|
|
||||||
GATT_HEART_RATE_CONTROL_POINT_CHARACTERISTIC,
|
|
||||||
GATT_HEART_RATE_MEASUREMENT_CHARACTERISTIC,
|
|
||||||
GATT_HEART_RATE_SERVICE,
|
|
||||||
Characteristic,
|
|
||||||
CharacteristicValue,
|
|
||||||
TemplateService,
|
|
||||||
)
|
|
||||||
from bumble.gatt_adapters import (
|
|
||||||
DelegatedCharacteristicAdapter,
|
|
||||||
PackedCharacteristicAdapter,
|
|
||||||
SerializableCharacteristicAdapter,
|
|
||||||
)
|
|
||||||
from bumble.gatt_client import CharacteristicProxy, ProfileServiceProxy
|
|
||||||
|
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
class HeartRateService(TemplateService):
|
class HeartRateService(gatt.TemplateService):
|
||||||
UUID = GATT_HEART_RATE_SERVICE
|
UUID = gatt.GATT_HEART_RATE_SERVICE
|
||||||
|
|
||||||
HEART_RATE_CONTROL_POINT_FORMAT = 'B'
|
HEART_RATE_CONTROL_POINT_FORMAT = 'B'
|
||||||
CONTROL_POINT_NOT_SUPPORTED = 0x80
|
CONTROL_POINT_NOT_SUPPORTED = 0x80
|
||||||
RESET_ENERGY_EXPENDED = 0x01
|
RESET_ENERGY_EXPENDED = 0x01
|
||||||
|
|
||||||
heart_rate_measurement_characteristic: Characteristic[HeartRateMeasurement]
|
heart_rate_measurement_characteristic: gatt.Characteristic[HeartRateMeasurement]
|
||||||
body_sensor_location_characteristic: Characteristic[BodySensorLocation]
|
body_sensor_location_characteristic: gatt.Characteristic[BodySensorLocation]
|
||||||
heart_rate_control_point_characteristic: Characteristic[int]
|
heart_rate_control_point_characteristic: gatt.Characteristic[int]
|
||||||
|
|
||||||
class BodySensorLocation(IntEnum):
|
class BodySensorLocation(utils.OpenIntEnum):
|
||||||
OTHER = 0
|
OTHER = 0
|
||||||
CHEST = 1
|
CHEST = 1
|
||||||
WRIST = 2
|
WRIST = 2
|
||||||
@@ -61,82 +50,90 @@ class HeartRateService(TemplateService):
|
|||||||
EAR_LOBE = 5
|
EAR_LOBE = 5
|
||||||
FOOT = 6
|
FOOT = 6
|
||||||
|
|
||||||
|
@dataclasses.dataclass
|
||||||
class HeartRateMeasurement:
|
class HeartRateMeasurement:
|
||||||
def __init__(
|
heart_rate: int
|
||||||
self,
|
sensor_contact_detected: bool | None = None
|
||||||
heart_rate,
|
energy_expended: int | None = None
|
||||||
sensor_contact_detected=None,
|
rr_intervals: Sequence[float] | None = None
|
||||||
energy_expended=None,
|
|
||||||
rr_intervals=None,
|
class Flag(enum.IntFlag):
|
||||||
):
|
INT16_HEART_RATE = 1 << 0
|
||||||
if heart_rate < 0 or heart_rate > 0xFFFF:
|
SENSOR_CONTACT_DETECTED = 1 << 1
|
||||||
|
SENSOR_CONTACT_SUPPORTED = 1 << 2
|
||||||
|
ENERGY_EXPENDED_STATUS = 1 << 3
|
||||||
|
RR_INTERVAL = 1 << 4
|
||||||
|
|
||||||
|
def __post_init__(self) -> None:
|
||||||
|
if self.heart_rate < 0 or self.heart_rate > 0xFFFF:
|
||||||
raise core.InvalidArgumentError('heart_rate out of range')
|
raise core.InvalidArgumentError('heart_rate out of range')
|
||||||
|
|
||||||
if energy_expended is not None and (
|
if self.energy_expended is not None and (
|
||||||
energy_expended < 0 or energy_expended > 0xFFFF
|
self.energy_expended < 0 or self.energy_expended > 0xFFFF
|
||||||
):
|
):
|
||||||
raise core.InvalidArgumentError('energy_expended out of range')
|
raise core.InvalidArgumentError('energy_expended out of range')
|
||||||
|
|
||||||
if rr_intervals:
|
if self.rr_intervals:
|
||||||
for rr_interval in rr_intervals:
|
for rr_interval in self.rr_intervals:
|
||||||
if rr_interval < 0 or rr_interval * 1024 > 0xFFFF:
|
if rr_interval < 0 or rr_interval * 1024 > 0xFFFF:
|
||||||
raise core.InvalidArgumentError('rr_intervals out of range')
|
raise core.InvalidArgumentError('rr_intervals out of range')
|
||||||
|
|
||||||
self.heart_rate = heart_rate
|
|
||||||
self.sensor_contact_detected = sensor_contact_detected
|
|
||||||
self.energy_expended = energy_expended
|
|
||||||
self.rr_intervals = rr_intervals
|
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def from_bytes(cls, data):
|
def from_bytes(cls, data: bytes) -> Self:
|
||||||
flags = data[0]
|
flags = data[0]
|
||||||
offset = 1
|
offset = 1
|
||||||
|
|
||||||
if flags & 1:
|
if flags & cls.Flag.INT16_HEART_RATE:
|
||||||
hr = struct.unpack_from('<H', data, offset)[0]
|
heart_rate = struct.unpack_from('<H', data, offset)[0]
|
||||||
offset += 2
|
offset += 2
|
||||||
else:
|
else:
|
||||||
hr = struct.unpack_from('B', data, offset)[0]
|
heart_rate = struct.unpack_from('B', data, offset)[0]
|
||||||
offset += 1
|
offset += 1
|
||||||
|
|
||||||
if flags & (1 << 2):
|
if flags & cls.Flag.SENSOR_CONTACT_SUPPORTED:
|
||||||
sensor_contact_detected = flags & (1 << 1) != 0
|
sensor_contact_detected = flags & cls.Flag.SENSOR_CONTACT_DETECTED != 0
|
||||||
else:
|
else:
|
||||||
sensor_contact_detected = None
|
sensor_contact_detected = None
|
||||||
|
|
||||||
if flags & (1 << 3):
|
if flags & cls.Flag.ENERGY_EXPENDED_STATUS:
|
||||||
energy_expended = struct.unpack_from('<H', data, offset)[0]
|
energy_expended = struct.unpack_from('<H', data, offset)[0]
|
||||||
offset += 2
|
offset += 2
|
||||||
else:
|
else:
|
||||||
energy_expended = None
|
energy_expended = None
|
||||||
|
|
||||||
if flags & (1 << 4):
|
rr_intervals: Sequence[float] | None = None
|
||||||
|
if flags & cls.Flag.RR_INTERVAL:
|
||||||
rr_intervals = tuple(
|
rr_intervals = tuple(
|
||||||
struct.unpack_from('<H', data, offset + i * 2)[0] / 1024
|
struct.unpack_from('<H', data, i)[0] / 1024
|
||||||
for i in range((len(data) - offset) // 2)
|
for i in range(offset, len(data), 2)
|
||||||
)
|
)
|
||||||
else:
|
|
||||||
rr_intervals = ()
|
|
||||||
|
|
||||||
return cls(hr, sensor_contact_detected, energy_expended, rr_intervals)
|
return cls(
|
||||||
|
heart_rate=heart_rate,
|
||||||
|
sensor_contact_detected=sensor_contact_detected,
|
||||||
|
energy_expended=energy_expended,
|
||||||
|
rr_intervals=rr_intervals,
|
||||||
|
)
|
||||||
|
|
||||||
def __bytes__(self):
|
def __bytes__(self) -> bytes:
|
||||||
|
flags = 0
|
||||||
if self.heart_rate < 256:
|
if self.heart_rate < 256:
|
||||||
flags = 0
|
|
||||||
data = struct.pack('B', self.heart_rate)
|
data = struct.pack('B', self.heart_rate)
|
||||||
else:
|
else:
|
||||||
flags = 1
|
flags |= self.Flag.INT16_HEART_RATE
|
||||||
data = struct.pack('<H', self.heart_rate)
|
data = struct.pack('<H', self.heart_rate)
|
||||||
|
|
||||||
if self.sensor_contact_detected is not None:
|
if self.sensor_contact_detected is not None:
|
||||||
flags |= ((1 if self.sensor_contact_detected else 0) << 1) | (1 << 2)
|
flags |= self.Flag.SENSOR_CONTACT_SUPPORTED
|
||||||
|
if self.sensor_contact_detected:
|
||||||
|
flags |= self.Flag.SENSOR_CONTACT_DETECTED
|
||||||
|
|
||||||
if self.energy_expended is not None:
|
if self.energy_expended is not None:
|
||||||
flags |= 1 << 3
|
flags |= self.Flag.ENERGY_EXPENDED_STATUS
|
||||||
data += struct.pack('<H', self.energy_expended)
|
data += struct.pack('<H', self.energy_expended)
|
||||||
|
|
||||||
if self.rr_intervals:
|
if self.rr_intervals is not None:
|
||||||
flags |= 1 << 4
|
flags |= self.Flag.RR_INTERVAL
|
||||||
data += b''.join(
|
data += b''.join(
|
||||||
[
|
[
|
||||||
struct.pack('<H', int(rr_interval * 1024))
|
struct.pack('<H', int(rr_interval * 1024))
|
||||||
@@ -146,57 +143,67 @@ class HeartRateService(TemplateService):
|
|||||||
|
|
||||||
return bytes([flags]) + data
|
return bytes([flags]) + data
|
||||||
|
|
||||||
def __str__(self):
|
|
||||||
return (
|
|
||||||
f'HeartRateMeasurement(heart_rate={self.heart_rate},'
|
|
||||||
f' sensor_contact_detected={self.sensor_contact_detected},'
|
|
||||||
f' energy_expended={self.energy_expended},'
|
|
||||||
f' rr_intervals={self.rr_intervals})'
|
|
||||||
)
|
|
||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
read_heart_rate_measurement,
|
read_heart_rate_measurement: Callable[
|
||||||
body_sensor_location=None,
|
[device.Connection], HeartRateMeasurement
|
||||||
reset_energy_expended=None,
|
],
|
||||||
|
body_sensor_location: HeartRateService.BodySensorLocation | None = None,
|
||||||
|
reset_energy_expended: Callable[[device.Connection], Any] | None = None,
|
||||||
):
|
):
|
||||||
self.heart_rate_measurement_characteristic = SerializableCharacteristicAdapter(
|
self.heart_rate_measurement_characteristic = (
|
||||||
Characteristic(
|
gatt_adapters.SerializableCharacteristicAdapter(
|
||||||
GATT_HEART_RATE_MEASUREMENT_CHARACTERISTIC,
|
gatt.Characteristic(
|
||||||
Characteristic.Properties.NOTIFY,
|
uuid=gatt.GATT_HEART_RATE_MEASUREMENT_CHARACTERISTIC,
|
||||||
0,
|
properties=gatt.Characteristic.Properties.NOTIFY,
|
||||||
CharacteristicValue(read=read_heart_rate_measurement),
|
permissions=gatt.Characteristic.Permissions(0),
|
||||||
),
|
value=gatt.CharacteristicValue(read=read_heart_rate_measurement),
|
||||||
HeartRateService.HeartRateMeasurement,
|
),
|
||||||
|
HeartRateService.HeartRateMeasurement,
|
||||||
|
)
|
||||||
)
|
)
|
||||||
characteristics = [self.heart_rate_measurement_characteristic]
|
characteristics: list[gatt.Characteristic] = [
|
||||||
|
self.heart_rate_measurement_characteristic
|
||||||
|
]
|
||||||
|
|
||||||
if body_sensor_location is not None:
|
if body_sensor_location is not None:
|
||||||
self.body_sensor_location_characteristic = Characteristic(
|
self.body_sensor_location_characteristic = (
|
||||||
GATT_BODY_SENSOR_LOCATION_CHARACTERISTIC,
|
gatt_adapters.EnumCharacteristicAdapter(
|
||||||
Characteristic.Properties.READ,
|
gatt.Characteristic(
|
||||||
Characteristic.READABLE,
|
uuid=gatt.GATT_BODY_SENSOR_LOCATION_CHARACTERISTIC,
|
||||||
bytes([int(body_sensor_location)]),
|
properties=gatt.Characteristic.Properties.READ,
|
||||||
|
permissions=gatt.Characteristic.READABLE,
|
||||||
|
value=body_sensor_location,
|
||||||
|
),
|
||||||
|
cls=self.BodySensorLocation,
|
||||||
|
length=1,
|
||||||
|
)
|
||||||
)
|
)
|
||||||
characteristics.append(self.body_sensor_location_characteristic)
|
characteristics.append(self.body_sensor_location_characteristic)
|
||||||
|
|
||||||
if reset_energy_expended:
|
if reset_energy_expended:
|
||||||
|
|
||||||
def write_heart_rate_control_point_value(connection, value):
|
def write_heart_rate_control_point_value(
|
||||||
|
connection: device.Connection, value: bytes
|
||||||
|
) -> None:
|
||||||
if value == self.RESET_ENERGY_EXPENDED:
|
if value == self.RESET_ENERGY_EXPENDED:
|
||||||
if reset_energy_expended is not None:
|
if reset_energy_expended is not None:
|
||||||
reset_energy_expended(connection)
|
reset_energy_expended(connection)
|
||||||
else:
|
else:
|
||||||
raise ATT_Error(self.CONTROL_POINT_NOT_SUPPORTED)
|
raise att.ATT_Error(self.CONTROL_POINT_NOT_SUPPORTED)
|
||||||
|
|
||||||
self.heart_rate_control_point_characteristic = PackedCharacteristicAdapter(
|
self.heart_rate_control_point_characteristic = (
|
||||||
Characteristic(
|
gatt_adapters.PackedCharacteristicAdapter(
|
||||||
GATT_HEART_RATE_CONTROL_POINT_CHARACTERISTIC,
|
gatt.Characteristic(
|
||||||
Characteristic.Properties.WRITE,
|
uuid=gatt.GATT_HEART_RATE_CONTROL_POINT_CHARACTERISTIC,
|
||||||
Characteristic.WRITEABLE,
|
properties=gatt.Characteristic.Properties.WRITE,
|
||||||
CharacteristicValue(write=write_heart_rate_control_point_value),
|
permissions=gatt.Characteristic.WRITEABLE,
|
||||||
),
|
value=gatt.CharacteristicValue(
|
||||||
pack_format=HeartRateService.HEART_RATE_CONTROL_POINT_FORMAT,
|
write=write_heart_rate_control_point_value
|
||||||
|
),
|
||||||
|
),
|
||||||
|
pack_format=HeartRateService.HEART_RATE_CONTROL_POINT_FORMAT,
|
||||||
|
)
|
||||||
)
|
)
|
||||||
characteristics.append(self.heart_rate_control_point_characteristic)
|
characteristics.append(self.heart_rate_control_point_characteristic)
|
||||||
|
|
||||||
@@ -204,50 +211,51 @@ class HeartRateService(TemplateService):
|
|||||||
|
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
class HeartRateServiceProxy(ProfileServiceProxy):
|
class HeartRateServiceProxy(gatt_client.ProfileServiceProxy):
|
||||||
SERVICE_CLASS = HeartRateService
|
SERVICE_CLASS = HeartRateService
|
||||||
|
|
||||||
heart_rate_measurement: Optional[
|
heart_rate_measurement: gatt_client.CharacteristicProxy[
|
||||||
CharacteristicProxy[HeartRateService.HeartRateMeasurement]
|
HeartRateService.HeartRateMeasurement
|
||||||
]
|
]
|
||||||
body_sensor_location: Optional[
|
body_sensor_location: (
|
||||||
CharacteristicProxy[HeartRateService.BodySensorLocation]
|
gatt_client.CharacteristicProxy[HeartRateService.BodySensorLocation] | None
|
||||||
]
|
)
|
||||||
heart_rate_control_point: Optional[CharacteristicProxy[int]]
|
heart_rate_control_point: gatt_client.CharacteristicProxy[int] | None
|
||||||
|
|
||||||
def __init__(self, service_proxy):
|
def __init__(self, service_proxy: gatt_client.ServiceProxy) -> None:
|
||||||
self.service_proxy = service_proxy
|
self.service_proxy = service_proxy
|
||||||
|
|
||||||
if characteristics := service_proxy.get_characteristics_by_uuid(
|
self.heart_rate_measurement = (
|
||||||
GATT_HEART_RATE_MEASUREMENT_CHARACTERISTIC
|
gatt_adapters.SerializableCharacteristicProxyAdapter(
|
||||||
):
|
service_proxy.get_required_characteristic_by_uuid(
|
||||||
self.heart_rate_measurement = SerializableCharacteristicAdapter(
|
gatt.GATT_HEART_RATE_MEASUREMENT_CHARACTERISTIC
|
||||||
characteristics[0], HeartRateService.HeartRateMeasurement
|
),
|
||||||
|
HeartRateService.HeartRateMeasurement,
|
||||||
)
|
)
|
||||||
else:
|
)
|
||||||
self.heart_rate_measurement = None
|
|
||||||
|
|
||||||
if characteristics := service_proxy.get_characteristics_by_uuid(
|
if characteristics := service_proxy.get_characteristics_by_uuid(
|
||||||
GATT_BODY_SENSOR_LOCATION_CHARACTERISTIC
|
gatt.GATT_BODY_SENSOR_LOCATION_CHARACTERISTIC
|
||||||
):
|
):
|
||||||
self.body_sensor_location = DelegatedCharacteristicAdapter(
|
self.body_sensor_location = gatt_adapters.EnumCharacteristicProxyAdapter(
|
||||||
characteristics[0],
|
characteristics[0], cls=HeartRateService.BodySensorLocation, length=1
|
||||||
decode=lambda value: HeartRateService.BodySensorLocation(value[0]),
|
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
self.body_sensor_location = None
|
self.body_sensor_location = None
|
||||||
|
|
||||||
if characteristics := service_proxy.get_characteristics_by_uuid(
|
if characteristics := service_proxy.get_characteristics_by_uuid(
|
||||||
GATT_HEART_RATE_CONTROL_POINT_CHARACTERISTIC
|
gatt.GATT_HEART_RATE_CONTROL_POINT_CHARACTERISTIC
|
||||||
):
|
):
|
||||||
self.heart_rate_control_point = PackedCharacteristicAdapter(
|
self.heart_rate_control_point = (
|
||||||
characteristics[0],
|
gatt_adapters.PackedCharacteristicProxyAdapter(
|
||||||
pack_format=HeartRateService.HEART_RATE_CONTROL_POINT_FORMAT,
|
characteristics[0],
|
||||||
|
pack_format=HeartRateService.HEART_RATE_CONTROL_POINT_FORMAT,
|
||||||
|
)
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
self.heart_rate_control_point = None
|
self.heart_rate_control_point = None
|
||||||
|
|
||||||
async def reset_energy_expended(self):
|
async def reset_energy_expended(self) -> None:
|
||||||
if self.heart_rate_control_point is not None:
|
if self.heart_rate_control_point is not None:
|
||||||
return await self.heart_rate_control_point.write_value(
|
return await self.heart_rate_control_point.write_value(
|
||||||
HeartRateService.RESET_ENERGY_EXPENDED
|
HeartRateService.RESET_ENERGY_EXPENDED
|
||||||
|
|||||||
@@ -137,7 +137,7 @@ class Metadata:
|
|||||||
values.append(str(decoded))
|
values.append(str(decoded))
|
||||||
|
|
||||||
return '\n'.join(
|
return '\n'.join(
|
||||||
f'{indent}{key}: {" " * (max_key_length-len(key))}{value}'
|
f'{indent}{key}: {" " * (max_key_length - len(key))}{value}'
|
||||||
for key, value in zip(keys, values)
|
for key, value in zip(keys, values)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ import asyncio
|
|||||||
import dataclasses
|
import dataclasses
|
||||||
import enum
|
import enum
|
||||||
import struct
|
import struct
|
||||||
from typing import TYPE_CHECKING, ClassVar, Optional
|
from typing import TYPE_CHECKING, ClassVar
|
||||||
|
|
||||||
from typing_extensions import Self
|
from typing_extensions import Self
|
||||||
|
|
||||||
@@ -196,7 +196,7 @@ class MediaControlService(gatt.TemplateService):
|
|||||||
|
|
||||||
UUID = gatt.GATT_MEDIA_CONTROL_SERVICE
|
UUID = gatt.GATT_MEDIA_CONTROL_SERVICE
|
||||||
|
|
||||||
def __init__(self, media_player_name: Optional[str] = None) -> None:
|
def __init__(self, media_player_name: str | None = None) -> None:
|
||||||
self.track_position = 0
|
self.track_position = 0
|
||||||
|
|
||||||
self.media_player_name_characteristic = gatt.Characteristic(
|
self.media_player_name_characteristic = gatt.Characteristic(
|
||||||
@@ -337,32 +337,32 @@ class MediaControlServiceProxy(
|
|||||||
EVENT_TRACK_DURATION = "track_duration"
|
EVENT_TRACK_DURATION = "track_duration"
|
||||||
EVENT_TRACK_POSITION = "track_position"
|
EVENT_TRACK_POSITION = "track_position"
|
||||||
|
|
||||||
media_player_name: Optional[gatt_client.CharacteristicProxy[bytes]] = None
|
media_player_name: gatt_client.CharacteristicProxy[bytes] | None = None
|
||||||
media_player_icon_object_id: Optional[gatt_client.CharacteristicProxy[bytes]] = None
|
media_player_icon_object_id: gatt_client.CharacteristicProxy[bytes] | None = None
|
||||||
media_player_icon_url: Optional[gatt_client.CharacteristicProxy[bytes]] = None
|
media_player_icon_url: gatt_client.CharacteristicProxy[bytes] | None = None
|
||||||
track_changed: Optional[gatt_client.CharacteristicProxy[bytes]] = None
|
track_changed: gatt_client.CharacteristicProxy[bytes] | None = None
|
||||||
track_title: Optional[gatt_client.CharacteristicProxy[bytes]] = None
|
track_title: gatt_client.CharacteristicProxy[bytes] | None = None
|
||||||
track_duration: Optional[gatt_client.CharacteristicProxy[bytes]] = None
|
track_duration: gatt_client.CharacteristicProxy[bytes] | None = None
|
||||||
track_position: Optional[gatt_client.CharacteristicProxy[bytes]] = None
|
track_position: gatt_client.CharacteristicProxy[bytes] | None = None
|
||||||
playback_speed: Optional[gatt_client.CharacteristicProxy[bytes]] = None
|
playback_speed: gatt_client.CharacteristicProxy[bytes] | None = None
|
||||||
seeking_speed: Optional[gatt_client.CharacteristicProxy[bytes]] = None
|
seeking_speed: gatt_client.CharacteristicProxy[bytes] | None = None
|
||||||
current_track_segments_object_id: Optional[
|
current_track_segments_object_id: gatt_client.CharacteristicProxy[bytes] | None = (
|
||||||
gatt_client.CharacteristicProxy[bytes]
|
None
|
||||||
] = None
|
)
|
||||||
current_track_object_id: Optional[gatt_client.CharacteristicProxy[bytes]] = None
|
current_track_object_id: gatt_client.CharacteristicProxy[bytes] | None = None
|
||||||
next_track_object_id: Optional[gatt_client.CharacteristicProxy[bytes]] = None
|
next_track_object_id: gatt_client.CharacteristicProxy[bytes] | None = None
|
||||||
parent_group_object_id: Optional[gatt_client.CharacteristicProxy[bytes]] = None
|
parent_group_object_id: gatt_client.CharacteristicProxy[bytes] | None = None
|
||||||
current_group_object_id: Optional[gatt_client.CharacteristicProxy[bytes]] = None
|
current_group_object_id: gatt_client.CharacteristicProxy[bytes] | None = None
|
||||||
playing_order: Optional[gatt_client.CharacteristicProxy[bytes]] = None
|
playing_order: gatt_client.CharacteristicProxy[bytes] | None = None
|
||||||
playing_orders_supported: Optional[gatt_client.CharacteristicProxy[bytes]] = None
|
playing_orders_supported: gatt_client.CharacteristicProxy[bytes] | None = None
|
||||||
media_state: Optional[gatt_client.CharacteristicProxy[bytes]] = None
|
media_state: gatt_client.CharacteristicProxy[bytes] | None = None
|
||||||
media_control_point: Optional[gatt_client.CharacteristicProxy[bytes]] = None
|
media_control_point: gatt_client.CharacteristicProxy[bytes] | None = None
|
||||||
media_control_point_opcodes_supported: Optional[
|
media_control_point_opcodes_supported: (
|
||||||
gatt_client.CharacteristicProxy[bytes]
|
gatt_client.CharacteristicProxy[bytes] | None
|
||||||
] = None
|
) = None
|
||||||
search_control_point: Optional[gatt_client.CharacteristicProxy[bytes]] = None
|
search_control_point: gatt_client.CharacteristicProxy[bytes] | None = None
|
||||||
search_results_object_id: Optional[gatt_client.CharacteristicProxy[bytes]] = None
|
search_results_object_id: gatt_client.CharacteristicProxy[bytes] | None = None
|
||||||
content_control_id: Optional[gatt_client.CharacteristicProxy[bytes]] = None
|
content_control_id: gatt_client.CharacteristicProxy[bytes] | None = None
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
media_control_point_notifications: asyncio.Queue[bytes]
|
media_control_point_notifications: asyncio.Queue[bytes]
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ from __future__ import annotations
|
|||||||
import dataclasses
|
import dataclasses
|
||||||
import logging
|
import logging
|
||||||
import struct
|
import struct
|
||||||
from typing import Optional, Sequence, Union
|
from collections.abc import Sequence
|
||||||
|
|
||||||
from bumble import gatt, gatt_adapters, gatt_client, hci
|
from bumble import gatt, gatt_adapters, gatt_client, hci
|
||||||
from bumble.profiles import le_audio
|
from bumble.profiles import le_audio
|
||||||
@@ -39,7 +39,7 @@ class PacRecord:
|
|||||||
'''Published Audio Capabilities Service, Table 3.2/3.4.'''
|
'''Published Audio Capabilities Service, Table 3.2/3.4.'''
|
||||||
|
|
||||||
coding_format: hci.CodingFormat
|
coding_format: hci.CodingFormat
|
||||||
codec_specific_capabilities: Union[CodecSpecificCapabilities, bytes]
|
codec_specific_capabilities: CodecSpecificCapabilities | bytes
|
||||||
metadata: le_audio.Metadata = dataclasses.field(default_factory=le_audio.Metadata)
|
metadata: le_audio.Metadata = dataclasses.field(default_factory=le_audio.Metadata)
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
@@ -56,7 +56,7 @@ class PacRecord:
|
|||||||
offset += 1
|
offset += 1
|
||||||
metadata = le_audio.Metadata.from_bytes(data[offset : offset + metadata_size])
|
metadata = le_audio.Metadata.from_bytes(data[offset : offset + metadata_size])
|
||||||
|
|
||||||
codec_specific_capabilities: Union[CodecSpecificCapabilities, bytes]
|
codec_specific_capabilities: CodecSpecificCapabilities | bytes
|
||||||
if coding_format.codec_id == hci.CodecID.VENDOR_SPECIFIC:
|
if coding_format.codec_id == hci.CodecID.VENDOR_SPECIFIC:
|
||||||
codec_specific_capabilities = codec_specific_capabilities_bytes
|
codec_specific_capabilities = codec_specific_capabilities_bytes
|
||||||
else:
|
else:
|
||||||
@@ -101,10 +101,10 @@ class PacRecord:
|
|||||||
class PublishedAudioCapabilitiesService(gatt.TemplateService):
|
class PublishedAudioCapabilitiesService(gatt.TemplateService):
|
||||||
UUID = gatt.GATT_PUBLISHED_AUDIO_CAPABILITIES_SERVICE
|
UUID = gatt.GATT_PUBLISHED_AUDIO_CAPABILITIES_SERVICE
|
||||||
|
|
||||||
sink_pac: Optional[gatt.Characteristic[bytes]]
|
sink_pac: gatt.Characteristic[bytes] | None
|
||||||
sink_audio_locations: Optional[gatt.Characteristic[bytes]]
|
sink_audio_locations: gatt.Characteristic[bytes] | None
|
||||||
source_pac: Optional[gatt.Characteristic[bytes]]
|
source_pac: gatt.Characteristic[bytes] | None
|
||||||
source_audio_locations: Optional[gatt.Characteristic[bytes]]
|
source_audio_locations: gatt.Characteristic[bytes] | None
|
||||||
available_audio_contexts: gatt.Characteristic[bytes]
|
available_audio_contexts: gatt.Characteristic[bytes]
|
||||||
supported_audio_contexts: gatt.Characteristic[bytes]
|
supported_audio_contexts: gatt.Characteristic[bytes]
|
||||||
|
|
||||||
@@ -115,9 +115,9 @@ class PublishedAudioCapabilitiesService(gatt.TemplateService):
|
|||||||
available_source_context: ContextType,
|
available_source_context: ContextType,
|
||||||
available_sink_context: ContextType,
|
available_sink_context: ContextType,
|
||||||
sink_pac: Sequence[PacRecord] = (),
|
sink_pac: Sequence[PacRecord] = (),
|
||||||
sink_audio_locations: Optional[AudioLocation] = None,
|
sink_audio_locations: AudioLocation | None = None,
|
||||||
source_pac: Sequence[PacRecord] = (),
|
source_pac: Sequence[PacRecord] = (),
|
||||||
source_audio_locations: Optional[AudioLocation] = None,
|
source_audio_locations: AudioLocation | None = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
characteristics = []
|
characteristics = []
|
||||||
|
|
||||||
@@ -183,14 +183,10 @@ class PublishedAudioCapabilitiesService(gatt.TemplateService):
|
|||||||
class PublishedAudioCapabilitiesServiceProxy(gatt_client.ProfileServiceProxy):
|
class PublishedAudioCapabilitiesServiceProxy(gatt_client.ProfileServiceProxy):
|
||||||
SERVICE_CLASS = PublishedAudioCapabilitiesService
|
SERVICE_CLASS = PublishedAudioCapabilitiesService
|
||||||
|
|
||||||
sink_pac: Optional[gatt_client.CharacteristicProxy[list[PacRecord]]] = None
|
sink_pac: gatt_client.CharacteristicProxy[list[PacRecord]] | None = None
|
||||||
sink_audio_locations: Optional[gatt_client.CharacteristicProxy[AudioLocation]] = (
|
sink_audio_locations: gatt_client.CharacteristicProxy[AudioLocation] | None = None
|
||||||
None
|
source_pac: gatt_client.CharacteristicProxy[list[PacRecord]] | None = None
|
||||||
)
|
source_audio_locations: gatt_client.CharacteristicProxy[AudioLocation] | None = None
|
||||||
source_pac: Optional[gatt_client.CharacteristicProxy[list[PacRecord]]] = None
|
|
||||||
source_audio_locations: Optional[gatt_client.CharacteristicProxy[AudioLocation]] = (
|
|
||||||
None
|
|
||||||
)
|
|
||||||
available_audio_contexts: gatt_client.CharacteristicProxy[tuple[ContextType, ...]]
|
available_audio_contexts: gatt_client.CharacteristicProxy[tuple[ContextType, ...]]
|
||||||
supported_audio_contexts: gatt_client.CharacteristicProxy[tuple[ContextType, ...]]
|
supported_audio_contexts: gatt_client.CharacteristicProxy[tuple[ContextType, ...]]
|
||||||
|
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ import enum
|
|||||||
|
|
||||||
from typing_extensions import Self
|
from typing_extensions import Self
|
||||||
|
|
||||||
|
from bumble import core, data_types, gatt
|
||||||
from bumble.profiles import le_audio
|
from bumble.profiles import le_audio
|
||||||
|
|
||||||
|
|
||||||
@@ -46,3 +47,18 @@ class PublicBroadcastAnnouncement:
|
|||||||
return cls(
|
return cls(
|
||||||
features=features, metadata=le_audio.Metadata.from_bytes(metadata_ltv)
|
features=features, metadata=le_audio.Metadata.from_bytes(metadata_ltv)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def get_advertising_data(self) -> bytes:
|
||||||
|
return bytes(
|
||||||
|
core.AdvertisingData(
|
||||||
|
[
|
||||||
|
data_types.ServiceData16BitUUID(
|
||||||
|
gatt.GATT_PUBLIC_BROADCAST_ANNOUNCEMENT_SERVICE, bytes(self)
|
||||||
|
)
|
||||||
|
]
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
def __bytes__(self) -> bytes:
|
||||||
|
metadata_bytes = bytes(self.metadata)
|
||||||
|
return bytes([self.features, len(metadata_bytes)]) + metadata_bytes
|
||||||
|
|||||||
@@ -20,9 +20,9 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import dataclasses
|
import dataclasses
|
||||||
import enum
|
import enum
|
||||||
from typing import Sequence
|
from collections.abc import Sequence
|
||||||
|
|
||||||
from bumble import att, device, gatt, gatt_adapters, gatt_client, utils
|
from bumble import att, device, gatt, gatt_adapters, gatt_client
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
# Constants
|
# Constants
|
||||||
|
|||||||
@@ -18,7 +18,6 @@
|
|||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
import struct
|
import struct
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from typing import Optional
|
|
||||||
|
|
||||||
from bumble import utils
|
from bumble import utils
|
||||||
from bumble.att import ATT_Error
|
from bumble.att import ATT_Error
|
||||||
@@ -69,7 +68,7 @@ class ErrorCode(utils.OpenIntEnum):
|
|||||||
class VolumeOffsetState:
|
class VolumeOffsetState:
|
||||||
volume_offset: int = 0
|
volume_offset: int = 0
|
||||||
change_counter: int = 0
|
change_counter: int = 0
|
||||||
attribute: Optional[Characteristic] = None
|
attribute: Characteristic | None = None
|
||||||
|
|
||||||
def __bytes__(self) -> bytes:
|
def __bytes__(self) -> bytes:
|
||||||
return struct.pack('<hB', self.volume_offset, self.change_counter)
|
return struct.pack('<hB', self.volume_offset, self.change_counter)
|
||||||
@@ -93,7 +92,7 @@ class VolumeOffsetState:
|
|||||||
@dataclass
|
@dataclass
|
||||||
class VocsAudioLocation:
|
class VocsAudioLocation:
|
||||||
audio_location: AudioLocation = AudioLocation.NOT_ALLOWED
|
audio_location: AudioLocation = AudioLocation.NOT_ALLOWED
|
||||||
attribute: Optional[Characteristic] = None
|
attribute: Characteristic | None = None
|
||||||
|
|
||||||
def __bytes__(self) -> bytes:
|
def __bytes__(self) -> bytes:
|
||||||
return struct.pack('<I', self.audio_location)
|
return struct.pack('<I', self.audio_location)
|
||||||
@@ -118,7 +117,6 @@ class VolumeOffsetControlPoint:
|
|||||||
volume_offset_state: VolumeOffsetState
|
volume_offset_state: VolumeOffsetState
|
||||||
|
|
||||||
async def on_write(self, connection: Connection, value: bytes) -> None:
|
async def on_write(self, connection: Connection, value: bytes) -> None:
|
||||||
|
|
||||||
opcode = value[0]
|
opcode = value[0]
|
||||||
if opcode != SetVolumeOffsetOpCode.SET_VOLUME_OFFSET:
|
if opcode != SetVolumeOffsetOpCode.SET_VOLUME_OFFSET:
|
||||||
raise ATT_Error(ErrorCode.OPCODE_NOT_SUPPORTED)
|
raise ATT_Error(ErrorCode.OPCODE_NOT_SUPPORTED)
|
||||||
@@ -148,7 +146,7 @@ class VolumeOffsetControlPoint:
|
|||||||
@dataclass
|
@dataclass
|
||||||
class AudioOutputDescription:
|
class AudioOutputDescription:
|
||||||
audio_output_description: str = ''
|
audio_output_description: str = ''
|
||||||
attribute: Optional[Characteristic] = None
|
attribute: Characteristic | None = None
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def from_bytes(cls, data: bytes):
|
def from_bytes(cls, data: bytes):
|
||||||
@@ -173,11 +171,10 @@ class VolumeOffsetControlService(TemplateService):
|
|||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
volume_offset_state: Optional[VolumeOffsetState] = None,
|
volume_offset_state: VolumeOffsetState | None = None,
|
||||||
audio_location: Optional[VocsAudioLocation] = None,
|
audio_location: VocsAudioLocation | None = None,
|
||||||
audio_output_description: Optional[AudioOutputDescription] = None,
|
audio_output_description: AudioOutputDescription | None = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
|
|
||||||
self.volume_offset_state = (
|
self.volume_offset_state = (
|
||||||
VolumeOffsetState() if volume_offset_state is None else volume_offset_state
|
VolumeOffsetState() if volume_offset_state is None else volume_offset_state
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -22,7 +22,8 @@ import collections
|
|||||||
import dataclasses
|
import dataclasses
|
||||||
import enum
|
import enum
|
||||||
import logging
|
import logging
|
||||||
from typing import TYPE_CHECKING, Callable, Optional, Union
|
from collections.abc import Callable
|
||||||
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
from typing_extensions import Self
|
from typing_extensions import Self
|
||||||
|
|
||||||
@@ -119,7 +120,7 @@ RFCOMM_DYNAMIC_CHANNEL_NUMBER_END = 30
|
|||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
def make_service_sdp_records(
|
def make_service_sdp_records(
|
||||||
service_record_handle: int, channel: int, uuid: Optional[UUID] = None
|
service_record_handle: int, channel: int, uuid: UUID | None = None
|
||||||
) -> list[sdp.ServiceAttribute]:
|
) -> list[sdp.ServiceAttribute]:
|
||||||
"""
|
"""
|
||||||
Create SDP records for an RFComm service given a channel number and an
|
Create SDP records for an RFComm service given a channel number and an
|
||||||
@@ -186,7 +187,7 @@ async def find_rfcomm_channels(connection: Connection) -> dict[int, list[UUID]]:
|
|||||||
)
|
)
|
||||||
for attribute_lists in search_result:
|
for attribute_lists in search_result:
|
||||||
service_classes: list[UUID] = []
|
service_classes: list[UUID] = []
|
||||||
channel: Optional[int] = None
|
channel: int | None = None
|
||||||
for attribute in attribute_lists:
|
for attribute in attribute_lists:
|
||||||
# The layout is [[L2CAP_PROTOCOL], [RFCOMM_PROTOCOL, RFCOMM_CHANNEL]].
|
# The layout is [[L2CAP_PROTOCOL], [RFCOMM_PROTOCOL, RFCOMM_CHANNEL]].
|
||||||
if attribute.id == sdp.SDP_PROTOCOL_DESCRIPTOR_LIST_ATTRIBUTE_ID:
|
if attribute.id == sdp.SDP_PROTOCOL_DESCRIPTOR_LIST_ATTRIBUTE_ID:
|
||||||
@@ -207,7 +208,7 @@ async def find_rfcomm_channels(connection: Connection) -> dict[int, list[UUID]]:
|
|||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
async def find_rfcomm_channel_with_uuid(
|
async def find_rfcomm_channel_with_uuid(
|
||||||
connection: Connection, uuid: str | UUID
|
connection: Connection, uuid: str | UUID
|
||||||
) -> Optional[int]:
|
) -> int | None:
|
||||||
"""Searches an RFCOMM channel associated with given UUID from service records.
|
"""Searches an RFCOMM channel associated with given UUID from service records.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
@@ -473,15 +474,15 @@ class DLC(utils.EventEmitter):
|
|||||||
self.state = DLC.State.INIT
|
self.state = DLC.State.INIT
|
||||||
self.role = multiplexer.role
|
self.role = multiplexer.role
|
||||||
self.c_r = 1 if self.role == Multiplexer.Role.INITIATOR else 0
|
self.c_r = 1 if self.role == Multiplexer.Role.INITIATOR else 0
|
||||||
self.connection_result: Optional[asyncio.Future] = None
|
self.connection_result: asyncio.Future | None = None
|
||||||
self.disconnection_result: Optional[asyncio.Future] = None
|
self.disconnection_result: asyncio.Future | None = None
|
||||||
self.drained = asyncio.Event()
|
self.drained = asyncio.Event()
|
||||||
self.drained.set()
|
self.drained.set()
|
||||||
# Queued packets when sink is not set.
|
# Queued packets when sink is not set.
|
||||||
self._enqueued_rx_packets: collections.deque[bytes] = collections.deque(
|
self._enqueued_rx_packets: collections.deque[bytes] = collections.deque(
|
||||||
maxlen=DEFAULT_RX_QUEUE_SIZE
|
maxlen=DEFAULT_RX_QUEUE_SIZE
|
||||||
)
|
)
|
||||||
self._sink: Optional[Callable[[bytes], None]] = None
|
self._sink: Callable[[bytes], None] | None = None
|
||||||
|
|
||||||
# Compute the MTU
|
# Compute the MTU
|
||||||
max_overhead = 4 + 1 # header with 2-byte length + fcs
|
max_overhead = 4 + 1 # header with 2-byte length + fcs
|
||||||
@@ -490,11 +491,11 @@ class DLC(utils.EventEmitter):
|
|||||||
)
|
)
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def sink(self) -> Optional[Callable[[bytes], None]]:
|
def sink(self) -> Callable[[bytes], None] | None:
|
||||||
return self._sink
|
return self._sink
|
||||||
|
|
||||||
@sink.setter
|
@sink.setter
|
||||||
def sink(self, sink: Optional[Callable[[bytes], None]]) -> None:
|
def sink(self, sink: Callable[[bytes], None] | None) -> None:
|
||||||
self._sink = sink
|
self._sink = sink
|
||||||
# Dump queued packets to sink
|
# Dump queued packets to sink
|
||||||
if sink:
|
if sink:
|
||||||
@@ -674,10 +675,14 @@ class DLC(utils.EventEmitter):
|
|||||||
while (self.tx_buffer and self.tx_credits > 0) or rx_credits_needed > 0:
|
while (self.tx_buffer and self.tx_credits > 0) or rx_credits_needed > 0:
|
||||||
# Get the next chunk, up to MTU size
|
# Get the next chunk, up to MTU size
|
||||||
if rx_credits_needed > 0:
|
if rx_credits_needed > 0:
|
||||||
chunk = bytes([rx_credits_needed]) + self.tx_buffer[: self.mtu - 1]
|
chunk = bytes([rx_credits_needed])
|
||||||
self.tx_buffer = self.tx_buffer[len(chunk) - 1 :]
|
|
||||||
self.rx_credits += rx_credits_needed
|
self.rx_credits += rx_credits_needed
|
||||||
tx_credit_spent = len(chunk) > 1
|
if self.tx_buffer and self.tx_credits > 0:
|
||||||
|
chunk += self.tx_buffer[: self.mtu - 1]
|
||||||
|
self.tx_buffer = self.tx_buffer[len(chunk) - 1 :]
|
||||||
|
tx_credit_spent = True
|
||||||
|
else:
|
||||||
|
tx_credit_spent = False
|
||||||
else:
|
else:
|
||||||
chunk = self.tx_buffer[: self.mtu]
|
chunk = self.tx_buffer[: self.mtu]
|
||||||
self.tx_buffer = self.tx_buffer[len(chunk) :]
|
self.tx_buffer = self.tx_buffer[len(chunk) :]
|
||||||
@@ -708,7 +713,7 @@ class DLC(utils.EventEmitter):
|
|||||||
self.drained.set()
|
self.drained.set()
|
||||||
|
|
||||||
# Stream protocol
|
# Stream protocol
|
||||||
def write(self, data: Union[bytes, str]) -> None:
|
def write(self, data: bytes | str) -> None:
|
||||||
# We can only send bytes
|
# We can only send bytes
|
||||||
if not isinstance(data, bytes):
|
if not isinstance(data, bytes):
|
||||||
if isinstance(data, str):
|
if isinstance(data, str):
|
||||||
@@ -765,10 +770,10 @@ class Multiplexer(utils.EventEmitter):
|
|||||||
|
|
||||||
EVENT_DLC = "dlc"
|
EVENT_DLC = "dlc"
|
||||||
|
|
||||||
connection_result: Optional[asyncio.Future]
|
connection_result: asyncio.Future | None
|
||||||
disconnection_result: Optional[asyncio.Future]
|
disconnection_result: asyncio.Future | None
|
||||||
open_result: Optional[asyncio.Future]
|
open_result: asyncio.Future | None
|
||||||
acceptor: Optional[Callable[[int], Optional[tuple[int, int]]]]
|
acceptor: Callable[[int], tuple[int, int] | None] | None
|
||||||
dlcs: dict[int, DLC]
|
dlcs: dict[int, DLC]
|
||||||
|
|
||||||
def __init__(self, l2cap_channel: l2cap.ClassicChannel, role: Role) -> None:
|
def __init__(self, l2cap_channel: l2cap.ClassicChannel, role: Role) -> None:
|
||||||
@@ -780,7 +785,7 @@ class Multiplexer(utils.EventEmitter):
|
|||||||
self.connection_result = None
|
self.connection_result = None
|
||||||
self.disconnection_result = None
|
self.disconnection_result = None
|
||||||
self.open_result = None
|
self.open_result = None
|
||||||
self.open_pn: Optional[RFCOMM_MCC_PN] = None
|
self.open_pn: RFCOMM_MCC_PN | None = None
|
||||||
self.open_rx_max_credits = 0
|
self.open_rx_max_credits = 0
|
||||||
self.acceptor = None
|
self.acceptor = None
|
||||||
|
|
||||||
@@ -795,7 +800,7 @@ class Multiplexer(utils.EventEmitter):
|
|||||||
|
|
||||||
def send_frame(self, frame: RFCOMM_Frame) -> None:
|
def send_frame(self, frame: RFCOMM_Frame) -> None:
|
||||||
logger.debug(f'>>> Multiplexer sending {frame}')
|
logger.debug(f'>>> Multiplexer sending {frame}')
|
||||||
self.l2cap_channel.send_pdu(frame)
|
self.l2cap_channel.write(bytes(frame))
|
||||||
|
|
||||||
def on_pdu(self, pdu: bytes) -> None:
|
def on_pdu(self, pdu: bytes) -> None:
|
||||||
frame = RFCOMM_Frame.from_bytes(pdu)
|
frame = RFCOMM_Frame.from_bytes(pdu)
|
||||||
@@ -1027,8 +1032,8 @@ class Multiplexer(utils.EventEmitter):
|
|||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
class Client:
|
class Client:
|
||||||
multiplexer: Optional[Multiplexer]
|
multiplexer: Multiplexer | None
|
||||||
l2cap_channel: Optional[l2cap.ClassicChannel]
|
l2cap_channel: l2cap.ClassicChannel | None
|
||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
self, connection: Connection, l2cap_mtu: int = RFCOMM_DEFAULT_L2CAP_MTU
|
self, connection: Connection, l2cap_mtu: int = RFCOMM_DEFAULT_L2CAP_MTU
|
||||||
@@ -1141,7 +1146,7 @@ class Server(utils.EventEmitter):
|
|||||||
# Notify
|
# Notify
|
||||||
self.emit(self.EVENT_START, multiplexer)
|
self.emit(self.EVENT_START, multiplexer)
|
||||||
|
|
||||||
def accept_dlc(self, channel_number: int) -> Optional[tuple[int, int]]:
|
def accept_dlc(self, channel_number: int) -> tuple[int, int] | None:
|
||||||
return self.dlc_configs.get(channel_number)
|
return self.dlc_configs.get(channel_number)
|
||||||
|
|
||||||
def on_dlc(self, dlc: DLC) -> None:
|
def on_dlc(self, dlc: DLC) -> None:
|
||||||
|
|||||||
480
bumble/sdp.py
480
bumble/sdp.py
@@ -20,11 +20,13 @@ from __future__ import annotations
|
|||||||
import asyncio
|
import asyncio
|
||||||
import logging
|
import logging
|
||||||
import struct
|
import struct
|
||||||
from typing import TYPE_CHECKING, Iterable, NewType, Optional, Sequence, Union
|
from collections.abc import Iterable, Sequence
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from typing import TYPE_CHECKING, Any, ClassVar, NewType, TypeVar
|
||||||
|
|
||||||
from typing_extensions import Self
|
from typing_extensions import Self
|
||||||
|
|
||||||
from bumble import core, l2cap
|
from bumble import core, hci, l2cap, utils
|
||||||
from bumble.colors import color
|
from bumble.colors import color
|
||||||
from bumble.core import (
|
from bumble.core import (
|
||||||
InvalidArgumentError,
|
InvalidArgumentError,
|
||||||
@@ -32,7 +34,6 @@ from bumble.core import (
|
|||||||
InvalidStateError,
|
InvalidStateError,
|
||||||
ProtocolError,
|
ProtocolError,
|
||||||
)
|
)
|
||||||
from bumble.hci import HCI_Object, key_with_value, name_or_number
|
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from bumble.device import Connection, Device
|
from bumble.device import Connection, Device
|
||||||
@@ -53,39 +54,22 @@ SDP_CONTINUATION_WATCHDOG = 64 # Maximum number of continuations we're willing
|
|||||||
|
|
||||||
SDP_PSM = 0x0001
|
SDP_PSM = 0x0001
|
||||||
|
|
||||||
SDP_ERROR_RESPONSE = 0x01
|
class PduId(hci.SpecableEnum):
|
||||||
SDP_SERVICE_SEARCH_REQUEST = 0x02
|
SDP_ERROR_RESPONSE = 0x01
|
||||||
SDP_SERVICE_SEARCH_RESPONSE = 0x03
|
SDP_SERVICE_SEARCH_REQUEST = 0x02
|
||||||
SDP_SERVICE_ATTRIBUTE_REQUEST = 0x04
|
SDP_SERVICE_SEARCH_RESPONSE = 0x03
|
||||||
SDP_SERVICE_ATTRIBUTE_RESPONSE = 0x05
|
SDP_SERVICE_ATTRIBUTE_REQUEST = 0x04
|
||||||
SDP_SERVICE_SEARCH_ATTRIBUTE_REQUEST = 0x06
|
SDP_SERVICE_ATTRIBUTE_RESPONSE = 0x05
|
||||||
SDP_SERVICE_SEARCH_ATTRIBUTE_RESPONSE = 0x07
|
SDP_SERVICE_SEARCH_ATTRIBUTE_REQUEST = 0x06
|
||||||
|
SDP_SERVICE_SEARCH_ATTRIBUTE_RESPONSE = 0x07
|
||||||
|
|
||||||
SDP_PDU_NAMES = {
|
class ErrorCode(hci.SpecableEnum):
|
||||||
SDP_ERROR_RESPONSE: 'SDP_ERROR_RESPONSE',
|
INVALID_SDP_VERSION = 0x0001
|
||||||
SDP_SERVICE_SEARCH_REQUEST: 'SDP_SERVICE_SEARCH_REQUEST',
|
INVALID_SERVICE_RECORD_HANDLE = 0x0002
|
||||||
SDP_SERVICE_SEARCH_RESPONSE: 'SDP_SERVICE_SEARCH_RESPONSE',
|
INVALID_REQUEST_SYNTAX = 0x0003
|
||||||
SDP_SERVICE_ATTRIBUTE_REQUEST: 'SDP_SERVICE_ATTRIBUTE_REQUEST',
|
INVALID_PDU_SIZE = 0x0004
|
||||||
SDP_SERVICE_ATTRIBUTE_RESPONSE: 'SDP_SERVICE_ATTRIBUTE_RESPONSE',
|
INVALID_CONTINUATION_STATE = 0x0005
|
||||||
SDP_SERVICE_SEARCH_ATTRIBUTE_REQUEST: 'SDP_SERVICE_SEARCH_ATTRIBUTE_REQUEST',
|
INSUFFICIENT_RESOURCES_TO_SATISFY_REQUEST = 0x0006
|
||||||
SDP_SERVICE_SEARCH_ATTRIBUTE_RESPONSE: 'SDP_SERVICE_SEARCH_ATTRIBUTE_RESPONSE'
|
|
||||||
}
|
|
||||||
|
|
||||||
SDP_INVALID_SDP_VERSION_ERROR = 0x0001
|
|
||||||
SDP_INVALID_SERVICE_RECORD_HANDLE_ERROR = 0x0002
|
|
||||||
SDP_INVALID_REQUEST_SYNTAX_ERROR = 0x0003
|
|
||||||
SDP_INVALID_PDU_SIZE_ERROR = 0x0004
|
|
||||||
SDP_INVALID_CONTINUATION_STATE_ERROR = 0x0005
|
|
||||||
SDP_INSUFFICIENT_RESOURCES_TO_SATISFY_REQUEST_ERROR = 0x0006
|
|
||||||
|
|
||||||
SDP_ERROR_NAMES = {
|
|
||||||
SDP_INVALID_SDP_VERSION_ERROR: 'SDP_INVALID_SDP_VERSION_ERROR',
|
|
||||||
SDP_INVALID_SERVICE_RECORD_HANDLE_ERROR: 'SDP_INVALID_SERVICE_RECORD_HANDLE_ERROR',
|
|
||||||
SDP_INVALID_REQUEST_SYNTAX_ERROR: 'SDP_INVALID_REQUEST_SYNTAX_ERROR',
|
|
||||||
SDP_INVALID_PDU_SIZE_ERROR: 'SDP_INVALID_PDU_SIZE_ERROR',
|
|
||||||
SDP_INVALID_CONTINUATION_STATE_ERROR: 'SDP_INVALID_CONTINUATION_STATE_ERROR',
|
|
||||||
SDP_INSUFFICIENT_RESOURCES_TO_SATISFY_REQUEST_ERROR: 'SDP_INSUFFICIENT_RESOURCES_TO_SATISFY_REQUEST_ERROR'
|
|
||||||
}
|
|
||||||
|
|
||||||
SDP_SERVICE_NAME_ATTRIBUTE_ID_OFFSET = 0x0000
|
SDP_SERVICE_NAME_ATTRIBUTE_ID_OFFSET = 0x0000
|
||||||
SDP_SERVICE_DESCRIPTION_ATTRIBUTE_ID_OFFSET = 0x0001
|
SDP_SERVICE_DESCRIPTION_ATTRIBUTE_ID_OFFSET = 0x0001
|
||||||
@@ -140,30 +124,31 @@ SDP_ALL_ATTRIBUTES_RANGE = (0x0000, 0xFFFF)
|
|||||||
|
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
|
@dataclass
|
||||||
class DataElement:
|
class DataElement:
|
||||||
NIL = 0
|
|
||||||
UNSIGNED_INTEGER = 1
|
|
||||||
SIGNED_INTEGER = 2
|
|
||||||
UUID = 3
|
|
||||||
TEXT_STRING = 4
|
|
||||||
BOOLEAN = 5
|
|
||||||
SEQUENCE = 6
|
|
||||||
ALTERNATIVE = 7
|
|
||||||
URL = 8
|
|
||||||
|
|
||||||
TYPE_NAMES = {
|
class Type(utils.OpenIntEnum):
|
||||||
NIL: 'NIL',
|
NIL = 0
|
||||||
UNSIGNED_INTEGER: 'UNSIGNED_INTEGER',
|
UNSIGNED_INTEGER = 1
|
||||||
SIGNED_INTEGER: 'SIGNED_INTEGER',
|
SIGNED_INTEGER = 2
|
||||||
UUID: 'UUID',
|
UUID = 3
|
||||||
TEXT_STRING: 'TEXT_STRING',
|
TEXT_STRING = 4
|
||||||
BOOLEAN: 'BOOLEAN',
|
BOOLEAN = 5
|
||||||
SEQUENCE: 'SEQUENCE',
|
SEQUENCE = 6
|
||||||
ALTERNATIVE: 'ALTERNATIVE',
|
ALTERNATIVE = 7
|
||||||
URL: 'URL',
|
URL = 8
|
||||||
}
|
|
||||||
|
|
||||||
type_constructors = {
|
NIL = Type.NIL
|
||||||
|
UNSIGNED_INTEGER = Type.UNSIGNED_INTEGER
|
||||||
|
SIGNED_INTEGER = Type.SIGNED_INTEGER
|
||||||
|
UUID = Type.UUID
|
||||||
|
TEXT_STRING = Type.TEXT_STRING
|
||||||
|
BOOLEAN = Type.BOOLEAN
|
||||||
|
SEQUENCE = Type.SEQUENCE
|
||||||
|
ALTERNATIVE = Type.ALTERNATIVE
|
||||||
|
URL = Type.URL
|
||||||
|
|
||||||
|
TYPE_CONSTRUCTORS = {
|
||||||
NIL: lambda x: DataElement(DataElement.NIL, None),
|
NIL: lambda x: DataElement(DataElement.NIL, None),
|
||||||
UNSIGNED_INTEGER: lambda x, y: DataElement(
|
UNSIGNED_INTEGER: lambda x, y: DataElement(
|
||||||
DataElement.UNSIGNED_INTEGER,
|
DataElement.UNSIGNED_INTEGER,
|
||||||
@@ -189,14 +174,18 @@ class DataElement:
|
|||||||
URL: lambda x: DataElement(DataElement.URL, x.decode('utf8')),
|
URL: lambda x: DataElement(DataElement.URL, x.decode('utf8')),
|
||||||
}
|
}
|
||||||
|
|
||||||
def __init__(self, element_type, value, value_size=None):
|
type: Type
|
||||||
self.type = element_type
|
value: Any
|
||||||
self.value = value
|
value_size: int | None = None
|
||||||
self.value_size = value_size
|
|
||||||
|
def __post_init__(self) -> None:
|
||||||
# Used as a cache when parsing from bytes so we can emit a byte-for-byte replica
|
# Used as a cache when parsing from bytes so we can emit a byte-for-byte replica
|
||||||
self.bytes = None
|
self._bytes: bytes | None = None
|
||||||
if element_type in (DataElement.UNSIGNED_INTEGER, DataElement.SIGNED_INTEGER):
|
if self.type in (
|
||||||
if value_size is None:
|
DataElement.UNSIGNED_INTEGER,
|
||||||
|
DataElement.SIGNED_INTEGER,
|
||||||
|
):
|
||||||
|
if self.value_size is None:
|
||||||
raise InvalidArgumentError(
|
raise InvalidArgumentError(
|
||||||
'integer types must have a value size specified'
|
'integer types must have a value size specified'
|
||||||
)
|
)
|
||||||
@@ -336,7 +325,7 @@ class DataElement:
|
|||||||
value_offset = 4
|
value_offset = 4
|
||||||
|
|
||||||
value_data = data[1 + value_offset : 1 + value_offset + value_size]
|
value_data = data[1 + value_offset : 1 + value_offset + value_size]
|
||||||
constructor = DataElement.type_constructors.get(element_type)
|
constructor = DataElement.TYPE_CONSTRUCTORS.get(element_type)
|
||||||
if constructor:
|
if constructor:
|
||||||
if element_type in (
|
if element_type in (
|
||||||
DataElement.UNSIGNED_INTEGER,
|
DataElement.UNSIGNED_INTEGER,
|
||||||
@@ -347,15 +336,15 @@ class DataElement:
|
|||||||
result = constructor(value_data)
|
result = constructor(value_data)
|
||||||
else:
|
else:
|
||||||
result = DataElement(element_type, value_data)
|
result = DataElement(element_type, value_data)
|
||||||
result.bytes = data[
|
result._bytes = data[
|
||||||
: 1 + value_offset + value_size
|
: 1 + value_offset + value_size
|
||||||
] # Keep a copy so we can re-serialize to an exact replica
|
] # Keep a copy so we can re-serialize to an exact replica
|
||||||
return result
|
return result
|
||||||
|
|
||||||
def __bytes__(self):
|
def __bytes__(self):
|
||||||
# Return early if we have a cache
|
# Return early if we have a cache
|
||||||
if self.bytes:
|
if self._bytes:
|
||||||
return self.bytes
|
return self._bytes
|
||||||
|
|
||||||
if self.type == DataElement.NIL:
|
if self.type == DataElement.NIL:
|
||||||
data = b''
|
data = b''
|
||||||
@@ -442,12 +431,12 @@ class DataElement:
|
|||||||
else:
|
else:
|
||||||
raise RuntimeError("internal error - self.type not supported")
|
raise RuntimeError("internal error - self.type not supported")
|
||||||
|
|
||||||
self.bytes = bytes([self.type << 3 | size_index]) + size_bytes + data
|
self._bytes = bytes([self.type << 3 | size_index]) + size_bytes + data
|
||||||
return self.bytes
|
return self._bytes
|
||||||
|
|
||||||
def to_string(self, pretty=False, indentation=0):
|
def to_string(self, pretty=False, indentation=0):
|
||||||
prefix = ' ' * indentation
|
prefix = ' ' * indentation
|
||||||
type_name = name_or_number(self.TYPE_NAMES, self.type)
|
type_name = self.type.name
|
||||||
if self.type == DataElement.NIL:
|
if self.type == DataElement.NIL:
|
||||||
value_string = ''
|
value_string = ''
|
||||||
elif self.type in (DataElement.SEQUENCE, DataElement.ALTERNATIVE):
|
elif self.type in (DataElement.SEQUENCE, DataElement.ALTERNATIVE):
|
||||||
@@ -475,10 +464,10 @@ class DataElement:
|
|||||||
|
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
|
@dataclass
|
||||||
class ServiceAttribute:
|
class ServiceAttribute:
|
||||||
def __init__(self, attribute_id: int, value: DataElement) -> None:
|
id: int
|
||||||
self.id = attribute_id
|
value: DataElement
|
||||||
self.value = value
|
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def list_from_data_elements(
|
def list_from_data_elements(
|
||||||
@@ -497,7 +486,7 @@ class ServiceAttribute:
|
|||||||
@staticmethod
|
@staticmethod
|
||||||
def find_attribute_in_list(
|
def find_attribute_in_list(
|
||||||
attribute_list: Iterable[ServiceAttribute], attribute_id: int
|
attribute_list: Iterable[ServiceAttribute], attribute_id: int
|
||||||
) -> Optional[DataElement]:
|
) -> DataElement | None:
|
||||||
return next(
|
return next(
|
||||||
(
|
(
|
||||||
attribute.value
|
attribute.value
|
||||||
@@ -509,7 +498,7 @@ class ServiceAttribute:
|
|||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def id_name(id_code):
|
def id_name(id_code):
|
||||||
return name_or_number(SDP_ATTRIBUTE_ID_NAMES, id_code)
|
return hci.name_or_number(SDP_ATTRIBUTE_ID_NAMES, id_code)
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def is_uuid_in_value(uuid: core.UUID, value: DataElement) -> bool:
|
def is_uuid_in_value(uuid: core.UUID, value: DataElement) -> bool:
|
||||||
@@ -528,7 +517,7 @@ class ServiceAttribute:
|
|||||||
def to_string(self, with_colors=False):
|
def to_string(self, with_colors=False):
|
||||||
if with_colors:
|
if with_colors:
|
||||||
return (
|
return (
|
||||||
f'Attribute(id={color(self.id_name(self.id),"magenta")},'
|
f'Attribute(id={color(self.id_name(self.id), "magenta")},'
|
||||||
f'value={self.value})'
|
f'value={self.value})'
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -539,250 +528,239 @@ class ServiceAttribute:
|
|||||||
|
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
|
def _parse_service_record_handle_list(
|
||||||
|
data: bytes, offset: int
|
||||||
|
) -> tuple[int, list[int]]:
|
||||||
|
count = struct.unpack_from('>H', data, offset)[0]
|
||||||
|
offset += 2
|
||||||
|
handle_list = [
|
||||||
|
struct.unpack_from('>I', data, offset + x * 4)[0] for x in range(count)
|
||||||
|
]
|
||||||
|
return offset + count * 4, handle_list
|
||||||
|
|
||||||
|
|
||||||
|
def _serialize_service_record_handle_list(
|
||||||
|
handles: list[int],
|
||||||
|
) -> bytes:
|
||||||
|
return struct.pack('>H', len(handles)) + b''.join(
|
||||||
|
struct.pack('>I', handle) for handle in handles
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_bytes_preceded_by_length(data: bytes, offset: int) -> tuple[int, bytes]:
|
||||||
|
length = struct.unpack_from('>H', data, offset)[0]
|
||||||
|
offset += 2
|
||||||
|
return offset + length, data[offset : offset + length]
|
||||||
|
|
||||||
|
|
||||||
|
def _serialize_bytes_preceded_by_length(data: bytes) -> bytes:
|
||||||
|
return struct.pack('>H', len(data)) + data
|
||||||
|
|
||||||
|
|
||||||
|
_SERVICE_RECORD_HANDLE_LIST_METADATA = hci.metadata(
|
||||||
|
{
|
||||||
|
'parser': _parse_service_record_handle_list,
|
||||||
|
'serializer': _serialize_service_record_handle_list,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
_BYTES_PRECEDED_BY_LENGTH_METADATA = hci.metadata(
|
||||||
|
{
|
||||||
|
'parser': _parse_bytes_preceded_by_length,
|
||||||
|
'serializer': _serialize_bytes_preceded_by_length,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
@dataclass
|
||||||
class SDP_PDU:
|
class SDP_PDU:
|
||||||
'''
|
'''
|
||||||
See Bluetooth spec @ Vol 3, Part B - 4.2 PROTOCOL DATA UNIT FORMAT
|
See Bluetooth spec @ Vol 3, Part B - 4.2 PROTOCOL DATA UNIT FORMAT
|
||||||
'''
|
'''
|
||||||
|
|
||||||
RESPONSE_PDU_IDS = {
|
RESPONSE_PDU_IDS = {
|
||||||
SDP_SERVICE_SEARCH_REQUEST: SDP_SERVICE_SEARCH_RESPONSE,
|
PduId.SDP_SERVICE_SEARCH_REQUEST: PduId.SDP_SERVICE_SEARCH_RESPONSE,
|
||||||
SDP_SERVICE_ATTRIBUTE_REQUEST: SDP_SERVICE_ATTRIBUTE_RESPONSE,
|
PduId.SDP_SERVICE_ATTRIBUTE_REQUEST: PduId.SDP_SERVICE_ATTRIBUTE_RESPONSE,
|
||||||
SDP_SERVICE_SEARCH_ATTRIBUTE_REQUEST: SDP_SERVICE_SEARCH_ATTRIBUTE_RESPONSE,
|
PduId.SDP_SERVICE_SEARCH_ATTRIBUTE_REQUEST: PduId.SDP_SERVICE_SEARCH_ATTRIBUTE_RESPONSE,
|
||||||
}
|
}
|
||||||
sdp_pdu_classes: dict[int, type[SDP_PDU]] = {}
|
subclasses: ClassVar[dict[int, type[SDP_PDU]]] = {}
|
||||||
name = None
|
pdu_id: ClassVar[PduId]
|
||||||
pdu_id = 0
|
fields: ClassVar[hci.Fields]
|
||||||
|
|
||||||
@staticmethod
|
transaction_id: int
|
||||||
def from_bytes(pdu):
|
_payload: bytes | None = field(init=False, repr=False, default=None)
|
||||||
pdu_id, transaction_id, _parameters_length = struct.unpack_from('>BHH', pdu, 0)
|
|
||||||
|
|
||||||
cls = SDP_PDU.sdp_pdu_classes.get(pdu_id)
|
@classmethod
|
||||||
if cls is None:
|
def from_bytes(cls, pdu: bytes) -> SDP_PDU:
|
||||||
instance = SDP_PDU(pdu)
|
pdu_id, transaction_id, parameters_length = struct.unpack_from('>BHH', pdu, 0)
|
||||||
instance.name = SDP_PDU.pdu_name(pdu_id)
|
|
||||||
instance.pdu_id = pdu_id
|
|
||||||
instance.transaction_id = transaction_id
|
|
||||||
return instance
|
|
||||||
self = cls.__new__(cls)
|
|
||||||
SDP_PDU.__init__(self, pdu, transaction_id)
|
|
||||||
if hasattr(self, 'fields'):
|
|
||||||
self.init_from_bytes(pdu, 5)
|
|
||||||
return self
|
|
||||||
|
|
||||||
@staticmethod
|
if len(pdu) != 5 + parameters_length:
|
||||||
def parse_service_record_handle_list_preceded_by_count(
|
logger.warning("Expect %d bytes, got %d", 5 + parameters_length, len(pdu))
|
||||||
data: bytes, offset: int
|
|
||||||
) -> tuple[int, list[int]]:
|
|
||||||
count = struct.unpack_from('>H', data, offset - 2)[0]
|
|
||||||
handle_list = [
|
|
||||||
struct.unpack_from('>I', data, offset + x * 4)[0] for x in range(count)
|
|
||||||
]
|
|
||||||
return offset + count * 4, handle_list
|
|
||||||
|
|
||||||
@staticmethod
|
subclass = cls.subclasses.get(pdu_id)
|
||||||
def parse_bytes_preceded_by_length(data, offset):
|
if not (subclass := cls.subclasses.get(pdu_id)):
|
||||||
length = struct.unpack_from('>H', data, offset - 2)[0]
|
raise InvalidPacketError(f"Unknown PDU type {pdu_id}")
|
||||||
return offset + length, data[offset : offset + length]
|
instance = subclass(
|
||||||
|
transaction_id=transaction_id,
|
||||||
|
**hci.HCI_Object.dict_from_bytes(pdu, 5, subclass.fields),
|
||||||
|
)
|
||||||
|
instance._payload = pdu
|
||||||
|
return instance
|
||||||
|
|
||||||
@staticmethod
|
_PDU = TypeVar('_PDU', bound='SDP_PDU')
|
||||||
def error_name(error_code):
|
|
||||||
return name_or_number(SDP_ERROR_NAMES, error_code)
|
|
||||||
|
|
||||||
@staticmethod
|
@classmethod
|
||||||
def pdu_name(code):
|
def subclass(cls, subclass: type[_PDU]) -> type[_PDU]:
|
||||||
return name_or_number(SDP_PDU_NAMES, code)
|
subclass.fields = hci.HCI_Object.fields_from_dataclass(subclass)
|
||||||
|
cls.subclasses[subclass.pdu_id] = subclass
|
||||||
@staticmethod
|
return subclass
|
||||||
def subclass(fields):
|
|
||||||
def inner(cls):
|
|
||||||
name = cls.__name__
|
|
||||||
|
|
||||||
# add a _ character before every uppercase letter, except the SDP_ prefix
|
|
||||||
location = len(name) - 1
|
|
||||||
while location > 4:
|
|
||||||
if not name[location].isupper():
|
|
||||||
location -= 1
|
|
||||||
continue
|
|
||||||
name = name[:location] + '_' + name[location:]
|
|
||||||
location -= 1
|
|
||||||
|
|
||||||
cls.name = name.upper()
|
|
||||||
cls.pdu_id = key_with_value(SDP_PDU_NAMES, cls.name)
|
|
||||||
if cls.pdu_id is None:
|
|
||||||
raise KeyError(f'PDU name {cls.name} not found in SDP_PDU_NAMES')
|
|
||||||
cls.fields = fields
|
|
||||||
|
|
||||||
# Register a factory for this class
|
|
||||||
SDP_PDU.sdp_pdu_classes[cls.pdu_id] = cls
|
|
||||||
|
|
||||||
return cls
|
|
||||||
|
|
||||||
return inner
|
|
||||||
|
|
||||||
def __init__(self, pdu=None, transaction_id=0, **kwargs):
|
|
||||||
if hasattr(self, 'fields') and kwargs:
|
|
||||||
HCI_Object.init_from_fields(self, self.fields, kwargs)
|
|
||||||
if pdu is None:
|
|
||||||
parameters = HCI_Object.dict_to_bytes(kwargs, self.fields)
|
|
||||||
pdu = (
|
|
||||||
struct.pack('>BHH', self.pdu_id, transaction_id, len(parameters))
|
|
||||||
+ parameters
|
|
||||||
)
|
|
||||||
self.pdu = pdu
|
|
||||||
self.transaction_id = transaction_id
|
|
||||||
|
|
||||||
def init_from_bytes(self, pdu, offset):
|
|
||||||
return HCI_Object.init_from_bytes(self, pdu, offset, self.fields)
|
|
||||||
|
|
||||||
def __bytes__(self):
|
def __bytes__(self):
|
||||||
return self.pdu
|
if self._payload is None:
|
||||||
|
parameters = hci.HCI_Object.dict_to_bytes(self.__dict__, self.fields)
|
||||||
|
self._payload = (
|
||||||
|
struct.pack('>BHH', self.pdu_id, self.transaction_id, len(parameters))
|
||||||
|
+ parameters
|
||||||
|
)
|
||||||
|
return self._payload
|
||||||
|
|
||||||
|
@property
|
||||||
|
def name(self) -> str:
|
||||||
|
return self.pdu_id.name
|
||||||
|
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
result = f'{color(self.name, "blue")} [TID={self.transaction_id}]'
|
result = f'{color(self.name, "blue")} [TID={self.transaction_id}]'
|
||||||
if fields := getattr(self, 'fields', None):
|
if fields := getattr(self, 'fields', None):
|
||||||
result += ':\n' + HCI_Object.format_fields(self.__dict__, fields, ' ')
|
result += ':\n' + hci.HCI_Object.format_fields(self.__dict__, fields, ' ')
|
||||||
elif len(self.pdu) > 1:
|
elif len(self.pdu) > 1:
|
||||||
result += f': {self.pdu.hex()}'
|
result += f': {self.pdu.hex()}'
|
||||||
return result
|
return result
|
||||||
|
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
@SDP_PDU.subclass([('error_code', {'size': 2, 'mapper': SDP_PDU.error_name})])
|
@SDP_PDU.subclass
|
||||||
|
@dataclass
|
||||||
class SDP_ErrorResponse(SDP_PDU):
|
class SDP_ErrorResponse(SDP_PDU):
|
||||||
'''
|
'''
|
||||||
See Bluetooth spec @ Vol 3, Part B - 4.4.1 SDP_ErrorResponse PDU
|
See Bluetooth spec @ Vol 3, Part B - 4.4.1 SDP_ErrorResponse PDU
|
||||||
'''
|
'''
|
||||||
|
|
||||||
error_code: int
|
pdu_id = PduId.SDP_ERROR_RESPONSE
|
||||||
|
|
||||||
|
error_code: ErrorCode = field(metadata=ErrorCode.type_metadata(2))
|
||||||
|
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
@SDP_PDU.subclass(
|
@SDP_PDU.subclass
|
||||||
[
|
@dataclass
|
||||||
('service_search_pattern', DataElement.parse_from_bytes),
|
|
||||||
('maximum_service_record_count', '>2'),
|
|
||||||
('continuation_state', '*'),
|
|
||||||
]
|
|
||||||
)
|
|
||||||
class SDP_ServiceSearchRequest(SDP_PDU):
|
class SDP_ServiceSearchRequest(SDP_PDU):
|
||||||
'''
|
'''
|
||||||
See Bluetooth spec @ Vol 3, Part B - 4.5.1 SDP_ServiceSearchRequest PDU
|
See Bluetooth spec @ Vol 3, Part B - 4.5.1 SDP_ServiceSearchRequest PDU
|
||||||
'''
|
'''
|
||||||
|
|
||||||
service_search_pattern: DataElement
|
pdu_id = PduId.SDP_SERVICE_SEARCH_REQUEST
|
||||||
maximum_service_record_count: int
|
|
||||||
continuation_state: bytes
|
service_search_pattern: DataElement = field(
|
||||||
|
metadata=hci.metadata(DataElement.parse_from_bytes)
|
||||||
|
)
|
||||||
|
maximum_service_record_count: int = field(metadata=hci.metadata('>2'))
|
||||||
|
continuation_state: bytes = field(metadata=hci.metadata('*'))
|
||||||
|
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
@SDP_PDU.subclass(
|
@SDP_PDU.subclass
|
||||||
[
|
@dataclass
|
||||||
('total_service_record_count', '>2'),
|
|
||||||
('current_service_record_count', '>2'),
|
|
||||||
(
|
|
||||||
'service_record_handle_list',
|
|
||||||
SDP_PDU.parse_service_record_handle_list_preceded_by_count,
|
|
||||||
),
|
|
||||||
('continuation_state', '*'),
|
|
||||||
]
|
|
||||||
)
|
|
||||||
class SDP_ServiceSearchResponse(SDP_PDU):
|
class SDP_ServiceSearchResponse(SDP_PDU):
|
||||||
'''
|
'''
|
||||||
See Bluetooth spec @ Vol 3, Part B - 4.5.2 SDP_ServiceSearchResponse PDU
|
See Bluetooth spec @ Vol 3, Part B - 4.5.2 SDP_ServiceSearchResponse PDU
|
||||||
'''
|
'''
|
||||||
|
|
||||||
service_record_handle_list: list[int]
|
pdu_id = PduId.SDP_SERVICE_SEARCH_RESPONSE
|
||||||
total_service_record_count: int
|
|
||||||
current_service_record_count: int
|
total_service_record_count: int = field(metadata=hci.metadata('>2'))
|
||||||
continuation_state: bytes
|
service_record_handle_list: Sequence[int] = field(
|
||||||
|
metadata=_SERVICE_RECORD_HANDLE_LIST_METADATA
|
||||||
|
)
|
||||||
|
continuation_state: bytes = field(metadata=hci.metadata('*'))
|
||||||
|
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
@SDP_PDU.subclass(
|
@SDP_PDU.subclass
|
||||||
[
|
@dataclass
|
||||||
('service_record_handle', '>4'),
|
|
||||||
('maximum_attribute_byte_count', '>2'),
|
|
||||||
('attribute_id_list', DataElement.parse_from_bytes),
|
|
||||||
('continuation_state', '*'),
|
|
||||||
]
|
|
||||||
)
|
|
||||||
class SDP_ServiceAttributeRequest(SDP_PDU):
|
class SDP_ServiceAttributeRequest(SDP_PDU):
|
||||||
'''
|
'''
|
||||||
See Bluetooth spec @ Vol 3, Part B - 4.6.1 SDP_ServiceAttributeRequest PDU
|
See Bluetooth spec @ Vol 3, Part B - 4.6.1 SDP_ServiceAttributeRequest PDU
|
||||||
'''
|
'''
|
||||||
|
|
||||||
service_record_handle: int
|
pdu_id = PduId.SDP_SERVICE_ATTRIBUTE_REQUEST
|
||||||
maximum_attribute_byte_count: int
|
|
||||||
attribute_id_list: DataElement
|
service_record_handle: int = field(metadata=hci.metadata('>4'))
|
||||||
continuation_state: bytes
|
maximum_attribute_byte_count: int = field(metadata=hci.metadata('>2'))
|
||||||
|
attribute_id_list: DataElement = field(
|
||||||
|
metadata=hci.metadata(DataElement.parse_from_bytes)
|
||||||
|
)
|
||||||
|
continuation_state: bytes = field(metadata=hci.metadata('*'))
|
||||||
|
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
@SDP_PDU.subclass(
|
@SDP_PDU.subclass
|
||||||
[
|
@dataclass
|
||||||
('attribute_list_byte_count', '>2'),
|
|
||||||
('attribute_list', SDP_PDU.parse_bytes_preceded_by_length),
|
|
||||||
('continuation_state', '*'),
|
|
||||||
]
|
|
||||||
)
|
|
||||||
class SDP_ServiceAttributeResponse(SDP_PDU):
|
class SDP_ServiceAttributeResponse(SDP_PDU):
|
||||||
'''
|
'''
|
||||||
See Bluetooth spec @ Vol 3, Part B - 4.6.2 SDP_ServiceAttributeResponse PDU
|
See Bluetooth spec @ Vol 3, Part B - 4.6.2 SDP_ServiceAttributeResponse PDU
|
||||||
'''
|
'''
|
||||||
|
|
||||||
attribute_list_byte_count: int
|
pdu_id = PduId.SDP_SERVICE_ATTRIBUTE_RESPONSE
|
||||||
attribute_list: bytes
|
|
||||||
continuation_state: bytes
|
attribute_list: bytes = field(metadata=_BYTES_PRECEDED_BY_LENGTH_METADATA)
|
||||||
|
continuation_state: bytes = field(metadata=hci.metadata('*'))
|
||||||
|
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
@SDP_PDU.subclass(
|
@SDP_PDU.subclass
|
||||||
[
|
@dataclass
|
||||||
('service_search_pattern', DataElement.parse_from_bytes),
|
|
||||||
('maximum_attribute_byte_count', '>2'),
|
|
||||||
('attribute_id_list', DataElement.parse_from_bytes),
|
|
||||||
('continuation_state', '*'),
|
|
||||||
]
|
|
||||||
)
|
|
||||||
class SDP_ServiceSearchAttributeRequest(SDP_PDU):
|
class SDP_ServiceSearchAttributeRequest(SDP_PDU):
|
||||||
'''
|
'''
|
||||||
See Bluetooth spec @ Vol 3, Part B - 4.7.1 SDP_ServiceSearchAttributeRequest PDU
|
See Bluetooth spec @ Vol 3, Part B - 4.7.1 SDP_ServiceSearchAttributeRequest PDU
|
||||||
'''
|
'''
|
||||||
|
|
||||||
service_search_pattern: DataElement
|
pdu_id = PduId.SDP_SERVICE_SEARCH_ATTRIBUTE_REQUEST
|
||||||
maximum_attribute_byte_count: int
|
|
||||||
attribute_id_list: DataElement
|
service_search_pattern: DataElement = field(
|
||||||
continuation_state: bytes
|
metadata=hci.metadata(DataElement.parse_from_bytes)
|
||||||
|
)
|
||||||
|
maximum_attribute_byte_count: int = field(metadata=hci.metadata('>2'))
|
||||||
|
attribute_id_list: DataElement = field(
|
||||||
|
metadata=hci.metadata(DataElement.parse_from_bytes)
|
||||||
|
)
|
||||||
|
continuation_state: bytes = field(metadata=hci.metadata('*'))
|
||||||
|
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
@SDP_PDU.subclass(
|
@SDP_PDU.subclass
|
||||||
[
|
@dataclass
|
||||||
('attribute_lists_byte_count', '>2'),
|
|
||||||
('attribute_lists', SDP_PDU.parse_bytes_preceded_by_length),
|
|
||||||
('continuation_state', '*'),
|
|
||||||
]
|
|
||||||
)
|
|
||||||
class SDP_ServiceSearchAttributeResponse(SDP_PDU):
|
class SDP_ServiceSearchAttributeResponse(SDP_PDU):
|
||||||
'''
|
'''
|
||||||
See Bluetooth spec @ Vol 3, Part B - 4.7.2 SDP_ServiceSearchAttributeResponse PDU
|
See Bluetooth spec @ Vol 3, Part B - 4.7.2 SDP_ServiceSearchAttributeResponse PDU
|
||||||
'''
|
'''
|
||||||
|
|
||||||
attribute_lists_byte_count: int
|
pdu_id = PduId.SDP_SERVICE_SEARCH_ATTRIBUTE_RESPONSE
|
||||||
attribute_lists: bytes
|
|
||||||
continuation_state: bytes
|
attribute_lists: bytes = field(metadata=_BYTES_PRECEDED_BY_LENGTH_METADATA)
|
||||||
|
continuation_state: bytes = field(metadata=hci.metadata('*'))
|
||||||
|
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
class Client:
|
class Client:
|
||||||
def __init__(self, connection: Connection, mtu: int = 0) -> None:
|
def __init__(self, connection: Connection, mtu: int = 0) -> None:
|
||||||
self.connection = connection
|
self.connection = connection
|
||||||
self.channel: Optional[l2cap.ClassicChannel] = None
|
self.channel: l2cap.ClassicChannel | None = None
|
||||||
self.mtu = mtu
|
self.mtu = mtu
|
||||||
self.request_semaphore = asyncio.Semaphore(1)
|
self.request_semaphore = asyncio.Semaphore(1)
|
||||||
self.pending_request: Optional[SDP_PDU] = None
|
self.pending_request: SDP_PDU | None = None
|
||||||
self.pending_response: Optional[asyncio.futures.Future[SDP_PDU]] = None
|
self.pending_response: asyncio.futures.Future[SDP_PDU] | None = None
|
||||||
self.next_transaction_id = 0
|
self.next_transaction_id = 0
|
||||||
|
|
||||||
async def connect(self) -> None:
|
async def connect(self) -> None:
|
||||||
@@ -846,7 +824,7 @@ class Client:
|
|||||||
self.pending_request = request
|
self.pending_request = request
|
||||||
|
|
||||||
try:
|
try:
|
||||||
self.channel.send_pdu(bytes(request))
|
self.channel.write(bytes(request))
|
||||||
return await self.pending_response
|
return await self.pending_response
|
||||||
finally:
|
finally:
|
||||||
self.pending_request = None
|
self.pending_request = None
|
||||||
@@ -872,7 +850,7 @@ class Client:
|
|||||||
)
|
)
|
||||||
|
|
||||||
# Request and accumulate until there's no more continuation
|
# Request and accumulate until there's no more continuation
|
||||||
service_record_handle_list = []
|
service_record_handle_list: list[int] = []
|
||||||
continuation_state = bytes([0])
|
continuation_state = bytes([0])
|
||||||
watchdog = SDP_CONTINUATION_WATCHDOG
|
watchdog = SDP_CONTINUATION_WATCHDOG
|
||||||
while watchdog > 0:
|
while watchdog > 0:
|
||||||
@@ -898,7 +876,7 @@ class Client:
|
|||||||
async def search_attributes(
|
async def search_attributes(
|
||||||
self,
|
self,
|
||||||
uuids: Iterable[core.UUID],
|
uuids: Iterable[core.UUID],
|
||||||
attribute_ids: Iterable[Union[int, tuple[int, int]]],
|
attribute_ids: Iterable[int | tuple[int, int]],
|
||||||
) -> list[list[ServiceAttribute]]:
|
) -> list[list[ServiceAttribute]]:
|
||||||
"""
|
"""
|
||||||
Search for attributes by UUID and attribute IDs.
|
Search for attributes by UUID and attribute IDs.
|
||||||
@@ -970,7 +948,7 @@ class Client:
|
|||||||
async def get_attributes(
|
async def get_attributes(
|
||||||
self,
|
self,
|
||||||
service_record_handle: int,
|
service_record_handle: int,
|
||||||
attribute_ids: Iterable[Union[int, tuple[int, int]]],
|
attribute_ids: Iterable[int | tuple[int, int]],
|
||||||
) -> list[ServiceAttribute]:
|
) -> list[ServiceAttribute]:
|
||||||
"""
|
"""
|
||||||
Get attributes for a service.
|
Get attributes for a service.
|
||||||
@@ -1042,10 +1020,10 @@ class Client:
|
|||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
class Server:
|
class Server:
|
||||||
CONTINUATION_STATE = bytes([0x01, 0x00])
|
CONTINUATION_STATE = bytes([0x01, 0x00])
|
||||||
channel: Optional[l2cap.ClassicChannel]
|
channel: l2cap.ClassicChannel | None
|
||||||
Service = NewType('Service', list[ServiceAttribute])
|
Service = NewType('Service', list[ServiceAttribute])
|
||||||
service_records: dict[int, Service]
|
service_records: dict[int, Service]
|
||||||
current_response: Union[None, bytes, tuple[int, list[int]]]
|
current_response: None | bytes | tuple[int, list[int]]
|
||||||
|
|
||||||
def __init__(self, device: Device) -> None:
|
def __init__(self, device: Device) -> None:
|
||||||
self.device = device
|
self.device = device
|
||||||
@@ -1060,7 +1038,7 @@ class Server:
|
|||||||
|
|
||||||
def send_response(self, response):
|
def send_response(self, response):
|
||||||
logger.debug(f'{color(">>> Sending SDP Response", "blue")}: {response}')
|
logger.debug(f'{color(">>> Sending SDP Response", "blue")}: {response}')
|
||||||
self.channel.send_pdu(response)
|
self.channel.write(response)
|
||||||
|
|
||||||
def match_services(self, search_pattern: DataElement) -> dict[int, Service]:
|
def match_services(self, search_pattern: DataElement) -> dict[int, Service]:
|
||||||
# Find the services for which the attributes in the pattern is a subset of the
|
# Find the services for which the attributes in the pattern is a subset of the
|
||||||
@@ -1090,7 +1068,7 @@ class Server:
|
|||||||
logger.exception(color('failed to parse SDP Request PDU', 'red'))
|
logger.exception(color('failed to parse SDP Request PDU', 'red'))
|
||||||
self.send_response(
|
self.send_response(
|
||||||
SDP_ErrorResponse(
|
SDP_ErrorResponse(
|
||||||
transaction_id=0, error_code=SDP_INVALID_REQUEST_SYNTAX_ERROR
|
transaction_id=0, error_code=ErrorCode.INVALID_REQUEST_SYNTAX
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -1107,7 +1085,7 @@ class Server:
|
|||||||
self.send_response(
|
self.send_response(
|
||||||
SDP_ErrorResponse(
|
SDP_ErrorResponse(
|
||||||
transaction_id=sdp_pdu.transaction_id,
|
transaction_id=sdp_pdu.transaction_id,
|
||||||
error_code=SDP_INSUFFICIENT_RESOURCES_TO_SATISFY_REQUEST_ERROR,
|
error_code=ErrorCode.INSUFFICIENT_RESOURCES_TO_SATISFY_REQUEST,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
@@ -1115,7 +1093,7 @@ class Server:
|
|||||||
self.send_response(
|
self.send_response(
|
||||||
SDP_ErrorResponse(
|
SDP_ErrorResponse(
|
||||||
transaction_id=sdp_pdu.transaction_id,
|
transaction_id=sdp_pdu.transaction_id,
|
||||||
error_code=SDP_INVALID_REQUEST_SYNTAX_ERROR,
|
error_code=ErrorCode.INVALID_REQUEST_SYNTAX,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -1123,7 +1101,7 @@ class Server:
|
|||||||
self,
|
self,
|
||||||
continuation_state: bytes,
|
continuation_state: bytes,
|
||||||
transaction_id: int,
|
transaction_id: int,
|
||||||
) -> Optional[bool]:
|
) -> bool | None:
|
||||||
# Check if this is a valid continuation
|
# Check if this is a valid continuation
|
||||||
if len(continuation_state) > 1:
|
if len(continuation_state) > 1:
|
||||||
if (
|
if (
|
||||||
@@ -1133,7 +1111,7 @@ class Server:
|
|||||||
self.send_response(
|
self.send_response(
|
||||||
SDP_ErrorResponse(
|
SDP_ErrorResponse(
|
||||||
transaction_id=transaction_id,
|
transaction_id=transaction_id,
|
||||||
error_code=SDP_INVALID_CONTINUATION_STATE_ERROR,
|
error_code=ErrorCode.INVALID_CONTINUATION_STATE,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
return None
|
return None
|
||||||
@@ -1227,15 +1205,11 @@ class Server:
|
|||||||
if service_record_handles_remaining
|
if service_record_handles_remaining
|
||||||
else bytes([0])
|
else bytes([0])
|
||||||
)
|
)
|
||||||
service_record_handle_list = b''.join(
|
|
||||||
[struct.pack('>I', handle) for handle in service_record_handles]
|
|
||||||
)
|
|
||||||
self.send_response(
|
self.send_response(
|
||||||
SDP_ServiceSearchResponse(
|
SDP_ServiceSearchResponse(
|
||||||
transaction_id=request.transaction_id,
|
transaction_id=request.transaction_id,
|
||||||
total_service_record_count=total_service_record_count,
|
total_service_record_count=total_service_record_count,
|
||||||
current_service_record_count=len(service_record_handles),
|
service_record_handle_list=service_record_handles,
|
||||||
service_record_handle_list=service_record_handle_list,
|
|
||||||
continuation_state=continuation_state,
|
continuation_state=continuation_state,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
@@ -1258,7 +1232,7 @@ class Server:
|
|||||||
self.send_response(
|
self.send_response(
|
||||||
SDP_ErrorResponse(
|
SDP_ErrorResponse(
|
||||||
transaction_id=request.transaction_id,
|
transaction_id=request.transaction_id,
|
||||||
error_code=SDP_INVALID_SERVICE_RECORD_HANDLE_ERROR,
|
error_code=ErrorCode.INVALID_SERVICE_RECORD_HANDLE,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
@@ -1283,7 +1257,6 @@ class Server:
|
|||||||
self.send_response(
|
self.send_response(
|
||||||
SDP_ServiceAttributeResponse(
|
SDP_ServiceAttributeResponse(
|
||||||
transaction_id=request.transaction_id,
|
transaction_id=request.transaction_id,
|
||||||
attribute_list_byte_count=len(attribute_list_response),
|
|
||||||
attribute_list=attribute_list_response,
|
attribute_list=attribute_list_response,
|
||||||
continuation_state=continuation_state,
|
continuation_state=continuation_state,
|
||||||
)
|
)
|
||||||
@@ -1330,7 +1303,6 @@ class Server:
|
|||||||
self.send_response(
|
self.send_response(
|
||||||
SDP_ServiceSearchAttributeResponse(
|
SDP_ServiceSearchAttributeResponse(
|
||||||
transaction_id=request.transaction_id,
|
transaction_id=request.transaction_id,
|
||||||
attribute_lists_byte_count=len(attribute_lists_response),
|
|
||||||
attribute_lists=attribute_lists_response,
|
attribute_lists=attribute_lists_response,
|
||||||
continuation_state=continuation_state,
|
continuation_state=continuation_state,
|
||||||
)
|
)
|
||||||
|
|||||||
590
bumble/smp.py
590
bumble/smp.py
File diff suppressed because it is too large
Load Diff
129
bumble/snoop.py
129
bumble/snoop.py
@@ -16,13 +16,14 @@ import datetime
|
|||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
import struct
|
import struct
|
||||||
|
from collections.abc import Generator
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
# Imports
|
# Imports
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
from contextlib import contextmanager
|
from contextlib import contextmanager
|
||||||
from enum import IntEnum
|
from enum import IntEnum
|
||||||
from typing import BinaryIO, Generator
|
from typing import BinaryIO
|
||||||
|
|
||||||
from bumble import core
|
from bumble import core
|
||||||
from bumble.hci import HCI_COMMAND_PACKET, HCI_EVENT_PACKET
|
from bumble.hci import HCI_COMMAND_PACKET, HCI_EVENT_PACKET
|
||||||
@@ -65,7 +66,7 @@ class BtSnooper(Snooper):
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
IDENTIFICATION_PATTERN = b'btsnoop\0'
|
IDENTIFICATION_PATTERN = b'btsnoop\0'
|
||||||
TIMESTAMP_ANCHOR = datetime.datetime(2000, 1, 1)
|
TIMESTAMP_ANCHOR = datetime.datetime(2000, 1, 1, tzinfo=datetime.timezone.utc)
|
||||||
TIMESTAMP_DELTA = 0x00E03AB44A676000
|
TIMESTAMP_DELTA = 0x00E03AB44A676000
|
||||||
ONE_MS = datetime.timedelta(microseconds=1)
|
ONE_MS = datetime.timedelta(microseconds=1)
|
||||||
|
|
||||||
@@ -85,7 +86,13 @@ class BtSnooper(Snooper):
|
|||||||
|
|
||||||
# Compute the current timestamp
|
# Compute the current timestamp
|
||||||
timestamp = (
|
timestamp = (
|
||||||
int((datetime.datetime.utcnow() - self.TIMESTAMP_ANCHOR) / self.ONE_MS)
|
int(
|
||||||
|
(
|
||||||
|
datetime.datetime.now(tz=datetime.timezone.utc)
|
||||||
|
- self.TIMESTAMP_ANCHOR
|
||||||
|
)
|
||||||
|
/ self.ONE_MS
|
||||||
|
)
|
||||||
+ self.TIMESTAMP_DELTA
|
+ self.TIMESTAMP_DELTA
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -103,6 +110,53 @@ class BtSnooper(Snooper):
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
class PcapSnooper(Snooper):
|
||||||
|
"""
|
||||||
|
Snooper that saves or streames HCI packets using the PCAP format.
|
||||||
|
"""
|
||||||
|
|
||||||
|
PCAP_MAGIC = 0xA1B2C3D4
|
||||||
|
DLT_BLUETOOTH_HCI_H4_WITH_PHDR = 201
|
||||||
|
|
||||||
|
def __init__(self, output: BinaryIO):
|
||||||
|
self.output = output
|
||||||
|
|
||||||
|
# Write the header
|
||||||
|
self.output.write(
|
||||||
|
struct.pack(
|
||||||
|
"<IHHIIII",
|
||||||
|
self.PCAP_MAGIC,
|
||||||
|
2, # Major PCAP Version
|
||||||
|
4, # Minor PCAP Version
|
||||||
|
0, # Reserved 1
|
||||||
|
0, # Reserved 2
|
||||||
|
65535, # SnapLen
|
||||||
|
# FCS and f are set to 0 implicitly by the next line
|
||||||
|
self.DLT_BLUETOOTH_HCI_H4_WITH_PHDR, # The DLT in this PCAP
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
def snoop(self, hci_packet: bytes, direction: Snooper.Direction):
|
||||||
|
now = datetime.datetime.now(datetime.timezone.utc)
|
||||||
|
sec = int(now.timestamp())
|
||||||
|
usec = now.microsecond
|
||||||
|
|
||||||
|
# Emit the record
|
||||||
|
self.output.write(
|
||||||
|
struct.pack(
|
||||||
|
"<IIII",
|
||||||
|
sec, # Timestamp (Seconds)
|
||||||
|
usec, # Timestamp (Microseconds)
|
||||||
|
len(hci_packet) + 4,
|
||||||
|
len(hci_packet) + 4, # +4 because of the addtional direction info...
|
||||||
|
)
|
||||||
|
+ struct.pack(">I", int(direction)) # ...thats being added here
|
||||||
|
+ hci_packet
|
||||||
|
)
|
||||||
|
self.output.flush() # flush after every packet for live logging
|
||||||
|
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
_SNOOPER_INSTANCE_COUNT = 0
|
_SNOOPER_INSTANCE_COUNT = 0
|
||||||
|
|
||||||
@@ -129,13 +183,42 @@ def create_snooper(spec: str) -> Generator[Snooper, None, None]:
|
|||||||
records will be written to that file if it can be opened/created.
|
records will be written to that file if it can be opened/created.
|
||||||
The keyword args that may be referenced by the string pattern are:
|
The keyword args that may be referenced by the string pattern are:
|
||||||
now: the value of `datetime.now()`
|
now: the value of `datetime.now()`
|
||||||
utcnow: the value of `datetime.utcnow()`
|
utcnow: the value of `datetime.now(tz=datetime.timezone.utc)`
|
||||||
|
pid: the current process ID.
|
||||||
|
instance: the instance ID in the current process.
|
||||||
|
|
||||||
|
pcapsnoop
|
||||||
|
The syntax for the type-specific arguments for this type is:
|
||||||
|
<io-type>:<io-type-specific-arguments>
|
||||||
|
|
||||||
|
Supported I/O types are:
|
||||||
|
|
||||||
|
file
|
||||||
|
The type-specific arguments for this I/O type is a string that is converted
|
||||||
|
to a file path using the python `str.format()` string formatting. The log
|
||||||
|
records will be written to that file if it can be opened/created.
|
||||||
|
The keyword args that may be referenced by the string pattern are:
|
||||||
|
now: the value of `datetime.now()`
|
||||||
|
utcnow: the value of `datetime.now(tz=datetime.timezone.utc)`
|
||||||
|
pid: the current process ID.
|
||||||
|
instance: the instance ID in the current process.
|
||||||
|
|
||||||
|
pipe
|
||||||
|
The type-specific arguments for this I/O type is a string that is converted
|
||||||
|
to a path using the python `str.format()` string formatting. The log
|
||||||
|
records will be written to the named pipe referenced by this path
|
||||||
|
if it can be opened. The keyword args that may be referenced by the
|
||||||
|
string pattern are:
|
||||||
|
now: the value of `datetime.now()`
|
||||||
|
utcnow: the value of `datetime.now(tz=datetime.timezone.utc)`
|
||||||
pid: the current process ID.
|
pid: the current process ID.
|
||||||
instance: the instance ID in the current process.
|
instance: the instance ID in the current process.
|
||||||
|
|
||||||
Examples:
|
Examples:
|
||||||
btsnoop:file:my_btsnoop.log
|
btsnoop:file:my_btsnoop.log
|
||||||
btsnoop:file:/tmp/bumble_{now:%Y-%m-%d-%H:%M:%S}_{pid}.log
|
btsnoop:file:/tmp/bumble_{now:%Y-%m-%d-%H:%M:%S}_{pid}.log
|
||||||
|
pcapsnoop:pipe:/tmp/bumble-extcap
|
||||||
|
|
||||||
|
|
||||||
"""
|
"""
|
||||||
if ':' not in spec:
|
if ':' not in spec:
|
||||||
@@ -143,6 +226,8 @@ def create_snooper(spec: str) -> Generator[Snooper, None, None]:
|
|||||||
|
|
||||||
snooper_type, snooper_args = spec.split(':', maxsplit=1)
|
snooper_type, snooper_args = spec.split(':', maxsplit=1)
|
||||||
|
|
||||||
|
global _SNOOPER_INSTANCE_COUNT
|
||||||
|
|
||||||
if snooper_type == 'btsnoop':
|
if snooper_type == 'btsnoop':
|
||||||
if ':' not in snooper_args:
|
if ':' not in snooper_args:
|
||||||
raise core.InvalidArgumentError('I/O type for btsnoop snooper type missing')
|
raise core.InvalidArgumentError('I/O type for btsnoop snooper type missing')
|
||||||
@@ -150,10 +235,9 @@ def create_snooper(spec: str) -> Generator[Snooper, None, None]:
|
|||||||
io_type, io_name = snooper_args.split(':', maxsplit=1)
|
io_type, io_name = snooper_args.split(':', maxsplit=1)
|
||||||
if io_type == 'file':
|
if io_type == 'file':
|
||||||
# Process the file name string pattern.
|
# Process the file name string pattern.
|
||||||
global _SNOOPER_INSTANCE_COUNT
|
|
||||||
file_path = io_name.format(
|
file_path = io_name.format(
|
||||||
now=datetime.datetime.now(),
|
now=datetime.datetime.now(),
|
||||||
utcnow=datetime.datetime.utcnow(),
|
utcnow=datetime.datetime.now(tz=datetime.timezone.utc),
|
||||||
pid=os.getpid(),
|
pid=os.getpid(),
|
||||||
instance=_SNOOPER_INSTANCE_COUNT,
|
instance=_SNOOPER_INSTANCE_COUNT,
|
||||||
)
|
)
|
||||||
@@ -166,6 +250,39 @@ def create_snooper(spec: str) -> Generator[Snooper, None, None]:
|
|||||||
_SNOOPER_INSTANCE_COUNT -= 1
|
_SNOOPER_INSTANCE_COUNT -= 1
|
||||||
return
|
return
|
||||||
|
|
||||||
|
elif snooper_type == 'pcapsnoop':
|
||||||
|
if ':' not in snooper_args:
|
||||||
|
raise core.InvalidArgumentError(
|
||||||
|
'I/O type for pcapsnoop snooper type missing'
|
||||||
|
)
|
||||||
|
|
||||||
|
io_type, io_name = snooper_args.split(':', maxsplit=1)
|
||||||
|
if io_type in {'pipe', 'file'}:
|
||||||
|
# Process the file name string pattern.
|
||||||
|
file_path = io_name.format(
|
||||||
|
now=datetime.datetime.now(),
|
||||||
|
utcnow=datetime.datetime.now(tz=datetime.timezone.utc),
|
||||||
|
pid=os.getpid(),
|
||||||
|
instance=_SNOOPER_INSTANCE_COUNT,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Open a file or pipe
|
||||||
|
logger.debug(f'PCAP file: {file_path}')
|
||||||
|
|
||||||
|
# Pipes we have to open with unbuffered binary I/O
|
||||||
|
# so we pass ``buffering`` for pipes but not for files
|
||||||
|
pcap_file: BinaryIO
|
||||||
|
if io_type == 'pipe':
|
||||||
|
pcap_file = open(file_path, 'wb', buffering=0)
|
||||||
|
else:
|
||||||
|
pcap_file = open(file_path, 'wb')
|
||||||
|
|
||||||
|
with pcap_file:
|
||||||
|
_SNOOPER_INSTANCE_COUNT += 1
|
||||||
|
yield PcapSnooper(pcap_file)
|
||||||
|
_SNOOPER_INSTANCE_COUNT -= 1
|
||||||
|
return
|
||||||
|
|
||||||
raise core.InvalidArgumentError(f'I/O type {io_type} not supported')
|
raise core.InvalidArgumentError(f'I/O type {io_type} not supported')
|
||||||
|
|
||||||
raise core.InvalidArgumentError(f'snooper type {snooper_type} not found')
|
raise core.InvalidArgumentError(f'snooper type {snooper_type} not found')
|
||||||
|
|||||||
@@ -18,7 +18,6 @@
|
|||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
import re
|
import re
|
||||||
from typing import Optional
|
|
||||||
|
|
||||||
from bumble import utils
|
from bumble import utils
|
||||||
from bumble.snoop import create_snooper
|
from bumble.snoop import create_snooper
|
||||||
@@ -84,7 +83,12 @@ async def open_transport(name: str) -> Transport:
|
|||||||
scheme, *tail = name.split(':', 1)
|
scheme, *tail = name.split(':', 1)
|
||||||
spec = tail[0] if tail else None
|
spec = tail[0] if tail else None
|
||||||
metadata = None
|
metadata = None
|
||||||
if spec and (m := re.search(r'\[(\w+=\w+(?:,\w+=\w+)*,?)\]', spec)):
|
# If a spec is provided, check for a metadata section in square brackets.
|
||||||
|
# The regex captures a comma-separated list of key=value pairs (allowing an
|
||||||
|
# optional trailing comma). The key is matched by \w+ and the value by [^,\]]+,
|
||||||
|
# meaning the value may contain any character except a comma or a closing
|
||||||
|
# bracket (']').
|
||||||
|
if spec and (m := re.search(r'\[(\w+=[^,\]]+(?:,\w+=[^,\]]+)*,?)\]', spec)):
|
||||||
metadata_str = m.group(1)
|
metadata_str = m.group(1)
|
||||||
if m.start() == 0:
|
if m.start() == 0:
|
||||||
# <metadata><spec>
|
# <metadata><spec>
|
||||||
@@ -106,7 +110,7 @@ async def open_transport(name: str) -> Transport:
|
|||||||
|
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
async def _open_transport(scheme: str, spec: Optional[str]) -> Transport:
|
async def _open_transport(scheme: str, spec: str | None) -> Transport:
|
||||||
# pylint: disable=import-outside-toplevel
|
# pylint: disable=import-outside-toplevel
|
||||||
# pylint: disable=too-many-return-statements
|
# pylint: disable=too-many-return-statements
|
||||||
|
|
||||||
|
|||||||
@@ -16,7 +16,6 @@
|
|||||||
# Imports
|
# Imports
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
import logging
|
import logging
|
||||||
from typing import Optional, Union
|
|
||||||
|
|
||||||
import grpc.aio
|
import grpc.aio
|
||||||
|
|
||||||
@@ -44,7 +43,7 @@ logger = logging.getLogger(__name__)
|
|||||||
|
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
async def open_android_emulator_transport(spec: Optional[str]) -> Transport:
|
async def open_android_emulator_transport(spec: str | None) -> Transport:
|
||||||
'''
|
'''
|
||||||
Open a transport connection to an Android emulator via its gRPC interface.
|
Open a transport connection to an Android emulator via its gRPC interface.
|
||||||
The parameter string has this syntax:
|
The parameter string has this syntax:
|
||||||
@@ -89,7 +88,7 @@ async def open_android_emulator_transport(spec: Optional[str]) -> Transport:
|
|||||||
logger.debug('connecting to gRPC server at %s', server_address)
|
logger.debug('connecting to gRPC server at %s', server_address)
|
||||||
channel = grpc.aio.insecure_channel(server_address)
|
channel = grpc.aio.insecure_channel(server_address)
|
||||||
|
|
||||||
service: Union[EmulatedBluetoothServiceStub, VhciForwardingServiceStub]
|
service: EmulatedBluetoothServiceStub | VhciForwardingServiceStub
|
||||||
if mode == 'host':
|
if mode == 'host':
|
||||||
# Connect as a host
|
# Connect as a host
|
||||||
service = EmulatedBluetoothServiceStub(channel)
|
service = EmulatedBluetoothServiceStub(channel)
|
||||||
|
|||||||
@@ -22,7 +22,6 @@ import os
|
|||||||
import pathlib
|
import pathlib
|
||||||
import platform
|
import platform
|
||||||
import sys
|
import sys
|
||||||
from typing import Optional
|
|
||||||
|
|
||||||
import grpc.aio
|
import grpc.aio
|
||||||
|
|
||||||
@@ -66,7 +65,7 @@ DEFAULT_VARIANT = ''
|
|||||||
|
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
def get_ini_dir() -> Optional[pathlib.Path]:
|
def get_ini_dir() -> pathlib.Path | None:
|
||||||
if sys.platform == 'darwin':
|
if sys.platform == 'darwin':
|
||||||
if tmpdir := os.getenv('TMPDIR', None):
|
if tmpdir := os.getenv('TMPDIR', None):
|
||||||
return pathlib.Path(tmpdir)
|
return pathlib.Path(tmpdir)
|
||||||
@@ -100,7 +99,7 @@ def find_grpc_port(instance_number: int) -> int:
|
|||||||
ini_file = ini_dir / ini_file_name(instance_number)
|
ini_file = ini_dir / ini_file_name(instance_number)
|
||||||
logger.debug(f'Looking for .ini file at {ini_file}')
|
logger.debug(f'Looking for .ini file at {ini_file}')
|
||||||
if ini_file.is_file():
|
if ini_file.is_file():
|
||||||
with open(ini_file, 'r') as ini_file_data:
|
with open(ini_file) as ini_file_data:
|
||||||
for line in ini_file_data.readlines():
|
for line in ini_file_data.readlines():
|
||||||
if '=' in line:
|
if '=' in line:
|
||||||
key, value = line.split('=')
|
key, value = line.split('=')
|
||||||
@@ -131,7 +130,11 @@ def publish_grpc_port(grpc_port: int, instance_number: int) -> bool:
|
|||||||
|
|
||||||
def cleanup():
|
def cleanup():
|
||||||
logger.debug("removing .ini file")
|
logger.debug("removing .ini file")
|
||||||
ini_file.unlink()
|
try:
|
||||||
|
ini_file.unlink()
|
||||||
|
except OSError as error:
|
||||||
|
# Don't log at exception level, since this may happen normally.
|
||||||
|
logger.debug(f'failed to remove .ini file ({error})')
|
||||||
|
|
||||||
atexit.register(cleanup)
|
atexit.register(cleanup)
|
||||||
return True
|
return True
|
||||||
@@ -142,7 +145,7 @@ def publish_grpc_port(grpc_port: int, instance_number: int) -> bool:
|
|||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
async def open_android_netsim_controller_transport(
|
async def open_android_netsim_controller_transport(
|
||||||
server_host: Optional[str], server_port: int, options: dict[str, str]
|
server_host: str | None, server_port: int, options: dict[str, str]
|
||||||
) -> Transport:
|
) -> Transport:
|
||||||
if server_host == '_' or not server_host:
|
if server_host == '_' or not server_host:
|
||||||
server_host = 'localhost'
|
server_host = 'localhost'
|
||||||
@@ -152,21 +155,26 @@ async def open_android_netsim_controller_transport(
|
|||||||
logger.warning("unable to publish gRPC port")
|
logger.warning("unable to publish gRPC port")
|
||||||
|
|
||||||
class HciDevice:
|
class HciDevice:
|
||||||
def __init__(self, context, on_data_received):
|
def __init__(self, context, server):
|
||||||
self.context = context
|
self.context = context
|
||||||
self.on_data_received = on_data_received
|
self.server = server
|
||||||
self.name = None
|
self.name = None
|
||||||
|
self.sink = None
|
||||||
self.loop = asyncio.get_running_loop()
|
self.loop = asyncio.get_running_loop()
|
||||||
self.done = self.loop.create_future()
|
self.done = self.loop.create_future()
|
||||||
self.task = self.loop.create_task(self.pump())
|
|
||||||
|
|
||||||
async def pump(self):
|
async def pump(self):
|
||||||
try:
|
try:
|
||||||
await self.pump_loop()
|
await self.pump_loop()
|
||||||
except asyncio.CancelledError:
|
except asyncio.CancelledError:
|
||||||
logger.debug('Pump task canceled')
|
logger.debug('Pump task canceled')
|
||||||
if not self.done.done():
|
finally:
|
||||||
self.done.set_result(None)
|
if self.sink:
|
||||||
|
logger.debug('Releasing sink')
|
||||||
|
self.server.release_sink()
|
||||||
|
self.sink = None
|
||||||
|
|
||||||
|
logger.debug('Pump task terminated')
|
||||||
|
|
||||||
async def pump_loop(self):
|
async def pump_loop(self):
|
||||||
while True:
|
while True:
|
||||||
@@ -182,15 +190,26 @@ async def open_android_netsim_controller_transport(
|
|||||||
if request.WhichOneof('request_type') == 'initial_info':
|
if request.WhichOneof('request_type') == 'initial_info':
|
||||||
logger.debug(f'Received initial info: {request}')
|
logger.debug(f'Received initial info: {request}')
|
||||||
|
|
||||||
|
self.name = request.initial_info.name
|
||||||
|
|
||||||
# We only accept BLUETOOTH
|
# We only accept BLUETOOTH
|
||||||
if request.initial_info.chip.kind != ChipKind.BLUETOOTH:
|
if request.initial_info.chip.kind != ChipKind.BLUETOOTH:
|
||||||
logger.warning('Unsupported chip type')
|
logger.debug('Request for unsupported chip type')
|
||||||
error = PacketResponse(error='Unsupported chip type')
|
error = PacketResponse(error='Unsupported chip type')
|
||||||
await self.context.write(error)
|
await self.context.write(error)
|
||||||
return
|
# return
|
||||||
|
continue
|
||||||
|
|
||||||
self.name = request.initial_info.name
|
# Lease the sink so that no other device can send
|
||||||
continue
|
self.sink = self.server.lease_sink(self)
|
||||||
|
if self.sink is None:
|
||||||
|
logger.warning('Another device is already connected')
|
||||||
|
error = PacketResponse(error='Device busy')
|
||||||
|
await self.context.write(error)
|
||||||
|
# return
|
||||||
|
continue
|
||||||
|
|
||||||
|
continue
|
||||||
|
|
||||||
# Expect a data packet
|
# Expect a data packet
|
||||||
request_type = request.WhichOneof('request_type')
|
request_type = request.WhichOneof('request_type')
|
||||||
@@ -201,10 +220,10 @@ async def open_android_netsim_controller_transport(
|
|||||||
continue
|
continue
|
||||||
|
|
||||||
# Process the packet
|
# Process the packet
|
||||||
data = (
|
assert self.sink is not None
|
||||||
|
self.sink(
|
||||||
bytes([request.hci_packet.packet_type]) + request.hci_packet.packet
|
bytes([request.hci_packet.packet_type]) + request.hci_packet.packet
|
||||||
)
|
)
|
||||||
self.on_data_received(data)
|
|
||||||
|
|
||||||
async def send_packet(self, data):
|
async def send_packet(self, data):
|
||||||
return await self.context.write(
|
return await self.context.write(
|
||||||
@@ -213,12 +232,6 @@ async def open_android_netsim_controller_transport(
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
def terminate(self):
|
|
||||||
self.task.cancel()
|
|
||||||
|
|
||||||
async def wait_for_termination(self):
|
|
||||||
await self.done
|
|
||||||
|
|
||||||
server_address = f'{server_host}:{server_port}'
|
server_address = f'{server_host}:{server_port}'
|
||||||
|
|
||||||
class Server(PacketStreamerServicer, ParserSource):
|
class Server(PacketStreamerServicer, ParserSource):
|
||||||
@@ -254,27 +267,27 @@ async def open_android_netsim_controller_transport(
|
|||||||
|
|
||||||
return await self.device.send_packet(packet)
|
return await self.device.send_packet(packet)
|
||||||
|
|
||||||
async def StreamPackets(self, _request_iterator, context):
|
def lease_sink(self, device):
|
||||||
|
if self.device:
|
||||||
|
return None
|
||||||
|
self.device = device
|
||||||
|
return self.parser.feed_data
|
||||||
|
|
||||||
|
def release_sink(self):
|
||||||
|
self.device = None
|
||||||
|
|
||||||
|
async def StreamPackets(self, request_iterator, context):
|
||||||
logger.debug('StreamPackets request')
|
logger.debug('StreamPackets request')
|
||||||
|
|
||||||
# Check that we don't already have a device
|
|
||||||
if self.device:
|
|
||||||
logger.debug('Busy, already serving a device')
|
|
||||||
return PacketResponse(error='Busy')
|
|
||||||
|
|
||||||
# Instantiate a new device
|
# Instantiate a new device
|
||||||
self.device = HciDevice(context, self.parser.feed_data)
|
device = HciDevice(context, self)
|
||||||
|
|
||||||
# Wait for the device to terminate
|
# Pump packets to/from the device
|
||||||
logger.debug('Waiting for device to terminate')
|
logger.debug('Pumping device packets')
|
||||||
try:
|
try:
|
||||||
await self.device.wait_for_termination()
|
await device.pump()
|
||||||
except asyncio.CancelledError:
|
finally:
|
||||||
logger.debug('Request canceled')
|
logger.debug('Pump terminated')
|
||||||
self.device.terminate()
|
|
||||||
|
|
||||||
logger.debug('Device terminated')
|
|
||||||
self.device = None
|
|
||||||
|
|
||||||
server = Server()
|
server = Server()
|
||||||
await server.start()
|
await server.start()
|
||||||
@@ -287,9 +300,9 @@ async def open_android_netsim_controller_transport(
|
|||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
async def open_android_netsim_host_transport_with_address(
|
async def open_android_netsim_host_transport_with_address(
|
||||||
server_host: Optional[str],
|
server_host: str | None,
|
||||||
server_port: int,
|
server_port: int,
|
||||||
options: Optional[dict[str, str]] = None,
|
options: dict[str, str] | None = None,
|
||||||
):
|
):
|
||||||
if server_host == '_' or not server_host:
|
if server_host == '_' or not server_host:
|
||||||
server_host = 'localhost'
|
server_host = 'localhost'
|
||||||
@@ -314,7 +327,7 @@ async def open_android_netsim_host_transport_with_address(
|
|||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
async def open_android_netsim_host_transport_with_channel(
|
async def open_android_netsim_host_transport_with_channel(
|
||||||
channel, options: Optional[dict[str, str]] = None
|
channel, options: dict[str, str] | None = None
|
||||||
):
|
):
|
||||||
# Wrapper for I/O operations
|
# Wrapper for I/O operations
|
||||||
class HciDevice:
|
class HciDevice:
|
||||||
@@ -394,7 +407,7 @@ async def open_android_netsim_host_transport_with_channel(
|
|||||||
|
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
async def open_android_netsim_transport(spec: Optional[str]) -> Transport:
|
async def open_android_netsim_transport(spec: str | None) -> Transport:
|
||||||
'''
|
'''
|
||||||
Open a transport connection as a client or server, implementing Android's `netsim`
|
Open a transport connection as a client or server, implementing Android's `netsim`
|
||||||
simulator protocol over gRPC.
|
simulator protocol over gRPC.
|
||||||
|
|||||||
@@ -22,7 +22,8 @@ import contextlib
|
|||||||
import io
|
import io
|
||||||
import logging
|
import logging
|
||||||
import struct
|
import struct
|
||||||
from typing import Any, ContextManager, Optional, Protocol
|
from collections.abc import Awaitable, Callable
|
||||||
|
from typing import Any, Protocol
|
||||||
|
|
||||||
from bumble import core, hci
|
from bumble import core, hci
|
||||||
from bumble.colors import color
|
from bumble.colors import color
|
||||||
@@ -106,11 +107,11 @@ class PacketParser:
|
|||||||
NEED_LENGTH = 1
|
NEED_LENGTH = 1
|
||||||
NEED_BODY = 2
|
NEED_BODY = 2
|
||||||
|
|
||||||
sink: Optional[TransportSink]
|
sink: TransportSink | None
|
||||||
extended_packet_info: dict[int, tuple[int, int, str]]
|
extended_packet_info: dict[int, tuple[int, int, str]]
|
||||||
packet_info: Optional[tuple[int, int, str]] = None
|
packet_info: tuple[int, int, str] | None = None
|
||||||
|
|
||||||
def __init__(self, sink: Optional[TransportSink] = None) -> None:
|
def __init__(self, sink: TransportSink | None = None) -> None:
|
||||||
self.sink = sink
|
self.sink = sink
|
||||||
self.extended_packet_info = {}
|
self.extended_packet_info = {}
|
||||||
self.reset()
|
self.reset()
|
||||||
@@ -175,7 +176,7 @@ class PacketReader:
|
|||||||
self.source = source
|
self.source = source
|
||||||
self.at_end = False
|
self.at_end = False
|
||||||
|
|
||||||
def next_packet(self) -> Optional[bytes]:
|
def next_packet(self) -> bytes | None:
|
||||||
# Get the packet type
|
# Get the packet type
|
||||||
packet_type = self.source.read(1)
|
packet_type = self.source.read(1)
|
||||||
if len(packet_type) != 1:
|
if len(packet_type) != 1:
|
||||||
@@ -252,7 +253,7 @@ class BaseSource:
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
terminated: asyncio.Future[None]
|
terminated: asyncio.Future[None]
|
||||||
sink: Optional[TransportSink]
|
sink: TransportSink | None
|
||||||
|
|
||||||
def __init__(self) -> None:
|
def __init__(self) -> None:
|
||||||
self.terminated = asyncio.get_running_loop().create_future()
|
self.terminated = asyncio.get_running_loop().create_future()
|
||||||
@@ -356,7 +357,7 @@ class Transport:
|
|||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
class PumpedPacketSource(ParserSource):
|
class PumpedPacketSource(ParserSource):
|
||||||
pump_task: Optional[asyncio.Task[None]]
|
pump_task: asyncio.Task[None] | None
|
||||||
|
|
||||||
def __init__(self, receive) -> None:
|
def __init__(self, receive) -> None:
|
||||||
super().__init__()
|
super().__init__()
|
||||||
@@ -389,15 +390,17 @@ class PumpedPacketSource(ParserSource):
|
|||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
class PumpedPacketSink:
|
class PumpedPacketSink:
|
||||||
def __init__(self, send):
|
pump_task: asyncio.Task[None] | None
|
||||||
|
|
||||||
|
def __init__(self, send: Callable[[bytes], Awaitable[Any]]):
|
||||||
self.send_function = send
|
self.send_function = send
|
||||||
self.packet_queue = asyncio.Queue()
|
self.packet_queue = asyncio.Queue[bytes]()
|
||||||
self.pump_task = None
|
self.pump_task = None
|
||||||
|
|
||||||
def on_packet(self, packet: bytes) -> None:
|
def on_packet(self, packet: bytes) -> None:
|
||||||
self.packet_queue.put_nowait(packet)
|
self.packet_queue.put_nowait(packet)
|
||||||
|
|
||||||
def start(self):
|
def start(self) -> None:
|
||||||
async def pump_packets():
|
async def pump_packets():
|
||||||
while True:
|
while True:
|
||||||
try:
|
try:
|
||||||
@@ -440,7 +443,7 @@ class SnoopingTransport(Transport):
|
|||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def create_with(
|
def create_with(
|
||||||
transport: Transport, snooper: ContextManager[Snooper]
|
transport: Transport, snooper: contextlib.AbstractContextManager[Snooper]
|
||||||
) -> SnoopingTransport:
|
) -> SnoopingTransport:
|
||||||
"""
|
"""
|
||||||
Create an instance given a snooper that works as as context manager.
|
Create an instance given a snooper that works as as context manager.
|
||||||
|
|||||||
@@ -16,7 +16,6 @@
|
|||||||
# Imports
|
# Imports
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
import asyncio
|
import asyncio
|
||||||
import io
|
|
||||||
import logging
|
import logging
|
||||||
|
|
||||||
from bumble.transport.common import StreamPacketSink, StreamPacketSource, Transport
|
from bumble.transport.common import StreamPacketSink, StreamPacketSource, Transport
|
||||||
@@ -36,7 +35,7 @@ async def open_file_transport(spec: str) -> Transport:
|
|||||||
'''
|
'''
|
||||||
|
|
||||||
# Open the file
|
# Open the file
|
||||||
file = io.open(spec, 'r+b', buffering=0)
|
file = open(spec, 'r+b', buffering=0)
|
||||||
|
|
||||||
# Setup reading
|
# Setup reading
|
||||||
read_transport, packet_source = await asyncio.get_running_loop().connect_read_pipe(
|
read_transport, packet_source = await asyncio.get_running_loop().connect_read_pipe(
|
||||||
|
|||||||
@@ -22,7 +22,6 @@ import logging
|
|||||||
import os
|
import os
|
||||||
import socket
|
import socket
|
||||||
import struct
|
import struct
|
||||||
from typing import Optional
|
|
||||||
|
|
||||||
from bumble.transport.common import ParserSource, Transport
|
from bumble.transport.common import ParserSource, Transport
|
||||||
|
|
||||||
@@ -33,7 +32,7 @@ logger = logging.getLogger(__name__)
|
|||||||
|
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
async def open_hci_socket_transport(spec: Optional[str]) -> Transport:
|
async def open_hci_socket_transport(spec: str | None) -> Transport:
|
||||||
'''
|
'''
|
||||||
Open an HCI Socket (only available on some platforms).
|
Open an HCI Socket (only available on some platforms).
|
||||||
The parameter string is either empty (to use the first/default Bluetooth adapter)
|
The parameter string is either empty (to use the first/default Bluetooth adapter)
|
||||||
@@ -87,7 +86,7 @@ async def open_hci_socket_transport(spec: Optional[str]) -> Transport:
|
|||||||
)
|
)
|
||||||
!= 0
|
!= 0
|
||||||
):
|
):
|
||||||
raise IOError(ctypes.get_errno(), os.strerror(ctypes.get_errno()))
|
raise OSError(ctypes.get_errno(), os.strerror(ctypes.get_errno()))
|
||||||
|
|
||||||
class HciSocketSource(ParserSource):
|
class HciSocketSource(ParserSource):
|
||||||
def __init__(self, hci_socket):
|
def __init__(self, hci_socket):
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user