mirror of
https://github.com/google/bumble.git
synced 2026-05-06 03:38:01 +00:00
Compare commits
324 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 | ||
|
|
768bbd95cc | ||
|
|
502b80af0d | ||
|
|
a25427305c | ||
|
|
3c47739029 | ||
|
|
8fc1330948 | ||
|
|
8a5f6a61d5 | ||
|
|
83c5061700 | ||
|
|
b80b790dc1 | ||
|
|
21bf69592c | ||
|
|
7d8addb849 | ||
|
|
d86d69d816 | ||
|
|
bb08a1c70b | ||
|
|
dc93f32a9a | ||
|
|
9838908a26 | ||
|
|
613519f0b3 | ||
|
|
a943ea57ef | ||
|
|
14401910bb | ||
|
|
5d35ed471c | ||
|
|
c720ad5fdc | ||
|
|
f02183f95d | ||
|
|
d903937a51 | ||
|
|
6381ee0ab1 | ||
|
|
59d99780e1 | ||
|
|
4bf0bc03af | ||
|
|
91ba2f61f1 | ||
|
|
116dc9b319 | ||
|
|
9f3d8c9b49 | ||
|
|
31961febe5 | ||
|
|
dab0993cba | ||
|
|
6f73b736d7 | ||
|
|
6091e6365d | ||
|
|
3333ba472b | ||
|
|
8bda7d2212 | ||
|
|
7aba36302a | ||
|
|
ceefe8b2a5 | ||
|
|
cd37027795 | ||
|
|
bb2aa8229d | ||
|
|
4aed53c48d | ||
|
|
4a88e9a0cf | ||
|
|
3b8dd6f3cf | ||
|
|
f41b7746d2 | ||
|
|
1b727741bf | ||
|
|
d2bc8175fb | ||
|
|
84dfff290a | ||
|
|
17563e423a | ||
|
|
19d3616032 | ||
|
|
4a48309643 | ||
|
|
870217acb3 | ||
|
|
f8077d7996 | ||
|
|
739907fa31 | ||
|
|
a275c399a3 | ||
|
|
c98275f385 | ||
|
|
0b19347bef | ||
|
|
f61fd64c0b | ||
|
|
ec12771be6 | ||
|
|
5b33e715da | ||
|
|
b885f29318 | ||
|
|
7ca13188d5 | ||
|
|
89586d5d18 | ||
|
|
381032ceb9 | ||
|
|
12ca1c01f0 | ||
|
|
a7111d0107 | ||
|
|
c034297bc0 | ||
|
|
a1eff958e6 | ||
|
|
d6282a7247 | ||
|
|
efdc770fde | ||
|
|
357d7f9c22 | ||
|
|
3bc08b4e0d | ||
|
|
982aaeabc3 | ||
|
|
1dc0950177 | ||
|
|
df0fd74533 | ||
|
|
822f97fa84 | ||
|
|
4a6b0ef840 | ||
|
|
a6ead0147e | ||
|
|
0665e9ca5c | ||
|
|
b8b78ca1ee | ||
|
|
d611d25802 | ||
|
|
bf8a2cdcb5 | ||
|
|
4bf7448a01 |
8
.github/workflows/code-check.yml
vendored
8
.github/workflows/code-check.yml
vendored
@@ -6,6 +6,8 @@ on:
|
|||||||
branches: [ main ]
|
branches: [ main ]
|
||||||
pull_request:
|
pull_request:
|
||||||
branches: [ main ]
|
branches: [ main ]
|
||||||
|
workflow_dispatch:
|
||||||
|
branches: [main]
|
||||||
|
|
||||||
permissions:
|
permissions:
|
||||||
contents: read
|
contents: read
|
||||||
@@ -16,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
|
||||||
|
|||||||
4
.github/workflows/codeql-analysis.yml
vendored
4
.github/workflows/codeql-analysis.yml
vendored
@@ -17,6 +17,8 @@ on:
|
|||||||
pull_request:
|
pull_request:
|
||||||
# The branches below must be a subset of the branches above
|
# The branches below must be a subset of the branches above
|
||||||
branches: [ main ]
|
branches: [ main ]
|
||||||
|
workflow_dispatch:
|
||||||
|
branches: [main]
|
||||||
schedule:
|
schedule:
|
||||||
- cron: '39 21 * * 4'
|
- cron: '39 21 * * 4'
|
||||||
|
|
||||||
@@ -38,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
|
||||||
|
|||||||
8
.github/workflows/gradle-btbench.yml
vendored
8
.github/workflows/gradle-btbench.yml
vendored
@@ -7,6 +7,10 @@ on:
|
|||||||
branches: [ main ]
|
branches: [ main ]
|
||||||
paths:
|
paths:
|
||||||
- 'extras/android/BtBench/**'
|
- 'extras/android/BtBench/**'
|
||||||
|
workflow_dispatch:
|
||||||
|
branches: [main]
|
||||||
|
paths:
|
||||||
|
- 'extras/android/BtBench/**'
|
||||||
|
|
||||||
permissions:
|
permissions:
|
||||||
contents: read
|
contents: read
|
||||||
@@ -18,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
|
||||||
|
|||||||
8
.github/workflows/python-avatar.yml
vendored
8
.github/workflows/python-avatar.yml
vendored
@@ -5,6 +5,8 @@ on:
|
|||||||
branches: [ main ]
|
branches: [ main ]
|
||||||
pull_request:
|
pull_request:
|
||||||
branches: [ main ]
|
branches: [ main ]
|
||||||
|
workflow_dispatch:
|
||||||
|
branches: [main]
|
||||||
|
|
||||||
permissions:
|
permissions:
|
||||||
contents: read
|
contents: read
|
||||||
@@ -24,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
|
||||||
@@ -44,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/
|
||||||
|
|||||||
21
.github/workflows/python-build-test.yml
vendored
21
.github/workflows/python-build-test.yml
vendored
@@ -6,6 +6,8 @@ on:
|
|||||||
branches: [ main ]
|
branches: [ main ]
|
||||||
pull_request:
|
pull_request:
|
||||||
branches: [ main ]
|
branches: [ main ]
|
||||||
|
workflow_dispatch:
|
||||||
|
branches: [main]
|
||||||
|
|
||||||
permissions:
|
permissions:
|
||||||
contents: read
|
contents: read
|
||||||
@@ -16,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
|
||||||
@@ -46,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.76.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
|
||||||
@@ -66,11 +69,11 @@ 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
|
||||||
run: cd rust && cargo build --all-targets && cargo build-all-features --all-targets
|
run: cd rust && cargo build --all-targets && cargo build-all-features
|
||||||
# Lints after build so what clippy needs is already built
|
# Lints after build so what clippy needs is already built
|
||||||
- name: Rust Lints
|
- name: Rust Lints
|
||||||
run: cd rust && cargo fmt --check && cargo clippy --all-targets -- --deny warnings && cargo clippy --all-features --all-targets -- --deny warnings
|
run: cd rust && cargo fmt --check && cargo clippy --all-targets -- --deny warnings && cargo clippy --all-features --all-targets -- --deny warnings
|
||||||
|
|||||||
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
|
||||||
|
._*
|
||||||
|
|||||||
7
.vscode/settings.json
vendored
7
.vscode/settings.json
vendored
@@ -102,5 +102,10 @@
|
|||||||
"."
|
"."
|
||||||
],
|
],
|
||||||
"python.testing.unittestEnabled": false,
|
"python.testing.unittestEnabled": false,
|
||||||
"python.testing.pytestEnabled": true
|
"python.testing.pytestEnabled": true,
|
||||||
|
"python-envs.defaultEnvManager": "ms-python.python:system",
|
||||||
|
"python-envs.pythonProjects": [],
|
||||||
|
"nrf-connect.applications": [
|
||||||
|
"${workspaceFolder}/extras/zephyr/hci_usb"
|
||||||
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
1288
apps/auracast.py
1288
apps/auracast.py
File diff suppressed because it is too large
Load Diff
@@ -19,24 +19,25 @@ import asyncio
|
|||||||
import dataclasses
|
import dataclasses
|
||||||
import enum
|
import enum
|
||||||
import logging
|
import logging
|
||||||
import os
|
|
||||||
import statistics
|
import statistics
|
||||||
import struct
|
import struct
|
||||||
import time
|
import time
|
||||||
from typing import Optional
|
|
||||||
|
|
||||||
import click
|
import click
|
||||||
|
|
||||||
|
import bumble.core
|
||||||
|
import bumble.logging
|
||||||
|
import bumble.rfcomm
|
||||||
from bumble import l2cap
|
from bumble import l2cap
|
||||||
|
from bumble.colors import color
|
||||||
from bumble.core import (
|
from bumble.core import (
|
||||||
PhysicalTransport,
|
|
||||||
BT_L2CAP_PROTOCOL_ID,
|
BT_L2CAP_PROTOCOL_ID,
|
||||||
BT_RFCOMM_PROTOCOL_ID,
|
BT_RFCOMM_PROTOCOL_ID,
|
||||||
UUID,
|
UUID,
|
||||||
CommandTimeoutError,
|
CommandTimeoutError,
|
||||||
|
ConnectionPHY,
|
||||||
|
PhysicalTransport,
|
||||||
)
|
)
|
||||||
from bumble.colors import color
|
|
||||||
from bumble.core import ConnectionPHY
|
|
||||||
from bumble.device import (
|
from bumble.device import (
|
||||||
CigParameters,
|
CigParameters,
|
||||||
CisLink,
|
CisLink,
|
||||||
@@ -50,12 +51,13 @@ from bumble.hci import (
|
|||||||
HCI_LE_1M_PHY,
|
HCI_LE_1M_PHY,
|
||||||
HCI_LE_2M_PHY,
|
HCI_LE_2M_PHY,
|
||||||
HCI_LE_CODED_PHY,
|
HCI_LE_CODED_PHY,
|
||||||
Role,
|
|
||||||
HCI_Constant,
|
HCI_Constant,
|
||||||
HCI_Error,
|
HCI_Error,
|
||||||
HCI_StatusError,
|
|
||||||
HCI_IsoDataPacket,
|
HCI_IsoDataPacket,
|
||||||
|
HCI_StatusError,
|
||||||
|
Role,
|
||||||
)
|
)
|
||||||
|
from bumble.pairing import PairingConfig
|
||||||
from bumble.sdp import (
|
from bumble.sdp import (
|
||||||
SDP_BROWSE_GROUP_LIST_ATTRIBUTE_ID,
|
SDP_BROWSE_GROUP_LIST_ATTRIBUTE_ID,
|
||||||
SDP_PROTOCOL_DESCRIPTOR_LIST_ATTRIBUTE_ID,
|
SDP_PROTOCOL_DESCRIPTOR_LIST_ATTRIBUTE_ID,
|
||||||
@@ -66,11 +68,7 @@ from bumble.sdp import (
|
|||||||
ServiceAttribute,
|
ServiceAttribute,
|
||||||
)
|
)
|
||||||
from bumble.transport import open_transport
|
from bumble.transport import open_transport
|
||||||
import bumble.rfcomm
|
|
||||||
import bumble.core
|
|
||||||
from bumble.utils import AsyncRunner
|
from bumble.utils import AsyncRunner
|
||||||
from bumble.pairing import PairingConfig
|
|
||||||
|
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
# Logging
|
# Logging
|
||||||
@@ -258,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:
|
||||||
@@ -1301,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
|
||||||
@@ -1342,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(
|
||||||
@@ -2321,11 +2319,7 @@ def peripheral(ctx, transport):
|
|||||||
|
|
||||||
|
|
||||||
def main():
|
def main():
|
||||||
logging.basicConfig(
|
bumble.logging.setup_basic_logging('INFO')
|
||||||
level=os.environ.get('BUMBLE_LOGLEVEL', 'INFO').upper(),
|
|
||||||
format="[%(asctime)s.%(msecs)03d] %(levelname)s:%(name)s:%(message)s",
|
|
||||||
datefmt="%H:%M:%S",
|
|
||||||
)
|
|
||||||
bench()
|
bench()
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -13,6 +13,7 @@
|
|||||||
# limitations under the License.
|
# limitations under the License.
|
||||||
|
|
||||||
import click
|
import click
|
||||||
|
|
||||||
from bumble.colors import color
|
from bumble.colors import color
|
||||||
from bumble.hci import Address
|
from bumble.hci import Address
|
||||||
from bumble.helpers import generate_irk, verify_rpa_with_irk
|
from bumble.helpers import generate_irk, verify_rpa_with_irk
|
||||||
|
|||||||
@@ -23,58 +23,54 @@ import asyncio
|
|||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
import re
|
import re
|
||||||
import humanize
|
|
||||||
from typing import Optional, Union
|
|
||||||
from collections import OrderedDict
|
from collections import OrderedDict
|
||||||
|
|
||||||
import click
|
import click
|
||||||
|
import humanize
|
||||||
from prettytable import PrettyTable
|
from prettytable import PrettyTable
|
||||||
|
|
||||||
from prompt_toolkit import Application
|
from prompt_toolkit import Application
|
||||||
from prompt_toolkit.history import FileHistory
|
|
||||||
from prompt_toolkit.completion import Completer, Completion, NestedCompleter
|
from prompt_toolkit.completion import Completer, Completion, NestedCompleter
|
||||||
from prompt_toolkit.key_binding import KeyBindings
|
|
||||||
from prompt_toolkit.formatted_text import ANSI
|
|
||||||
from prompt_toolkit.styles import Style
|
|
||||||
from prompt_toolkit.filters import Condition
|
|
||||||
from prompt_toolkit.widgets import TextArea, Frame
|
|
||||||
from prompt_toolkit.widgets.toolbars import FormattedTextToolbar
|
|
||||||
from prompt_toolkit.data_structures import Point
|
from prompt_toolkit.data_structures import Point
|
||||||
|
from prompt_toolkit.filters import Condition
|
||||||
|
from prompt_toolkit.formatted_text import ANSI
|
||||||
|
from prompt_toolkit.history import FileHistory
|
||||||
|
from prompt_toolkit.key_binding import KeyBindings
|
||||||
from prompt_toolkit.layout import (
|
from prompt_toolkit.layout import (
|
||||||
Layout,
|
|
||||||
HSplit,
|
|
||||||
Window,
|
|
||||||
CompletionsMenu,
|
CompletionsMenu,
|
||||||
Float,
|
|
||||||
FormattedTextControl,
|
|
||||||
FloatContainer,
|
|
||||||
ConditionalContainer,
|
ConditionalContainer,
|
||||||
Dimension,
|
Dimension,
|
||||||
|
Float,
|
||||||
|
FloatContainer,
|
||||||
|
FormattedTextControl,
|
||||||
|
HSplit,
|
||||||
|
Layout,
|
||||||
|
Window,
|
||||||
)
|
)
|
||||||
|
from prompt_toolkit.styles import Style
|
||||||
|
from prompt_toolkit.widgets import Frame, TextArea
|
||||||
|
from prompt_toolkit.widgets.toolbars import FormattedTextToolbar
|
||||||
|
|
||||||
from bumble import __version__
|
|
||||||
import bumble.core
|
import bumble.core
|
||||||
from bumble import colors
|
from bumble import __version__, colors
|
||||||
from bumble.core import UUID, AdvertisingData
|
from bumble.core import UUID, AdvertisingData
|
||||||
from bumble.device import (
|
from bumble.device import (
|
||||||
|
Connection,
|
||||||
ConnectionParametersPreferences,
|
ConnectionParametersPreferences,
|
||||||
ConnectionPHY,
|
ConnectionPHY,
|
||||||
Device,
|
Device,
|
||||||
Connection,
|
|
||||||
Peer,
|
Peer,
|
||||||
)
|
)
|
||||||
from bumble.utils import AsyncRunner
|
from bumble.gatt import Characteristic, CharacteristicDeclaration, Descriptor, Service
|
||||||
from bumble.transport import open_transport
|
|
||||||
from bumble.gatt import Characteristic, Service, CharacteristicDeclaration, Descriptor
|
|
||||||
from bumble.gatt_client import CharacteristicProxy
|
from bumble.gatt_client import CharacteristicProxy
|
||||||
from bumble.hci import (
|
from bumble.hci import (
|
||||||
Address,
|
|
||||||
HCI_Constant,
|
|
||||||
HCI_LE_1M_PHY,
|
HCI_LE_1M_PHY,
|
||||||
HCI_LE_2M_PHY,
|
HCI_LE_2M_PHY,
|
||||||
HCI_LE_CODED_PHY,
|
HCI_LE_CODED_PHY,
|
||||||
|
Address,
|
||||||
|
HCI_Constant,
|
||||||
)
|
)
|
||||||
|
from bumble.transport import open_transport
|
||||||
|
from bumble.utils import AsyncRunner
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
# Constants
|
# Constants
|
||||||
@@ -129,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()
|
||||||
@@ -523,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('.')
|
||||||
@@ -545,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])
|
||||||
@@ -1099,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(
|
||||||
|
|||||||
@@ -16,130 +16,118 @@
|
|||||||
# Imports
|
# Imports
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
import asyncio
|
import asyncio
|
||||||
import os
|
|
||||||
import logging
|
|
||||||
import time
|
import time
|
||||||
|
|
||||||
import click
|
import click
|
||||||
|
|
||||||
from bumble.company_ids import COMPANY_IDENTIFIERS
|
import bumble.logging
|
||||||
from bumble.colors import color
|
from bumble.colors import color
|
||||||
|
from bumble.company_ids import COMPANY_IDENTIFIERS
|
||||||
from bumble.core import name_or_number
|
from bumble.core import name_or_number
|
||||||
from bumble.hci import (
|
from bumble.hci import (
|
||||||
map_null_terminated_utf8_string,
|
|
||||||
CodecID,
|
|
||||||
LeFeature,
|
|
||||||
HCI_SUCCESS,
|
|
||||||
HCI_VERSION_NAMES,
|
|
||||||
LMP_VERSION_NAMES,
|
|
||||||
HCI_Command,
|
|
||||||
HCI_Command_Complete_Event,
|
|
||||||
HCI_Command_Status_Event,
|
|
||||||
HCI_READ_BUFFER_SIZE_COMMAND,
|
|
||||||
HCI_Read_Buffer_Size_Command,
|
|
||||||
HCI_LE_READ_BUFFER_SIZE_V2_COMMAND,
|
|
||||||
HCI_LE_Read_Buffer_Size_V2_Command,
|
|
||||||
HCI_READ_BD_ADDR_COMMAND,
|
|
||||||
HCI_Read_BD_ADDR_Command,
|
|
||||||
HCI_READ_LOCAL_NAME_COMMAND,
|
|
||||||
HCI_Read_Local_Name_Command,
|
|
||||||
HCI_LE_READ_BUFFER_SIZE_COMMAND,
|
HCI_LE_READ_BUFFER_SIZE_COMMAND,
|
||||||
HCI_LE_Read_Buffer_Size_Command,
|
HCI_LE_READ_BUFFER_SIZE_V2_COMMAND,
|
||||||
HCI_LE_READ_MAXIMUM_DATA_LENGTH_COMMAND,
|
HCI_LE_READ_MAXIMUM_DATA_LENGTH_COMMAND,
|
||||||
HCI_LE_Read_Maximum_Data_Length_Command,
|
HCI_LE_READ_MINIMUM_SUPPORTED_CONNECTION_INTERVAL_COMMAND,
|
||||||
HCI_LE_READ_NUMBER_OF_SUPPORTED_ADVERTISING_SETS_COMMAND,
|
|
||||||
HCI_LE_Read_Number_Of_Supported_Advertising_Sets_Command,
|
|
||||||
HCI_LE_READ_MAXIMUM_ADVERTISING_DATA_LENGTH_COMMAND,
|
|
||||||
HCI_LE_Read_Maximum_Advertising_Data_Length_Command,
|
|
||||||
HCI_LE_READ_SUGGESTED_DEFAULT_DATA_LENGTH_COMMAND,
|
HCI_LE_READ_SUGGESTED_DEFAULT_DATA_LENGTH_COMMAND,
|
||||||
|
HCI_READ_BD_ADDR_COMMAND,
|
||||||
|
HCI_READ_BUFFER_SIZE_COMMAND,
|
||||||
|
HCI_READ_LOCAL_NAME_COMMAND,
|
||||||
|
HCI_Command,
|
||||||
|
HCI_LE_Read_Buffer_Size_Command,
|
||||||
|
HCI_LE_Read_Buffer_Size_V2_Command,
|
||||||
|
HCI_LE_Read_Maximum_Data_Length_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_Buffer_Size_Command,
|
||||||
|
HCI_Read_Local_Name_Command,
|
||||||
HCI_Read_Local_Supported_Codecs_Command,
|
HCI_Read_Local_Supported_Codecs_Command,
|
||||||
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,
|
||||||
|
SpecificationVersion,
|
||||||
|
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',
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -153,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}',
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -192,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')
|
||||||
|
|
||||||
|
|
||||||
@@ -276,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',
|
||||||
@@ -290,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)
|
||||||
@@ -342,11 +322,7 @@ async def async_main(
|
|||||||
)
|
)
|
||||||
@click.argument('transport')
|
@click.argument('transport')
|
||||||
def main(latency_probes, latency_probe_interval, latency_probe_command, transport):
|
def main(latency_probes, latency_probe_interval, latency_probe_command, transport):
|
||||||
logging.basicConfig(
|
bumble.logging.setup_basic_logging()
|
||||||
level=os.environ.get('BUMBLE_LOGLEVEL', 'INFO').upper(),
|
|
||||||
format="[%(asctime)s.%(msecs)03d] %(levelname)s:%(name)s:%(message)s",
|
|
||||||
datefmt="%H:%M:%S",
|
|
||||||
)
|
|
||||||
asyncio.run(
|
asyncio.run(
|
||||||
async_main(
|
async_main(
|
||||||
latency_probes, latency_probe_interval, latency_probe_command, transport
|
latency_probes, latency_probe_interval, latency_probe_command, transport
|
||||||
|
|||||||
@@ -16,21 +16,21 @@
|
|||||||
# Imports
|
# Imports
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
import asyncio
|
import asyncio
|
||||||
import logging
|
|
||||||
import os
|
|
||||||
import time
|
import time
|
||||||
from typing import Optional
|
|
||||||
|
import click
|
||||||
|
|
||||||
|
import bumble.logging
|
||||||
from bumble.colors import color
|
from bumble.colors import color
|
||||||
from bumble.hci import (
|
from bumble.hci import (
|
||||||
HCI_READ_LOOPBACK_MODE_COMMAND,
|
HCI_READ_LOOPBACK_MODE_COMMAND,
|
||||||
HCI_Read_Loopback_Mode_Command,
|
|
||||||
HCI_WRITE_LOOPBACK_MODE_COMMAND,
|
HCI_WRITE_LOOPBACK_MODE_COMMAND,
|
||||||
|
HCI_Read_Loopback_Mode_Command,
|
||||||
HCI_Write_Loopback_Mode_Command,
|
HCI_Write_Loopback_Mode_Command,
|
||||||
LoopbackMode,
|
LoopbackMode,
|
||||||
)
|
)
|
||||||
from bumble.host import Host
|
from bumble.host import Host
|
||||||
from bumble.transport import open_transport
|
from bumble.transport import open_transport
|
||||||
import click
|
|
||||||
|
|
||||||
|
|
||||||
class Loopback:
|
class Loopback:
|
||||||
@@ -40,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
|
||||||
@@ -85,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 (
|
||||||
@@ -100,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(
|
||||||
@@ -128,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'))
|
||||||
@@ -194,12 +196,7 @@ class Loopback:
|
|||||||
)
|
)
|
||||||
@click.argument('transport')
|
@click.argument('transport')
|
||||||
def main(packet_size, packet_count, transport):
|
def main(packet_size, packet_count, transport):
|
||||||
logging.basicConfig(
|
bumble.logging.setup_basic_logging()
|
||||||
level=os.environ.get('BUMBLE_LOGLEVEL', 'INFO').upper(),
|
|
||||||
format="[%(asctime)s.%(msecs)03d] %(levelname)s:%(name)s:%(message)s",
|
|
||||||
datefmt="%H:%M:%S",
|
|
||||||
)
|
|
||||||
|
|
||||||
loopback = Loopback(packet_size, packet_count, transport)
|
loopback = Loopback(packet_size, packet_count, transport)
|
||||||
asyncio.run(loopback.run())
|
asyncio.run(loopback.run())
|
||||||
|
|
||||||
|
|||||||
@@ -15,11 +15,10 @@
|
|||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
# Imports
|
# Imports
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
import logging
|
|
||||||
import asyncio
|
import asyncio
|
||||||
import sys
|
import sys
|
||||||
import os
|
|
||||||
|
|
||||||
|
import bumble.logging
|
||||||
from bumble.controller import Controller
|
from bumble.controller import Controller
|
||||||
from bumble.link import LocalLink
|
from bumble.link import LocalLink
|
||||||
from bumble.transport import open_transport
|
from bumble.transport import open_transport
|
||||||
@@ -62,7 +61,7 @@ async def async_main():
|
|||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
def main():
|
def main():
|
||||||
logging.basicConfig(level=os.environ.get('BUMBLE_LOGLEVEL', 'INFO').upper())
|
bumble.logging.setup_basic_logging()
|
||||||
asyncio.run(async_main())
|
asyncio.run(async_main())
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -16,18 +16,17 @@
|
|||||||
# Imports
|
# Imports
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
import asyncio
|
import asyncio
|
||||||
import os
|
from collections.abc import Callable, Iterable
|
||||||
import logging
|
|
||||||
from typing import Callable, Iterable, Optional
|
|
||||||
|
|
||||||
import click
|
import click
|
||||||
|
|
||||||
from bumble.core import ProtocolError
|
import bumble.logging
|
||||||
from bumble.colors import color
|
from bumble.colors import color
|
||||||
|
from bumble.core import ProtocolError
|
||||||
from bumble.device import Device, Peer
|
from bumble.device import Device, Peer
|
||||||
from bumble.gatt import Service
|
from bumble.gatt import Service
|
||||||
from bumble.profiles.device_information_service import DeviceInformationServiceProxy
|
|
||||||
from bumble.profiles.battery_service import BatteryServiceProxy
|
from bumble.profiles.battery_service import BatteryServiceProxy
|
||||||
|
from bumble.profiles.device_information_service import DeviceInformationServiceProxy
|
||||||
from bumble.profiles.gap import GenericAccessServiceProxy
|
from bumble.profiles.gap import GenericAccessServiceProxy
|
||||||
from bumble.profiles.pacs import PublishedAudioCapabilitiesServiceProxy
|
from bumble.profiles.pacs import PublishedAudioCapabilitiesServiceProxy
|
||||||
from bumble.profiles.tmap import TelephonyAndMediaAudioServiceProxy
|
from bumble.profiles.tmap import TelephonyAndMediaAudioServiceProxy
|
||||||
@@ -175,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'))
|
||||||
@@ -216,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(
|
||||||
@@ -267,7 +265,7 @@ def main(device_config, encrypt, transport, address_or_name):
|
|||||||
Dump the GATT database on a remote device. If ADDRESS_OR_NAME is not specified,
|
Dump the GATT database on a remote device. If ADDRESS_OR_NAME is not specified,
|
||||||
wait for an incoming connection.
|
wait for an incoming connection.
|
||||||
"""
|
"""
|
||||||
logging.basicConfig(level=os.environ.get('BUMBLE_LOGLEVEL', 'INFO').upper())
|
bumble.logging.setup_basic_logging()
|
||||||
asyncio.run(async_main(device_config, encrypt, transport, address_or_name))
|
asyncio.run(async_main(device_config, encrypt, transport, address_or_name))
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -16,11 +16,11 @@
|
|||||||
# Imports
|
# Imports
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
import asyncio
|
import asyncio
|
||||||
import os
|
|
||||||
import logging
|
|
||||||
import click
|
import click
|
||||||
|
|
||||||
import bumble.core
|
import bumble.core
|
||||||
|
import bumble.logging
|
||||||
from bumble.colors import color
|
from bumble.colors import color
|
||||||
from bumble.device import Device, Peer
|
from bumble.device import Device, Peer
|
||||||
from bumble.gatt import show_services
|
from bumble.gatt import show_services
|
||||||
@@ -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(
|
||||||
@@ -112,7 +111,7 @@ def main(device_config, encrypt, transport, address_or_name):
|
|||||||
Dump the GATT database on a remote device. If ADDRESS_OR_NAME is not specified,
|
Dump the GATT database on a remote device. If ADDRESS_OR_NAME is not specified,
|
||||||
wait for an incoming connection.
|
wait for an incoming connection.
|
||||||
"""
|
"""
|
||||||
logging.basicConfig(level=os.environ.get('BUMBLE_LOGLEVEL', 'INFO').upper())
|
bumble.logging.setup_basic_logging()
|
||||||
asyncio.run(async_main(device_config, encrypt, transport, address_or_name))
|
asyncio.run(async_main(device_config, encrypt, transport, address_or_name))
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -16,20 +16,19 @@
|
|||||||
# Imports
|
# Imports
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
import asyncio
|
import asyncio
|
||||||
import os
|
|
||||||
import struct
|
import struct
|
||||||
import logging
|
|
||||||
import click
|
import click
|
||||||
|
|
||||||
|
import bumble.logging
|
||||||
from bumble import l2cap
|
from bumble import l2cap
|
||||||
from bumble.colors import color
|
from bumble.colors import color
|
||||||
from bumble.device import Device, Peer
|
|
||||||
from bumble.core import AdvertisingData
|
from bumble.core import AdvertisingData
|
||||||
from bumble.gatt import Service, Characteristic, CharacteristicValue
|
from bumble.device import Device, Peer
|
||||||
from bumble.utils import AsyncRunner
|
from bumble.gatt import Characteristic, CharacteristicValue, Service
|
||||||
from bumble.transport import open_transport
|
|
||||||
from bumble.hci import HCI_Constant
|
from bumble.hci import HCI_Constant
|
||||||
|
from bumble.transport import open_transport
|
||||||
|
from bumble.utils import AsyncRunner
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
# Constants
|
# Constants
|
||||||
@@ -353,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()
|
||||||
@@ -383,6 +382,7 @@ def main(
|
|||||||
receive_host,
|
receive_host,
|
||||||
receive_port,
|
receive_port,
|
||||||
):
|
):
|
||||||
|
bumble.logging.setup_basic_logging('WARNING')
|
||||||
asyncio.run(
|
asyncio.run(
|
||||||
run(
|
run(
|
||||||
hci_transport,
|
hci_transport,
|
||||||
@@ -397,6 +397,5 @@ def main(
|
|||||||
|
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
logging.basicConfig(level=os.environ.get('BUMBLE_LOGLEVEL', 'WARNING').upper())
|
|
||||||
if __name__ == '__main__':
|
if __name__ == '__main__':
|
||||||
main()
|
main()
|
||||||
|
|||||||
@@ -12,14 +12,15 @@
|
|||||||
# 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.
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
# Imports
|
# Imports
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
import logging
|
import logging
|
||||||
import asyncio
|
|
||||||
import os
|
|
||||||
import sys
|
import sys
|
||||||
|
|
||||||
|
import bumble.logging
|
||||||
from bumble import hci, transport
|
from bumble import hci, transport
|
||||||
from bumble.bridge import HCI_Bridge
|
from bumble.bridge import HCI_Bridge
|
||||||
|
|
||||||
@@ -80,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)
|
||||||
@@ -100,7 +103,7 @@ async def async_main():
|
|||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
def main():
|
def main():
|
||||||
logging.basicConfig(level=os.environ.get('BUMBLE_LOGLEVEL', 'INFO').upper())
|
bumble.logging.setup_basic_logging()
|
||||||
asyncio.run(async_main())
|
asyncio.run(async_main())
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -16,16 +16,16 @@
|
|||||||
# Imports
|
# Imports
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
import asyncio
|
import asyncio
|
||||||
import logging
|
|
||||||
import os
|
|
||||||
import click
|
import click
|
||||||
|
|
||||||
|
import bumble.logging
|
||||||
from bumble import l2cap
|
from bumble import l2cap
|
||||||
from bumble.colors import color
|
from bumble.colors import color
|
||||||
from bumble.transport import open_transport
|
|
||||||
from bumble.device import Device
|
from bumble.device import Device
|
||||||
from bumble.utils import FlowControlAsyncPipe
|
|
||||||
from bumble.hci import HCI_Constant
|
from bumble.hci import HCI_Constant
|
||||||
|
from bumble.transport import open_transport
|
||||||
|
from bumble.utils import FlowControlAsyncPipe
|
||||||
|
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
@@ -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
|
||||||
|
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
@@ -356,6 +356,6 @@ def client(context, bluetooth_address, tcp_host, tcp_port):
|
|||||||
|
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
logging.basicConfig(level=os.environ.get('BUMBLE_LOGLEVEL', 'WARNING').upper())
|
|
||||||
if __name__ == '__main__':
|
if __name__ == '__main__':
|
||||||
|
bumble.logging.setup_basic_logging('WARNING')
|
||||||
cli(obj={}) # pylint: disable=no-value-for-parameter
|
cli(obj={}) # pylint: disable=no-value-for-parameter
|
||||||
|
|||||||
@@ -20,31 +20,30 @@ from __future__ import annotations
|
|||||||
import asyncio
|
import asyncio
|
||||||
import datetime
|
import datetime
|
||||||
import functools
|
import functools
|
||||||
from importlib import resources
|
|
||||||
import json
|
import json
|
||||||
import os
|
|
||||||
import logging
|
import logging
|
||||||
import pathlib
|
import pathlib
|
||||||
import weakref
|
|
||||||
import wave
|
import wave
|
||||||
|
import weakref
|
||||||
|
from importlib import resources
|
||||||
|
|
||||||
try:
|
try:
|
||||||
import lc3 # type: ignore # pylint: disable=E0401
|
import lc3 # type: ignore # pylint: disable=E0401
|
||||||
except ImportError as e:
|
except ImportError as e:
|
||||||
raise ImportError("Try `python -m pip install \".[lc3]\"`.") from e
|
raise ImportError("Try `python -m pip install \".[lc3]\"`.") from e
|
||||||
|
|
||||||
import click
|
|
||||||
import aiohttp.web
|
import aiohttp.web
|
||||||
|
import click
|
||||||
|
|
||||||
import bumble
|
import bumble
|
||||||
from bumble import utils
|
import bumble.logging
|
||||||
from bumble.core import AdvertisingData
|
from bumble import data_types, utils
|
||||||
from bumble.colors import color
|
from bumble.colors import color
|
||||||
from bumble.device import Device, DeviceConfiguration, AdvertisingParameters, CisLink
|
from bumble.core import AdvertisingData
|
||||||
from bumble.transport import open_transport
|
from bumble.device import AdvertisingParameters, CisLink, Device, DeviceConfiguration
|
||||||
from bumble.profiles import ascs, bap, pacs
|
|
||||||
from bumble.hci import Address, CodecID, CodingFormat, HCI_IsoDataPacket
|
from bumble.hci import Address, CodecID, CodingFormat, HCI_IsoDataPacket
|
||||||
|
from bumble.profiles import ascs, bap, pacs
|
||||||
|
from bumble.transport import open_transport
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
# Logging
|
# Logging
|
||||||
@@ -269,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,
|
||||||
@@ -300,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
|
||||||
@@ -331,22 +330,13 @@ class Speaker:
|
|||||||
advertising_data = bytes(
|
advertising_data = bytes(
|
||||||
AdvertisingData(
|
AdvertisingData(
|
||||||
[
|
[
|
||||||
(
|
data_types.CompleteLocalName(device_config.name),
|
||||||
AdvertisingData.COMPLETE_LOCAL_NAME,
|
data_types.Flags(
|
||||||
bytes(device_config.name, 'utf-8'),
|
AdvertisingData.Flags.LE_GENERAL_DISCOVERABLE_MODE
|
||||||
|
| AdvertisingData.Flags.BR_EDR_NOT_SUPPORTED
|
||||||
),
|
),
|
||||||
(
|
data_types.IncompleteListOf16BitServiceUUIDs(
|
||||||
AdvertisingData.FLAGS,
|
[pacs.PublishedAudioCapabilitiesService.UUID]
|
||||||
bytes(
|
|
||||||
[
|
|
||||||
AdvertisingData.LE_GENERAL_DISCOVERABLE_MODE_FLAG
|
|
||||||
| AdvertisingData.BR_EDR_NOT_SUPPORTED_FLAG
|
|
||||||
]
|
|
||||||
),
|
|
||||||
),
|
|
||||||
(
|
|
||||||
AdvertisingData.INCOMPLETE_LIST_OF_16_BIT_SERVICE_CLASS_UUIDS,
|
|
||||||
bytes(pacs.PublishedAudioCapabilitiesService.UUID),
|
|
||||||
),
|
),
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
@@ -454,7 +444,7 @@ def speaker(ui_port: int, device_config: str, transport: str, lc3_file: str) ->
|
|||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
def main():
|
def main():
|
||||||
logging.basicConfig(level=os.environ.get('BUMBLE_LOGLEVEL', 'INFO').upper())
|
bumble.logging.setup_basic_logging()
|
||||||
speaker()
|
speaker()
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
171
apps/pair.py
171
apps/pair.py
@@ -15,43 +15,46 @@
|
|||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
# Imports
|
# Imports
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
import os
|
|
||||||
import logging
|
import logging
|
||||||
import struct
|
import os
|
||||||
|
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, 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 (
|
||||||
|
ATT_INSUFFICIENT_AUTHENTICATION_ERROR,
|
||||||
|
ATT_INSUFFICIENT_ENCRYPTION_ERROR,
|
||||||
|
ATT_Error,
|
||||||
|
)
|
||||||
from bumble.colors import color
|
from bumble.colors import color
|
||||||
from bumble.device import Device, Peer
|
|
||||||
from bumble.transport import open_transport
|
|
||||||
from bumble.pairing import OobData, PairingDelegate, PairingConfig
|
|
||||||
from bumble.smp import OobContext, OobLegacyContext
|
|
||||||
from bumble.smp import error_name as smp_error_name
|
|
||||||
from bumble.keys import JsonKeyStore
|
|
||||||
from bumble.core import (
|
from bumble.core import (
|
||||||
|
UUID,
|
||||||
AdvertisingData,
|
AdvertisingData,
|
||||||
Appearance,
|
Appearance,
|
||||||
ProtocolError,
|
DataType,
|
||||||
PhysicalTransport,
|
PhysicalTransport,
|
||||||
UUID,
|
ProtocolError,
|
||||||
)
|
)
|
||||||
|
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,
|
||||||
GATT_HEART_RATE_SERVICE,
|
|
||||||
GATT_HEART_RATE_MEASUREMENT_CHARACTERISTIC,
|
GATT_HEART_RATE_MEASUREMENT_CHARACTERISTIC,
|
||||||
Service,
|
GATT_HEART_RATE_SERVICE,
|
||||||
Characteristic,
|
Characteristic,
|
||||||
|
Service,
|
||||||
)
|
)
|
||||||
from bumble.hci import OwnAddressType
|
from bumble.hci import OwnAddressType
|
||||||
from bumble.att import (
|
from bumble.keys import JsonKeyStore
|
||||||
ATT_Error,
|
from bumble.pairing import OobData, PairingConfig, PairingDelegate
|
||||||
ATT_INSUFFICIENT_AUTHENTICATION_ERROR,
|
from bumble.smp import OobContext, OobLegacyContext
|
||||||
ATT_INSUFFICIENT_ENCRYPTION_ERROR,
|
from bumble.transport import open_transport
|
||||||
)
|
|
||||||
from bumble.utils import AsyncRunner
|
from bumble.utils import AsyncRunner
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
@@ -62,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()
|
||||||
@@ -316,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)
|
||||||
|
|
||||||
@@ -402,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()
|
||||||
@@ -506,39 +511,29 @@ async def pair(
|
|||||||
if mode == 'dual':
|
if mode == 'dual':
|
||||||
flags |= AdvertisingData.Flags.SIMULTANEOUS_LE_BR_EDR_CAPABLE
|
flags |= AdvertisingData.Flags.SIMULTANEOUS_LE_BR_EDR_CAPABLE
|
||||||
|
|
||||||
ad_structs = [
|
advertising_data_types: list[DataType] = [
|
||||||
(
|
data_types.Flags(flags),
|
||||||
AdvertisingData.FLAGS,
|
data_types.CompleteLocalName('Bumble'),
|
||||||
bytes([flags]),
|
|
||||||
),
|
|
||||||
(AdvertisingData.COMPLETE_LOCAL_NAME, 'Bumble'.encode()),
|
|
||||||
]
|
]
|
||||||
if service_uuids_16:
|
if service_uuids_16:
|
||||||
ad_structs.append(
|
advertising_data_types.append(
|
||||||
(
|
data_types.IncompleteListOf16BitServiceUUIDs(service_uuids_16)
|
||||||
AdvertisingData.INCOMPLETE_LIST_OF_16_BIT_SERVICE_CLASS_UUIDS,
|
|
||||||
b"".join(bytes(uuid) for uuid in service_uuids_16),
|
|
||||||
)
|
|
||||||
)
|
)
|
||||||
if service_uuids_32:
|
if service_uuids_32:
|
||||||
ad_structs.append(
|
advertising_data_types.append(
|
||||||
(
|
data_types.IncompleteListOf32BitServiceUUIDs(service_uuids_32)
|
||||||
AdvertisingData.INCOMPLETE_LIST_OF_32_BIT_SERVICE_CLASS_UUIDS,
|
|
||||||
b"".join(bytes(uuid) for uuid in service_uuids_32),
|
|
||||||
)
|
|
||||||
)
|
)
|
||||||
if service_uuids_128:
|
if service_uuids_128:
|
||||||
ad_structs.append(
|
advertising_data_types.append(
|
||||||
(
|
data_types.IncompleteListOf128BitServiceUUIDs(service_uuids_128)
|
||||||
AdvertisingData.INCOMPLETE_LIST_OF_128_BIT_SERVICE_CLASS_UUIDS,
|
|
||||||
b"".join(bytes(uuid) for uuid in service_uuids_128),
|
|
||||||
)
|
|
||||||
)
|
)
|
||||||
|
|
||||||
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:
|
||||||
@@ -556,16 +551,12 @@ 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
|
||||||
)
|
)
|
||||||
ad_structs.append(
|
|
||||||
(
|
advertising_data_types.append(appearance)
|
||||||
AdvertisingData.APPEARANCE,
|
device.advertising_data = bytes(AdvertisingData(advertising_data_types))
|
||||||
struct.pack('<H', advertise_appearance_int),
|
|
||||||
)
|
|
||||||
)
|
|
||||||
device.advertising_data = bytes(AdvertisingData(ad_structs))
|
|
||||||
await device.start_advertising(
|
await device.start_advertising(
|
||||||
auto_restart=True,
|
auto_restart=True,
|
||||||
own_address_type=(
|
own_address_type=(
|
||||||
@@ -674,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()
|
||||||
|
|||||||
@@ -1,11 +1,12 @@
|
|||||||
import asyncio
|
import asyncio
|
||||||
import click
|
|
||||||
import logging
|
|
||||||
import json
|
import json
|
||||||
|
import logging
|
||||||
from bumble.pandora import PandoraDevice, Config, serve
|
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
|
import click
|
||||||
|
|
||||||
|
from bumble.pandora import Config, PandoraDevice, serve
|
||||||
|
|
||||||
BUMBLE_SERVER_GRPC_PORT = 7999
|
BUMBLE_SERVER_GRPC_PORT = 7999
|
||||||
ROOTCANAL_PORT_CUTTLEFISH = 7300
|
ROOTCANAL_PORT_CUTTLEFISH = 7300
|
||||||
|
|
||||||
@@ -18,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',
|
||||||
@@ -43,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)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -16,55 +16,49 @@
|
|||||||
# Imports
|
# Imports
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
import asyncio.subprocess
|
|
||||||
import os
|
|
||||||
import logging
|
import logging
|
||||||
from typing import Optional, Union
|
|
||||||
|
|
||||||
import click
|
import click
|
||||||
|
|
||||||
|
import bumble.logging
|
||||||
from bumble.a2dp import (
|
from bumble.a2dp import (
|
||||||
make_audio_source_service_sdp_records,
|
|
||||||
A2DP_SBC_CODEC_TYPE,
|
|
||||||
A2DP_MPEG_2_4_AAC_CODEC_TYPE,
|
A2DP_MPEG_2_4_AAC_CODEC_TYPE,
|
||||||
A2DP_NON_A2DP_CODEC_TYPE,
|
A2DP_NON_A2DP_CODEC_TYPE,
|
||||||
|
A2DP_SBC_CODEC_TYPE,
|
||||||
AacFrame,
|
AacFrame,
|
||||||
AacParser,
|
|
||||||
AacPacketSource,
|
|
||||||
AacMediaCodecInformation,
|
AacMediaCodecInformation,
|
||||||
SbcFrame,
|
AacPacketSource,
|
||||||
SbcParser,
|
AacParser,
|
||||||
SbcPacketSource,
|
|
||||||
SbcMediaCodecInformation,
|
|
||||||
OpusPacket,
|
|
||||||
OpusParser,
|
|
||||||
OpusPacketSource,
|
|
||||||
OpusMediaCodecInformation,
|
OpusMediaCodecInformation,
|
||||||
|
OpusPacket,
|
||||||
|
OpusPacketSource,
|
||||||
|
OpusParser,
|
||||||
|
SbcFrame,
|
||||||
|
SbcMediaCodecInformation,
|
||||||
|
SbcPacketSource,
|
||||||
|
SbcParser,
|
||||||
|
make_audio_source_service_sdp_records,
|
||||||
)
|
)
|
||||||
from bumble.avrcp import Protocol as AvrcpProtocol
|
|
||||||
from bumble.avdtp import (
|
from bumble.avdtp import (
|
||||||
find_avdtp_service_with_connection,
|
|
||||||
AVDTP_AUDIO_MEDIA_TYPE,
|
AVDTP_AUDIO_MEDIA_TYPE,
|
||||||
AVDTP_DELAY_REPORTING_SERVICE_CATEGORY,
|
AVDTP_DELAY_REPORTING_SERVICE_CATEGORY,
|
||||||
MediaCodecCapabilities,
|
MediaCodecCapabilities,
|
||||||
MediaPacketPump,
|
MediaPacketPump,
|
||||||
Protocol as AvdtpProtocol,
|
find_avdtp_service_with_connection,
|
||||||
)
|
)
|
||||||
|
from bumble.avdtp import Protocol as AvdtpProtocol
|
||||||
|
from bumble.avrcp import Protocol as AvrcpProtocol
|
||||||
from bumble.colors import color
|
from bumble.colors import color
|
||||||
from bumble.core import (
|
from bumble.core import AdvertisingData, DeviceClass, PhysicalTransport
|
||||||
AdvertisingData,
|
from bumble.core import ConnectionError as BumbleConnectionError
|
||||||
ConnectionError as BumbleConnectionError,
|
|
||||||
DeviceClass,
|
|
||||||
PhysicalTransport,
|
|
||||||
)
|
|
||||||
from bumble.device import Connection, Device, DeviceConfiguration
|
from bumble.device import Connection, Device, DeviceConfiguration
|
||||||
from bumble.hci import Address, HCI_CONNECTION_ALREADY_EXISTS_ERROR, HCI_Constant
|
from bumble.hci import HCI_CONNECTION_ALREADY_EXISTS_ERROR, Address, HCI_Constant
|
||||||
from bumble.pairing import PairingConfig
|
from bumble.pairing import PairingConfig
|
||||||
from bumble.transport import open_transport
|
from bumble.transport import open_transport
|
||||||
from bumble.utils import AsyncRunner
|
from bumble.utils import AsyncRunner
|
||||||
|
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
# Logging
|
# Logging
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
@@ -195,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:
|
||||||
@@ -203,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()
|
||||||
@@ -319,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
|
||||||
@@ -385,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))}"
|
||||||
@@ -424,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:
|
||||||
@@ -453,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":
|
||||||
@@ -599,7 +593,7 @@ def play(context, address, audio_format, audio_file):
|
|||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
def main():
|
def main():
|
||||||
logging.basicConfig(level=os.environ.get("BUMBLE_LOGLEVEL", "WARNING").upper())
|
bumble.logging.setup_basic_logging("WARNING")
|
||||||
player_cli()
|
player_cli()
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -16,21 +16,14 @@
|
|||||||
# Imports
|
# Imports
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
import asyncio
|
import asyncio
|
||||||
import logging
|
|
||||||
import os
|
|
||||||
import time
|
import time
|
||||||
from typing import Optional
|
|
||||||
|
|
||||||
import click
|
import click
|
||||||
|
|
||||||
|
import bumble.logging
|
||||||
|
from bumble import core, hci, rfcomm, transport, utils
|
||||||
from bumble.colors import color
|
from bumble.colors import color
|
||||||
from bumble.device import Device, DeviceConfiguration, Connection
|
from bumble.device import Connection, Device, DeviceConfiguration
|
||||||
from bumble import core
|
|
||||||
from bumble import hci
|
|
||||||
from bumble import rfcomm
|
|
||||||
from bumble import transport
|
|
||||||
from bumble import utils
|
|
||||||
|
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
# Constants
|
# Constants
|
||||||
@@ -88,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"))
|
||||||
@@ -248,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"))
|
||||||
@@ -428,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:
|
||||||
@@ -515,6 +508,6 @@ def client(context, bluetooth_address, tcp_host, tcp_port, authenticate, encrypt
|
|||||||
|
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
logging.basicConfig(level=os.environ.get("BUMBLE_LOGLEVEL", "WARNING").upper())
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
|
bumble.logging.setup_basic_logging("WARNING")
|
||||||
cli(obj={}) # pylint: disable=no-value-for-parameter
|
cli(obj={}) # pylint: disable=no-value-for-parameter
|
||||||
|
|||||||
41
apps/scan.py
41
apps/scan.py
@@ -16,17 +16,17 @@
|
|||||||
# Imports
|
# Imports
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
import asyncio
|
import asyncio
|
||||||
import os
|
|
||||||
import logging
|
|
||||||
import click
|
import click
|
||||||
|
|
||||||
|
import bumble.logging
|
||||||
|
from bumble import data_types
|
||||||
from bumble.colors import color
|
from bumble.colors import color
|
||||||
from bumble.device import Device
|
from bumble.device import Advertisement, Device, DeviceConfiguration
|
||||||
from bumble.transport import open_transport
|
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
|
||||||
from bumble.device import Advertisement
|
from bumble.transport import open_transport
|
||||||
from bumble.hci import Address, HCI_Constant, HCI_LE_1M_PHY, HCI_LE_CODED_PHY
|
|
||||||
|
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
@@ -95,13 +95,22 @@ class AdvertisementPrinter:
|
|||||||
else:
|
else:
|
||||||
phy_info = ''
|
phy_info = ''
|
||||||
|
|
||||||
|
details = separator.join(
|
||||||
|
[
|
||||||
|
data_type.to_string(use_label=True)
|
||||||
|
for data_type in data_types.data_types_from_advertising_data(
|
||||||
|
advertisement.data
|
||||||
|
)
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
print(
|
print(
|
||||||
f'>>> {color(address, address_color)} '
|
f'>>> {color(address, address_color)} '
|
||||||
f'[{color(address_type_string, type_color)}]{address_qualifier}'
|
f'[{color(address_type_string, type_color)}]{address_qualifier}'
|
||||||
f'{resolution_qualifier}:{separator}'
|
f'{resolution_qualifier}:{separator}'
|
||||||
f'{phy_info}'
|
f'{phy_info}'
|
||||||
f'RSSI:{advertisement.rssi:4} {rssi_bar}{separator}'
|
f'RSSI:{advertisement.rssi:4} {rssi_bar}{separator}'
|
||||||
f'{advertisement.data.to_string(separator)}\n'
|
f'{details}\n'
|
||||||
)
|
)
|
||||||
|
|
||||||
def on_advertisement(self, advertisement):
|
def on_advertisement(self, advertisement):
|
||||||
@@ -135,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()
|
||||||
@@ -181,7 +196,7 @@ async def scan(
|
|||||||
scanning_phys=scanning_phys,
|
scanning_phys=scanning_phys,
|
||||||
)
|
)
|
||||||
|
|
||||||
await hci_source.wait_for_termination()
|
await hci_source.terminated
|
||||||
|
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
@@ -208,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(
|
||||||
@@ -237,7 +250,7 @@ def main(
|
|||||||
device_config,
|
device_config,
|
||||||
transport,
|
transport,
|
||||||
):
|
):
|
||||||
logging.basicConfig(level=os.environ.get('BUMBLE_LOGLEVEL', 'WARNING').upper())
|
bumble.logging.setup_basic_logging('WARNING')
|
||||||
asyncio.run(
|
asyncio.run(
|
||||||
scan(
|
scan(
|
||||||
min_rssi,
|
min_rssi,
|
||||||
|
|||||||
@@ -18,16 +18,15 @@
|
|||||||
import datetime
|
import datetime
|
||||||
import importlib
|
import importlib
|
||||||
import logging
|
import logging
|
||||||
import os
|
|
||||||
import struct
|
import struct
|
||||||
|
|
||||||
import click
|
import click
|
||||||
|
|
||||||
from bumble.colors import color
|
import bumble.logging
|
||||||
from bumble import hci
|
from bumble import hci
|
||||||
from bumble.transport.common import PacketReader
|
from bumble.colors import color
|
||||||
from bumble.helpers import PacketTracer
|
from bumble.helpers import PacketTracer
|
||||||
|
from bumble.transport.common import PacketReader
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
# Logging
|
# Logging
|
||||||
@@ -188,5 +187,5 @@ def main(format, vendor, filename):
|
|||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
if __name__ == '__main__':
|
if __name__ == '__main__':
|
||||||
logging.basicConfig(level=os.environ.get('BUMBLE_LOGLEVEL', 'WARNING').upper())
|
bumble.logging.setup_basic_logging('WARNING')
|
||||||
main() # pylint: disable=no-value-for-parameter
|
main() # pylint: disable=no-value-for-parameter
|
||||||
|
|||||||
@@ -16,49 +16,48 @@
|
|||||||
# Imports
|
# Imports
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
import asyncio.subprocess
|
import asyncio.subprocess
|
||||||
from importlib import resources
|
|
||||||
import enum
|
import enum
|
||||||
import json
|
import json
|
||||||
import os
|
|
||||||
import logging
|
import logging
|
||||||
import pathlib
|
import pathlib
|
||||||
import subprocess
|
import subprocess
|
||||||
from typing import Optional
|
|
||||||
import weakref
|
import weakref
|
||||||
|
from importlib import resources
|
||||||
|
|
||||||
import click
|
|
||||||
import aiohttp
|
import aiohttp
|
||||||
|
import click
|
||||||
from aiohttp import web
|
from aiohttp import web
|
||||||
|
|
||||||
import bumble
|
import bumble
|
||||||
from bumble.colors import color
|
import bumble.logging
|
||||||
from bumble.core import PhysicalTransport, CommandTimeoutError
|
from bumble.a2dp import (
|
||||||
from bumble.device import Connection, Device, DeviceConfiguration
|
A2DP_MPEG_2_4_AAC_CODEC_TYPE,
|
||||||
from bumble.hci import HCI_StatusError
|
A2DP_NON_A2DP_CODEC_TYPE,
|
||||||
from bumble.pairing import PairingConfig
|
A2DP_SBC_CODEC_TYPE,
|
||||||
from bumble.sdp import ServiceAttribute
|
AacMediaCodecInformation,
|
||||||
from bumble.transport import open_transport
|
OpusMediaCodecInformation,
|
||||||
|
SbcMediaCodecInformation,
|
||||||
|
make_audio_sink_service_sdp_records,
|
||||||
|
)
|
||||||
from bumble.avdtp import (
|
from bumble.avdtp import (
|
||||||
AVDTP_AUDIO_MEDIA_TYPE,
|
AVDTP_AUDIO_MEDIA_TYPE,
|
||||||
Listener,
|
Listener,
|
||||||
MediaCodecCapabilities,
|
MediaCodecCapabilities,
|
||||||
Protocol,
|
Protocol,
|
||||||
)
|
)
|
||||||
from bumble.a2dp import (
|
|
||||||
make_audio_sink_service_sdp_records,
|
|
||||||
A2DP_SBC_CODEC_TYPE,
|
|
||||||
A2DP_MPEG_2_4_AAC_CODEC_TYPE,
|
|
||||||
A2DP_NON_A2DP_CODEC_TYPE,
|
|
||||||
SbcMediaCodecInformation,
|
|
||||||
AacMediaCodecInformation,
|
|
||||||
OpusMediaCodecInformation,
|
|
||||||
)
|
|
||||||
from bumble.utils import AsyncRunner
|
|
||||||
from bumble.codecs import AacAudioRtpPacket
|
from bumble.codecs import AacAudioRtpPacket
|
||||||
|
from bumble.colors import color
|
||||||
|
from bumble.core import CommandTimeoutError, PhysicalTransport
|
||||||
|
from bumble.device import Connection, Device, DeviceConfiguration
|
||||||
|
from bumble.hci import HCI_StatusError
|
||||||
|
from bumble.pairing import PairingConfig
|
||||||
from bumble.rtp import MediaPacket
|
from bumble.rtp import MediaPacket
|
||||||
|
from bumble.sdp import ServiceAttribute
|
||||||
|
from bumble.transport import open_transport
|
||||||
|
from bumble.utils import AsyncRunner
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
# Logging
|
# Logging
|
||||||
@@ -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()
|
||||||
@@ -833,11 +832,7 @@ def speaker(
|
|||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
def main():
|
def main():
|
||||||
logging.basicConfig(
|
bumble.logging.setup_basic_logging('WARNING')
|
||||||
level=os.environ.get('BUMBLE_LOGLEVEL', 'WARNING').upper(),
|
|
||||||
format="[%(asctime)s.%(msecs)03d] %(levelname)s:%(name)s:%(message)s",
|
|
||||||
datefmt="%H:%M:%S",
|
|
||||||
)
|
|
||||||
speaker()
|
speaker()
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -16,10 +16,10 @@
|
|||||||
# Imports
|
# Imports
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
import asyncio
|
import asyncio
|
||||||
import os
|
|
||||||
import logging
|
|
||||||
import click
|
import click
|
||||||
|
|
||||||
|
import bumble.logging
|
||||||
from bumble.device import Device
|
from bumble.device import Device
|
||||||
from bumble.keys import JsonKeyStore
|
from bumble.keys import JsonKeyStore
|
||||||
from bumble.transport import open_transport
|
from bumble.transport import open_transport
|
||||||
@@ -68,7 +68,7 @@ def main(keystore_file, hci_transport, device_config, address):
|
|||||||
instantiated.
|
instantiated.
|
||||||
If no address is passed, the existing pairing keys for all addresses are printed.
|
If no address is passed, the existing pairing keys for all addresses are printed.
|
||||||
"""
|
"""
|
||||||
logging.basicConfig(level=os.environ.get('BUMBLE_LOGLEVEL', 'INFO').upper())
|
bumble.logging.setup_basic_logging()
|
||||||
|
|
||||||
if not keystore_file and not hci_transport:
|
if not keystore_file and not hci_transport:
|
||||||
print('either --keystore-file or --hci-transport must be specified.')
|
print('either --keystore-file or --hci-transport must be specified.')
|
||||||
|
|||||||
@@ -26,15 +26,15 @@
|
|||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
# Imports
|
# Imports
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
import os
|
from typing import Any
|
||||||
import logging
|
|
||||||
import click
|
import click
|
||||||
import usb1
|
import usb1
|
||||||
|
|
||||||
|
import bumble.logging
|
||||||
from bumble.colors import color
|
from bumble.colors import color
|
||||||
from bumble.transport.usb import load_libusb
|
from bumble.transport.usb import load_libusb
|
||||||
|
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
# Constants
|
# Constants
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
@@ -168,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')
|
||||||
logging.basicConfig(level=os.environ.get('BUMBLE_LOGLEVEL', 'WARNING').upper())
|
@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')
|
||||||
|
|
||||||
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()
|
||||||
@@ -236,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(
|
||||||
|
|||||||
128
bumble/a2dp.py
128
bumble/a2dp.py
@@ -17,37 +17,37 @@
|
|||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from collections.abc import AsyncGenerator
|
|
||||||
import dataclasses
|
import dataclasses
|
||||||
import enum
|
import enum
|
||||||
import logging
|
import logging
|
||||||
import struct
|
import struct
|
||||||
from typing import Awaitable, Callable
|
from collections.abc import AsyncGenerator, Awaitable, Callable
|
||||||
from typing_extensions import ClassVar, Self
|
from typing import ClassVar
|
||||||
|
|
||||||
|
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.sdp import (
|
|
||||||
DataElement,
|
|
||||||
ServiceAttribute,
|
|
||||||
SDP_PUBLIC_BROWSE_ROOT,
|
|
||||||
SDP_BROWSE_GROUP_LIST_ATTRIBUTE_ID,
|
|
||||||
SDP_SERVICE_RECORD_HANDLE_ATTRIBUTE_ID,
|
|
||||||
SDP_SERVICE_CLASS_ID_LIST_ATTRIBUTE_ID,
|
|
||||||
SDP_PROTOCOL_DESCRIPTOR_LIST_ATTRIBUTE_ID,
|
|
||||||
SDP_BLUETOOTH_PROFILE_DESCRIPTOR_LIST_ATTRIBUTE_ID,
|
|
||||||
)
|
|
||||||
from bumble.core import (
|
from bumble.core import (
|
||||||
BT_L2CAP_PROTOCOL_ID,
|
|
||||||
BT_AUDIO_SOURCE_SERVICE,
|
|
||||||
BT_AUDIO_SINK_SERVICE,
|
|
||||||
BT_AVDTP_PROTOCOL_ID,
|
|
||||||
BT_ADVANCED_AUDIO_DISTRIBUTION_SERVICE,
|
BT_ADVANCED_AUDIO_DISTRIBUTION_SERVICE,
|
||||||
|
BT_AUDIO_SINK_SERVICE,
|
||||||
|
BT_AUDIO_SOURCE_SERVICE,
|
||||||
|
BT_AVDTP_PROTOCOL_ID,
|
||||||
|
BT_L2CAP_PROTOCOL_ID,
|
||||||
name_or_number,
|
name_or_number,
|
||||||
)
|
)
|
||||||
from bumble.rtp import MediaPacket
|
from bumble.rtp import MediaPacket
|
||||||
|
from bumble.sdp import (
|
||||||
|
SDP_BLUETOOTH_PROFILE_DESCRIPTOR_LIST_ATTRIBUTE_ID,
|
||||||
|
SDP_BROWSE_GROUP_LIST_ATTRIBUTE_ID,
|
||||||
|
SDP_PROTOCOL_DESCRIPTOR_LIST_ATTRIBUTE_ID,
|
||||||
|
SDP_PUBLIC_BROWSE_ROOT,
|
||||||
|
SDP_SERVICE_CLASS_ID_LIST_ATTRIBUTE_ID,
|
||||||
|
SDP_SERVICE_RECORD_HANDLE_ATTRIBUTE_ID,
|
||||||
|
DataElement,
|
||||||
|
ServiceAttribute,
|
||||||
|
)
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
# Logging
|
# Logging
|
||||||
@@ -60,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
|
||||||
@@ -89,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]
|
||||||
@@ -103,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 = [
|
||||||
@@ -130,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
|
||||||
|
|
||||||
@@ -260,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
|
||||||
'''
|
'''
|
||||||
@@ -346,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
|
||||||
'''
|
'''
|
||||||
@@ -428,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
|
||||||
'''
|
'''
|
||||||
@@ -452,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()})',
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -648,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:
|
||||||
|
|||||||
733
bumble/att.py
733
bumble/att.py
File diff suppressed because it is too large
Load Diff
@@ -17,20 +17,17 @@
|
|||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import asyncio
|
|
||||||
import abc
|
import abc
|
||||||
from concurrent.futures import ThreadPoolExecutor
|
import asyncio
|
||||||
|
import concurrent.futures
|
||||||
import dataclasses
|
import dataclasses
|
||||||
import enum
|
import enum
|
||||||
import logging
|
import logging
|
||||||
import pathlib
|
import pathlib
|
||||||
from typing import (
|
|
||||||
AsyncGenerator,
|
|
||||||
BinaryIO,
|
|
||||||
TYPE_CHECKING,
|
|
||||||
)
|
|
||||||
import sys
|
import sys
|
||||||
import wave
|
import wave
|
||||||
|
from collections.abc import AsyncGenerator
|
||||||
|
from typing import TYPE_CHECKING, BinaryIO
|
||||||
|
|
||||||
from bumble.colors import color
|
from bumble.colors import color
|
||||||
|
|
||||||
@@ -180,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())
|
||||||
|
|
||||||
@@ -230,8 +227,8 @@ class SoundDeviceAudioOutput(ThreadedAudioOutput):
|
|||||||
|
|
||||||
try:
|
try:
|
||||||
self._stream.write(pcm_samples)
|
self._stream.write(pcm_samples)
|
||||||
except Exception as error:
|
except Exception:
|
||||||
print(f'Sound device error: {error}')
|
logger.exception('Sound device error')
|
||||||
raise
|
raise
|
||||||
|
|
||||||
def _close(self):
|
def _close(self):
|
||||||
@@ -409,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
|
||||||
@@ -549,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
|
||||||
|
|||||||
@@ -16,12 +16,11 @@
|
|||||||
# Imports
|
# Imports
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import enum
|
import enum
|
||||||
import struct
|
import struct
|
||||||
from typing import Union
|
|
||||||
|
|
||||||
from bumble import core
|
from bumble import core, utils
|
||||||
from bumble import 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 (
|
||||||
|
|||||||
@@ -16,15 +16,14 @@
|
|||||||
# Imports
|
# Imports
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
from enum import IntEnum
|
|
||||||
import logging
|
import logging
|
||||||
import struct
|
import struct
|
||||||
from typing import Callable, cast, Optional
|
from collections.abc import Callable
|
||||||
|
from enum import IntEnum
|
||||||
|
|
||||||
|
from bumble import core, l2cap
|
||||||
from bumble.colors import color
|
from bumble.colors import color
|
||||||
from bumble import avc
|
|
||||||
from bumble import core
|
|
||||||
from bumble import l2cap
|
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
# Logging
|
# Logging
|
||||||
@@ -137,17 +136,17 @@ class MessageAssembler:
|
|||||||
self.pid,
|
self.pid,
|
||||||
self.payload,
|
self.payload,
|
||||||
)
|
)
|
||||||
except Exception as error:
|
except Exception:
|
||||||
logger.exception(color(f"!!! exception in callback: {error}", "red"))
|
logger.exception(color("!!! exception in callback", "red"))
|
||||||
|
|
||||||
self.reset()
|
self.reset()
|
||||||
|
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
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
|
||||||
@@ -205,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,
|
||||||
@@ -241,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(
|
||||||
@@ -263,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'')
|
||||||
|
|
||||||
|
|||||||
1196
bumble/avdtp.py
1196
bumble/avdtp.py
File diff suppressed because it is too large
Load Diff
2597
bumble/avrcp.py
2597
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)
|
||||||
|
|||||||
@@ -16,7 +16,9 @@
|
|||||||
# Imports
|
# Imports
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
|
|
||||||
from typing_extensions import Self
|
from typing_extensions import Self
|
||||||
|
|
||||||
from bumble import core
|
from bumble import core
|
||||||
@@ -161,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')
|
||||||
@@ -362,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')
|
||||||
@@ -389,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'
|
||||||
@@ -411,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
|
||||||
|
|
||||||
|
|||||||
2753
bumble/controller.py
2753
bumble/controller.py
File diff suppressed because it is too large
Load Diff
1045
bumble/core.py
1045
bumble/core.py
File diff suppressed because it is too large
Load Diff
@@ -22,13 +22,14 @@ import operator
|
|||||||
import secrets
|
import secrets
|
||||||
|
|
||||||
try:
|
try:
|
||||||
from bumble.crypto.cryptography import EccKey, e, aes_cmac
|
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, e, aes_cmac # type: ignore[assignment]
|
from bumble.crypto.builtin import EccKey, aes_cmac, e # type: ignore[assignment]
|
||||||
|
|
||||||
|
_EccKey = EccKey # For the linter only
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
# Logging
|
# Logging
|
||||||
|
|||||||
@@ -24,12 +24,11 @@
|
|||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import copy
|
||||||
import dataclasses
|
import dataclasses
|
||||||
import functools
|
import functools
|
||||||
import copy
|
|
||||||
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:
|
||||||
|
|||||||
@@ -16,11 +16,9 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import functools
|
import functools
|
||||||
|
|
||||||
from cryptography.hazmat.primitives import ciphers
|
from cryptography.hazmat.primitives import ciphers, cmac
|
||||||
from cryptography.hazmat.primitives.ciphers import algorithms
|
|
||||||
from cryptography.hazmat.primitives.ciphers import modes
|
|
||||||
from cryptography.hazmat.primitives.asymmetric import ec
|
from cryptography.hazmat.primitives.asymmetric import ec
|
||||||
from cryptography.hazmat.primitives import cmac
|
from cryptography.hazmat.primitives.ciphers import algorithms, modes
|
||||||
|
|
||||||
|
|
||||||
def e(key: bytes, data: bytes) -> bytes:
|
def e(key: bytes, data: bytes) -> bytes:
|
||||||
|
|||||||
1026
bumble/data_types.py
Normal file
1026
bumble/data_types.py
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||||
|
|
||||||
|
|||||||
2692
bumble/device.py
2692
bumble/device.py
File diff suppressed because it is too large
Load Diff
@@ -20,12 +20,14 @@ like loading firmware after a cold start.
|
|||||||
# Imports
|
# Imports
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
import pathlib
|
import pathlib
|
||||||
import platform
|
import platform
|
||||||
from typing import Iterable, Optional, TYPE_CHECKING
|
from collections.abc import Iterable
|
||||||
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
from bumble.drivers import rtk, intel
|
from bumble.drivers import intel, rtk
|
||||||
from bumble.drivers.common import Driver
|
from bumble.drivers.common import Driver
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
@@ -40,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.
|
||||||
@@ -48,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:
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ Loosely based on the Fuchsia OS implementation.
|
|||||||
# Imports
|
# Imports
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
import collections
|
import collections
|
||||||
import dataclasses
|
import dataclasses
|
||||||
@@ -28,12 +29,10 @@ import os
|
|||||||
import pathlib
|
import pathlib
|
||||||
import platform
|
import platform
|
||||||
import struct
|
import struct
|
||||||
from typing import Any, Optional, TYPE_CHECKING
|
from typing import TYPE_CHECKING, Any
|
||||||
|
|
||||||
from bumble import core
|
from bumble import core, hci, utils
|
||||||
from bumble.drivers import common
|
from bumble.drivers import common
|
||||||
from bumble import hci
|
|
||||||
from bumble import utils
|
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from bumble.host import Host
|
from bumble.host import Host
|
||||||
@@ -90,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("*"))
|
||||||
|
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
@@ -199,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 :]
|
||||||
@@ -354,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 [
|
||||||
@@ -381,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
|
||||||
|
|
||||||
@@ -403,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:
|
||||||
@@ -460,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.
|
||||||
@@ -480,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}")
|
||||||
|
|
||||||
@@ -599,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)
|
||||||
@@ -618,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:]
|
||||||
|
|
||||||
@@ -637,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,11 +16,8 @@ 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
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
|
||||||
# Imports
|
|
||||||
# -----------------------------------------------------------------------------
|
|
||||||
from dataclasses import dataclass, field
|
|
||||||
import asyncio
|
import asyncio
|
||||||
import enum
|
import enum
|
||||||
import logging
|
import logging
|
||||||
@@ -31,11 +28,18 @@ import platform
|
|||||||
import struct
|
import struct
|
||||||
import weakref
|
import weakref
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
# Imports
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
from bumble import core
|
from bumble import core, hci
|
||||||
from bumble import hci
|
|
||||||
from bumble.drivers import common
|
from bumble.drivers import common
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from bumble.host import Host
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
# Logging
|
# Logging
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
@@ -78,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 = {
|
||||||
@@ -93,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.
|
||||||
@@ -116,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),
|
||||||
@@ -181,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
|
||||||
|
|
||||||
|
|
||||||
@@ -362,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,
|
||||||
@@ -419,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
|
||||||
|
|
||||||
@@ -466,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
|
||||||
@@ -483,29 +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
|
||||||
|
async def get_loaded_firmware_version(host: Host) -> int | None:
|
||||||
|
response1 = await host.send_sync_command_raw(HCI_RTK_Read_ROM_Version_Command())
|
||||||
|
if (
|
||||||
|
not isinstance(
|
||||||
|
response1.return_parameters, HCI_RTK_Read_ROM_Version_ReturnParameters
|
||||||
|
)
|
||||||
|
or response1.return_parameters.status != hci.HCI_SUCCESS
|
||||||
|
):
|
||||||
|
return None
|
||||||
|
|
||||||
|
response2 = await host.send_sync_command(
|
||||||
|
hci.HCI_Read_Local_Version_Information_Command()
|
||||||
|
)
|
||||||
|
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
|
||||||
|
|
||||||
@@ -530,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
|
||||||
@@ -585,28 +650,35 @@ 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
|
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
|
||||||
|
|
||||||
firmware = Firmware(self.firmware)
|
firmware = Firmware(self.firmware)
|
||||||
logger.debug(f"firmware: project_id=0x{firmware.project_id:04X}")
|
logger.debug(f"firmware: project_id=0x{firmware.project_id:04X}")
|
||||||
|
logger.debug(f"firmware: version=0x{firmware.version:04X}")
|
||||||
for patch in firmware.patches:
|
for patch in firmware.patches:
|
||||||
if patch[0] == rom_version + 1:
|
if patch[0] == rom_version + 1:
|
||||||
logger.debug(f"using patch {patch[0]}")
|
logger.debug(f"using patch {patch[0]}")
|
||||||
break
|
break
|
||||||
else:
|
else:
|
||||||
logger.warning("no valid patch found for rom version {rom_version}")
|
logger.warning("no valid patch found for rom version {rom_version}")
|
||||||
return
|
return None
|
||||||
|
|
||||||
# Append the config if there is one.
|
# Append the config if there is one.
|
||||||
if self.config:
|
if self.config:
|
||||||
@@ -627,22 +699,28 @@ 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:04X}")
|
logger.debug(f"ROM version after download: {rom_version:02X}")
|
||||||
|
|
||||||
|
return firmware.version
|
||||||
|
|
||||||
async def download_firmware(self):
|
async def download_firmware(self):
|
||||||
if self.driver_info.rom == RTK_ROM_LMP_8723A:
|
if self.driver_info.rom == RTK_ROM_LMP_8723A:
|
||||||
@@ -661,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 (
|
|
||||||
Service,
|
|
||||||
Characteristic,
|
|
||||||
GATT_GENERIC_ACCESS_SERVICE,
|
|
||||||
GATT_DEVICE_NAME_CHARACTERISTIC,
|
|
||||||
GATT_APPEARANCE_CHARACTERISTIC,
|
|
||||||
)
|
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
|
||||||
# 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],
|
|
||||||
)
|
|
||||||
@@ -23,15 +23,17 @@
|
|||||||
# Imports
|
# Imports
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import enum
|
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, AttributeValueV2
|
||||||
from bumble.colors import color
|
from bumble.colors import color
|
||||||
from bumble.core import BaseBumbleError, UUID
|
from bumble.core import UUID, BaseBumbleError
|
||||||
from bumble.att import Attribute, AttributeValue
|
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
# Typing
|
# Typing
|
||||||
@@ -226,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')
|
||||||
@@ -355,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] = (),
|
||||||
@@ -378,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
|
||||||
@@ -402,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,
|
||||||
@@ -502,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)
|
||||||
@@ -578,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 = '<...>'
|
||||||
|
|||||||
@@ -20,22 +20,15 @@
|
|||||||
# Imports
|
# Imports
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
import struct
|
|
||||||
from typing import (
|
|
||||||
Any,
|
|
||||||
Callable,
|
|
||||||
Generic,
|
|
||||||
Iterable,
|
|
||||||
Literal,
|
|
||||||
Optional,
|
|
||||||
TypeVar,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
import struct
|
||||||
|
from collections.abc import Callable, Iterable
|
||||||
|
from typing import Any, Generic, Literal, TypeVar
|
||||||
|
|
||||||
|
from bumble import utils
|
||||||
from bumble.core import InvalidOperationError
|
from bumble.core import InvalidOperationError
|
||||||
from bumble.gatt import Characteristic
|
from bumble.gatt import Characteristic
|
||||||
from bumble.gatt_client import CharacteristicProxy
|
from bumble.gatt_client import CharacteristicProxy
|
||||||
from bumble import utils
|
|
||||||
|
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
# Typing
|
# Typing
|
||||||
@@ -82,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
|
||||||
@@ -109,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
|
||||||
@@ -369,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)
|
||||||
|
|||||||
@@ -24,67 +24,47 @@
|
|||||||
# Imports
|
# Imports
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
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 (
|
||||||
Any,
|
|
||||||
Callable,
|
|
||||||
Generic,
|
|
||||||
Iterable,
|
|
||||||
Optional,
|
|
||||||
Union,
|
|
||||||
TypeVar,
|
|
||||||
TYPE_CHECKING,
|
TYPE_CHECKING,
|
||||||
|
Any,
|
||||||
|
ClassVar,
|
||||||
|
Generic,
|
||||||
|
TypeVar,
|
||||||
|
overload,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
from typing_extensions import Self
|
||||||
|
|
||||||
|
from bumble import att, core, l2cap, utils
|
||||||
from bumble.colors import color
|
from bumble.colors import color
|
||||||
from bumble.hci import HCI_Constant
|
|
||||||
from bumble.att import (
|
|
||||||
ATT_ATTRIBUTE_NOT_FOUND_ERROR,
|
|
||||||
ATT_ATTRIBUTE_NOT_LONG_ERROR,
|
|
||||||
ATT_CID,
|
|
||||||
ATT_DEFAULT_MTU,
|
|
||||||
ATT_ERROR_RESPONSE,
|
|
||||||
ATT_INVALID_OFFSET_ERROR,
|
|
||||||
ATT_PDU,
|
|
||||||
ATT_RESPONSES,
|
|
||||||
ATT_Exchange_MTU_Request,
|
|
||||||
ATT_Find_By_Type_Value_Request,
|
|
||||||
ATT_Find_Information_Request,
|
|
||||||
ATT_Handle_Value_Confirmation,
|
|
||||||
ATT_Read_Blob_Request,
|
|
||||||
ATT_Read_By_Group_Type_Request,
|
|
||||||
ATT_Read_By_Type_Request,
|
|
||||||
ATT_Read_Request,
|
|
||||||
ATT_Write_Command,
|
|
||||||
ATT_Write_Request,
|
|
||||||
ATT_Error,
|
|
||||||
)
|
|
||||||
from bumble import utils
|
|
||||||
from bumble import core
|
|
||||||
from bumble.core import UUID, InvalidStateError
|
from bumble.core import UUID, InvalidStateError
|
||||||
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,
|
||||||
|
GATT_INCLUDE_ATTRIBUTE_TYPE,
|
||||||
GATT_PRIMARY_SERVICE_ATTRIBUTE_TYPE,
|
GATT_PRIMARY_SERVICE_ATTRIBUTE_TYPE,
|
||||||
GATT_REQUEST_TIMEOUT,
|
GATT_REQUEST_TIMEOUT,
|
||||||
GATT_SECONDARY_SERVICE_ATTRIBUTE_TYPE,
|
GATT_SECONDARY_SERVICE_ATTRIBUTE_TYPE,
|
||||||
GATT_INCLUDE_ATTRIBUTE_TYPE,
|
|
||||||
Characteristic,
|
Characteristic,
|
||||||
ClientCharacteristicConfigurationBits,
|
ClientCharacteristicConfigurationBits,
|
||||||
InvalidServiceError,
|
InvalidServiceError,
|
||||||
TemplateService,
|
TemplateService,
|
||||||
)
|
)
|
||||||
|
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')
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
@@ -214,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
|
||||||
@@ -226,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:
|
||||||
@@ -272,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)
|
||||||
|
|
||||||
|
|
||||||
@@ -286,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_PDU]]
|
|
||||||
pending_request: Optional[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
|
||||||
@@ -305,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_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_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_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
|
||||||
@@ -345,40 +378,41 @@ class Client:
|
|||||||
|
|
||||||
return response
|
return response
|
||||||
|
|
||||||
def send_confirmation(self, confirmation: ATT_Handle_Value_Confirmation) -> None:
|
def send_confirmation(
|
||||||
logger.debug(
|
self, confirmation: att.ATT_Handle_Value_Confirmation
|
||||||
f'GATT Confirmation from client: [0x{self.connection.handle:04X}] '
|
) -> None:
|
||||||
f'{confirmation}'
|
logger.debug(f'GATT Confirmation from client: {self._bearer_id} {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:
|
||||||
# Check the range
|
# Check the range
|
||||||
if mtu < ATT_DEFAULT_MTU:
|
if mtu < att.ATT_DEFAULT_MTU:
|
||||||
raise core.InvalidArgumentError(f'MTU must be >= {ATT_DEFAULT_MTU}')
|
raise core.InvalidArgumentError(f'MTU must be >= {att.ATT_DEFAULT_MTU}')
|
||||||
if mtu > 0xFFFF:
|
if mtu > 0xFFFF:
|
||||||
raise core.InvalidArgumentError('MTU must be <= 0xFFFF')
|
raise core.InvalidArgumentError('MTU must be <= 0xFFFF')
|
||||||
|
|
||||||
# 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
|
||||||
response = await self.send_request(ATT_Exchange_MTU_Request(client_rx_mtu=mtu))
|
response = await self.send_request(
|
||||||
if response.op_code == ATT_ERROR_RESPONSE:
|
att.ATT_Exchange_MTU_Request(client_rx_mtu=mtu)
|
||||||
raise ATT_Error(error_code=response.error_code, message=response)
|
)
|
||||||
|
if response.op_code == att.Opcode.ATT_ERROR_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 [
|
||||||
@@ -387,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
|
||||||
"""
|
"""
|
||||||
@@ -432,7 +467,7 @@ class Client:
|
|||||||
services = []
|
services = []
|
||||||
while starting_handle < 0xFFFF:
|
while starting_handle < 0xFFFF:
|
||||||
response = await self.send_request(
|
response = await self.send_request(
|
||||||
ATT_Read_By_Group_Type_Request(
|
att.ATT_Read_By_Group_Type_Request(
|
||||||
starting_handle=starting_handle,
|
starting_handle=starting_handle,
|
||||||
ending_handle=0xFFFF,
|
ending_handle=0xFFFF,
|
||||||
attribute_group_type=GATT_PRIMARY_SERVICE_ATTRIBUTE_TYPE,
|
attribute_group_type=GATT_PRIMARY_SERVICE_ATTRIBUTE_TYPE,
|
||||||
@@ -443,14 +478,14 @@ class Client:
|
|||||||
return []
|
return []
|
||||||
|
|
||||||
# Check if we reached the end of the iteration
|
# Check if we reached the end of the iteration
|
||||||
if response.op_code == ATT_ERROR_RESPONSE:
|
if response.op_code == att.Opcode.ATT_ERROR_RESPONSE:
|
||||||
if response.error_code != ATT_ATTRIBUTE_NOT_FOUND_ERROR:
|
if response.error_code != att.ATT_ATTRIBUTE_NOT_FOUND_ERROR:
|
||||||
# Unexpected end
|
# Unexpected end
|
||||||
logger.warning(
|
logger.warning(
|
||||||
'!!! unexpected error while discovering services: '
|
'!!! unexpected error while discovering services: '
|
||||||
f'{HCI_Constant.error_name(response.error_code)}'
|
f'{HCI_Constant.error_name(response.error_code)}'
|
||||||
)
|
)
|
||||||
raise ATT_Error(
|
raise att.ATT_Error(
|
||||||
error_code=response.error_code,
|
error_code=response.error_code,
|
||||||
message='Unexpected error while discovering services',
|
message='Unexpected error while discovering services',
|
||||||
)
|
)
|
||||||
@@ -496,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
|
||||||
'''
|
'''
|
||||||
@@ -509,7 +544,7 @@ class Client:
|
|||||||
services = []
|
services = []
|
||||||
while starting_handle < 0xFFFF:
|
while starting_handle < 0xFFFF:
|
||||||
response = await self.send_request(
|
response = await self.send_request(
|
||||||
ATT_Find_By_Type_Value_Request(
|
att.ATT_Find_By_Type_Value_Request(
|
||||||
starting_handle=starting_handle,
|
starting_handle=starting_handle,
|
||||||
ending_handle=0xFFFF,
|
ending_handle=0xFFFF,
|
||||||
attribute_type=GATT_PRIMARY_SERVICE_ATTRIBUTE_TYPE,
|
attribute_type=GATT_PRIMARY_SERVICE_ATTRIBUTE_TYPE,
|
||||||
@@ -521,8 +556,8 @@ class Client:
|
|||||||
return []
|
return []
|
||||||
|
|
||||||
# Check if we reached the end of the iteration
|
# Check if we reached the end of the iteration
|
||||||
if response.op_code == ATT_ERROR_RESPONSE:
|
if response.op_code == att.Opcode.ATT_ERROR_RESPONSE:
|
||||||
if response.error_code != ATT_ATTRIBUTE_NOT_FOUND_ERROR:
|
if response.error_code != att.ATT_ATTRIBUTE_NOT_FOUND_ERROR:
|
||||||
# Unexpected end
|
# Unexpected end
|
||||||
logger.warning(
|
logger.warning(
|
||||||
'!!! unexpected error while discovering services: '
|
'!!! unexpected error while discovering services: '
|
||||||
@@ -578,7 +613,7 @@ class Client:
|
|||||||
included_services: list[ServiceProxy] = []
|
included_services: list[ServiceProxy] = []
|
||||||
while starting_handle <= ending_handle:
|
while starting_handle <= ending_handle:
|
||||||
response = await self.send_request(
|
response = await self.send_request(
|
||||||
ATT_Read_By_Type_Request(
|
att.ATT_Read_By_Type_Request(
|
||||||
starting_handle=starting_handle,
|
starting_handle=starting_handle,
|
||||||
ending_handle=ending_handle,
|
ending_handle=ending_handle,
|
||||||
attribute_type=GATT_INCLUDE_ATTRIBUTE_TYPE,
|
attribute_type=GATT_INCLUDE_ATTRIBUTE_TYPE,
|
||||||
@@ -589,14 +624,14 @@ class Client:
|
|||||||
return []
|
return []
|
||||||
|
|
||||||
# Check if we reached the end of the iteration
|
# Check if we reached the end of the iteration
|
||||||
if response.op_code == ATT_ERROR_RESPONSE:
|
if response.op_code == att.Opcode.ATT_ERROR_RESPONSE:
|
||||||
if response.error_code != ATT_ATTRIBUTE_NOT_FOUND_ERROR:
|
if response.error_code != att.ATT_ATTRIBUTE_NOT_FOUND_ERROR:
|
||||||
# Unexpected end
|
# Unexpected end
|
||||||
logger.warning(
|
logger.warning(
|
||||||
'!!! unexpected error while discovering included services: '
|
'!!! unexpected error while discovering included services: '
|
||||||
f'{HCI_Constant.error_name(response.error_code)}'
|
f'{HCI_Constant.error_name(response.error_code)}'
|
||||||
)
|
)
|
||||||
raise ATT_Error(
|
raise att.ATT_Error(
|
||||||
error_code=response.error_code,
|
error_code=response.error_code,
|
||||||
message='Unexpected error while discovering included services',
|
message='Unexpected error while discovering included services',
|
||||||
)
|
)
|
||||||
@@ -630,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
|
||||||
@@ -652,7 +687,7 @@ class Client:
|
|||||||
characteristics: list[CharacteristicProxy[bytes]] = []
|
characteristics: list[CharacteristicProxy[bytes]] = []
|
||||||
while starting_handle <= ending_handle:
|
while starting_handle <= ending_handle:
|
||||||
response = await self.send_request(
|
response = await self.send_request(
|
||||||
ATT_Read_By_Type_Request(
|
att.ATT_Read_By_Type_Request(
|
||||||
starting_handle=starting_handle,
|
starting_handle=starting_handle,
|
||||||
ending_handle=ending_handle,
|
ending_handle=ending_handle,
|
||||||
attribute_type=GATT_CHARACTERISTIC_ATTRIBUTE_TYPE,
|
attribute_type=GATT_CHARACTERISTIC_ATTRIBUTE_TYPE,
|
||||||
@@ -663,14 +698,14 @@ class Client:
|
|||||||
return []
|
return []
|
||||||
|
|
||||||
# Check if we reached the end of the iteration
|
# Check if we reached the end of the iteration
|
||||||
if response.op_code == ATT_ERROR_RESPONSE:
|
if response.op_code == att.Opcode.ATT_ERROR_RESPONSE:
|
||||||
if response.error_code != ATT_ATTRIBUTE_NOT_FOUND_ERROR:
|
if response.error_code != att.ATT_ATTRIBUTE_NOT_FOUND_ERROR:
|
||||||
# Unexpected end
|
# Unexpected end
|
||||||
logger.warning(
|
logger.warning(
|
||||||
'!!! unexpected error while discovering characteristics: '
|
'!!! unexpected error while discovering characteristics: '
|
||||||
f'{HCI_Constant.error_name(response.error_code)}'
|
f'{HCI_Constant.error_name(response.error_code)}'
|
||||||
)
|
)
|
||||||
raise ATT_Error(
|
raise att.ATT_Error(
|
||||||
error_code=response.error_code,
|
error_code=response.error_code,
|
||||||
message='Unexpected error while discovering characteristics',
|
message='Unexpected error while discovering characteristics',
|
||||||
)
|
)
|
||||||
@@ -717,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
|
||||||
@@ -736,7 +771,7 @@ class Client:
|
|||||||
descriptors: list[DescriptorProxy] = []
|
descriptors: list[DescriptorProxy] = []
|
||||||
while starting_handle <= ending_handle:
|
while starting_handle <= ending_handle:
|
||||||
response = await self.send_request(
|
response = await self.send_request(
|
||||||
ATT_Find_Information_Request(
|
att.ATT_Find_Information_Request(
|
||||||
starting_handle=starting_handle, ending_handle=ending_handle
|
starting_handle=starting_handle, ending_handle=ending_handle
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
@@ -745,8 +780,8 @@ class Client:
|
|||||||
return []
|
return []
|
||||||
|
|
||||||
# Check if we reached the end of the iteration
|
# Check if we reached the end of the iteration
|
||||||
if response.op_code == ATT_ERROR_RESPONSE:
|
if response.op_code == att.Opcode.ATT_ERROR_RESPONSE:
|
||||||
if response.error_code != ATT_ATTRIBUTE_NOT_FOUND_ERROR:
|
if response.error_code != att.ATT_ATTRIBUTE_NOT_FOUND_ERROR:
|
||||||
# Unexpected end
|
# Unexpected end
|
||||||
logger.warning(
|
logger.warning(
|
||||||
'!!! unexpected error while discovering descriptors: '
|
'!!! unexpected error while discovering descriptors: '
|
||||||
@@ -791,7 +826,7 @@ class Client:
|
|||||||
attributes = []
|
attributes = []
|
||||||
while True:
|
while True:
|
||||||
response = await self.send_request(
|
response = await self.send_request(
|
||||||
ATT_Find_Information_Request(
|
att.ATT_Find_Information_Request(
|
||||||
starting_handle=starting_handle, ending_handle=ending_handle
|
starting_handle=starting_handle, ending_handle=ending_handle
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
@@ -799,8 +834,8 @@ class Client:
|
|||||||
return []
|
return []
|
||||||
|
|
||||||
# Check if we reached the end of the iteration
|
# Check if we reached the end of the iteration
|
||||||
if response.op_code == ATT_ERROR_RESPONSE:
|
if response.op_code == att.Opcode.ATT_ERROR_RESPONSE:
|
||||||
if response.error_code != ATT_ATTRIBUTE_NOT_FOUND_ERROR:
|
if response.error_code != att.ATT_ATTRIBUTE_NOT_FOUND_ERROR:
|
||||||
# Unexpected end
|
# Unexpected end
|
||||||
logger.warning(
|
logger.warning(
|
||||||
'!!! unexpected error while discovering attributes: '
|
'!!! unexpected error while discovering attributes: '
|
||||||
@@ -828,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,
|
||||||
@@ -878,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:
|
||||||
'''
|
'''
|
||||||
@@ -943,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
|
||||||
@@ -954,39 +989,41 @@ class Client:
|
|||||||
# Send a request to read
|
# Send a request to read
|
||||||
attribute_handle = attribute if isinstance(attribute, int) else attribute.handle
|
attribute_handle = attribute if isinstance(attribute, int) else attribute.handle
|
||||||
response = await self.send_request(
|
response = await self.send_request(
|
||||||
ATT_Read_Request(attribute_handle=attribute_handle)
|
att.ATT_Read_Request(attribute_handle=attribute_handle)
|
||||||
)
|
)
|
||||||
if response is None:
|
if response is None:
|
||||||
raise TimeoutError('read timeout')
|
raise TimeoutError('read timeout')
|
||||||
if response.op_code == ATT_ERROR_RESPONSE:
|
if response.op_code == att.Opcode.ATT_ERROR_RESPONSE:
|
||||||
raise ATT_Error(error_code=response.error_code, message=response)
|
raise att.ATT_Error(error_code=response.error_code, message=response)
|
||||||
|
|
||||||
# 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:
|
||||||
response = await self.send_request(
|
response = await self.send_request(
|
||||||
ATT_Read_Blob_Request(
|
att.ATT_Read_Blob_Request(
|
||||||
attribute_handle=attribute_handle, value_offset=offset
|
attribute_handle=attribute_handle, value_offset=offset
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
if response is None:
|
if response is None:
|
||||||
raise TimeoutError('read timeout')
|
raise TimeoutError('read timeout')
|
||||||
if response.op_code == ATT_ERROR_RESPONSE:
|
if response.op_code == att.Opcode.ATT_ERROR_RESPONSE:
|
||||||
if response.error_code in (
|
if response.error_code in (
|
||||||
ATT_ATTRIBUTE_NOT_LONG_ERROR,
|
att.ATT_ATTRIBUTE_NOT_LONG_ERROR,
|
||||||
ATT_INVALID_OFFSET_ERROR,
|
att.ATT_INVALID_OFFSET_ERROR,
|
||||||
):
|
):
|
||||||
break
|
break
|
||||||
raise ATT_Error(error_code=response.error_code, message=response)
|
raise att.ATT_Error(
|
||||||
|
error_code=response.error_code, message=response
|
||||||
|
)
|
||||||
|
|
||||||
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)
|
||||||
@@ -996,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
|
||||||
@@ -1012,7 +1049,7 @@ class Client:
|
|||||||
characteristics_values = []
|
characteristics_values = []
|
||||||
while starting_handle <= ending_handle:
|
while starting_handle <= ending_handle:
|
||||||
response = await self.send_request(
|
response = await self.send_request(
|
||||||
ATT_Read_By_Type_Request(
|
att.ATT_Read_By_Type_Request(
|
||||||
starting_handle=starting_handle,
|
starting_handle=starting_handle,
|
||||||
ending_handle=ending_handle,
|
ending_handle=ending_handle,
|
||||||
attribute_type=uuid,
|
attribute_type=uuid,
|
||||||
@@ -1023,8 +1060,8 @@ class Client:
|
|||||||
return []
|
return []
|
||||||
|
|
||||||
# Check if we reached the end of the iteration
|
# Check if we reached the end of the iteration
|
||||||
if response.op_code == ATT_ERROR_RESPONSE:
|
if response.op_code == att.Opcode.ATT_ERROR_RESPONSE:
|
||||||
if response.error_code != ATT_ATTRIBUTE_NOT_FOUND_ERROR:
|
if response.error_code != att.ATT_ATTRIBUTE_NOT_FOUND_ERROR:
|
||||||
# Unexpected end
|
# Unexpected end
|
||||||
logger.warning(
|
logger.warning(
|
||||||
'!!! unexpected error while reading characteristics: '
|
'!!! unexpected error while reading characteristics: '
|
||||||
@@ -1054,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:
|
||||||
@@ -1069,28 +1106,27 @@ class Client:
|
|||||||
attribute_handle = attribute if isinstance(attribute, int) else attribute.handle
|
attribute_handle = attribute if isinstance(attribute, int) else attribute.handle
|
||||||
if with_response:
|
if with_response:
|
||||||
response = await self.send_request(
|
response = await self.send_request(
|
||||||
ATT_Write_Request(
|
att.ATT_Write_Request(
|
||||||
attribute_handle=attribute_handle, attribute_value=value
|
attribute_handle=attribute_handle, attribute_value=value
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
if response.op_code == ATT_ERROR_RESPONSE:
|
if response.op_code == att.Opcode.ATT_ERROR_RESPONSE:
|
||||||
raise ATT_Error(error_code=response.error_code, message=response)
|
raise att.ATT_Error(error_code=response.error_code, message=response)
|
||||||
else:
|
else:
|
||||||
await self.send_command(
|
await self.send_command(
|
||||||
ATT_Write_Command(
|
att.ATT_Write_Command(
|
||||||
attribute_handle=attribute_handle, attribute_value=value
|
attribute_handle=attribute_handle, attribute_value=value
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
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_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_RESPONSES:
|
|
||||||
if self.pending_request is None:
|
if self.pending_request is None:
|
||||||
# Not expected!
|
# Not expected!
|
||||||
logger.warning('!!! unexpected response, there is no pending request')
|
logger.warning('!!! unexpected response, there is no pending request')
|
||||||
@@ -1098,7 +1134,7 @@ class Client:
|
|||||||
|
|
||||||
# The response should match the pending request unless it is
|
# The response should match the pending request unless it is
|
||||||
# an error response
|
# an error response
|
||||||
if att_pdu.op_code != ATT_ERROR_RESPONSE:
|
if att_pdu.op_code != att.Opcode.ATT_ERROR_RESPONSE:
|
||||||
expected_response_name = self.pending_request.name.replace(
|
expected_response_name = self.pending_request.name.replace(
|
||||||
'_REQUEST', '_RESPONSE'
|
'_REQUEST', '_RESPONSE'
|
||||||
)
|
)
|
||||||
@@ -1119,14 +1155,15 @@ 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)
|
||||||
)
|
)
|
||||||
|
|
||||||
def on_att_handle_value_notification(self, notification):
|
def on_att_handle_value_notification(
|
||||||
|
self, notification: att.ATT_Handle_Value_Notification
|
||||||
|
):
|
||||||
# Call all subscribers
|
# Call all subscribers
|
||||||
subscribers = self.notification_subscribers.get(
|
subscribers = self.notification_subscribers.get(
|
||||||
notification.attribute_handle, set()
|
notification.attribute_handle, set()
|
||||||
@@ -1141,7 +1178,9 @@ class Client:
|
|||||||
else:
|
else:
|
||||||
subscriber.emit(subscriber.EVENT_UPDATE, notification.attribute_value)
|
subscriber.emit(subscriber.EVENT_UPDATE, notification.attribute_value)
|
||||||
|
|
||||||
def on_att_handle_value_indication(self, indication):
|
def on_att_handle_value_indication(
|
||||||
|
self, indication: att.ATT_Handle_Value_Indication
|
||||||
|
):
|
||||||
# Call all subscribers
|
# Call all subscribers
|
||||||
subscribers = self.indication_subscribers.get(
|
subscribers = self.indication_subscribers.get(
|
||||||
indication.attribute_handle, set()
|
indication.attribute_handle, set()
|
||||||
@@ -1157,7 +1196,7 @@ class Client:
|
|||||||
subscriber.emit(subscriber.EVENT_UPDATE, indication.attribute_value)
|
subscriber.emit(subscriber.EVENT_UPDATE, indication.attribute_value)
|
||||||
|
|
||||||
# Confirm that we received the indication
|
# Confirm that we received the indication
|
||||||
self.send_confirmation(ATT_Handle_Value_Confirmation())
|
self.send_confirmation(att.ATT_Handle_Value_Confirmation())
|
||||||
|
|
||||||
def cache_value(self, attribute_handle: int, value: bytes) -> None:
|
def cache_value(self, attribute_handle: int, value: bytes) -> None:
|
||||||
self.cached_values[attribute_handle] = (
|
self.cached_values[attribute_handle] = (
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
2699
bumble/hci.py
2699
bumble/hci.py
File diff suppressed because it is too large
Load Diff
@@ -17,43 +17,36 @@
|
|||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from collections.abc import Callable, MutableMapping
|
|
||||||
import datetime
|
import datetime
|
||||||
from typing import cast, Any, Optional
|
|
||||||
import logging
|
import logging
|
||||||
|
from collections.abc import Callable, MutableMapping
|
||||||
|
from typing import Any, cast
|
||||||
|
|
||||||
from bumble import avc
|
from bumble import avc, avctp, avdtp, avrcp, crypto, rfcomm, sdp
|
||||||
from bumble import avctp
|
|
||||||
from bumble import avdtp
|
|
||||||
from bumble import avrcp
|
|
||||||
from bumble import crypto
|
|
||||||
from bumble import rfcomm
|
|
||||||
from bumble import sdp
|
|
||||||
from bumble.colors import color
|
|
||||||
from bumble.att import ATT_CID, ATT_PDU
|
from bumble.att import ATT_CID, ATT_PDU
|
||||||
from bumble.smp import SMP_CID, SMP_Command
|
from bumble.colors import color
|
||||||
from bumble.core import name_or_number
|
from bumble.core import name_or_number
|
||||||
from bumble.l2cap import (
|
|
||||||
CommandCode,
|
|
||||||
L2CAP_PDU,
|
|
||||||
L2CAP_SIGNALING_CID,
|
|
||||||
L2CAP_LE_SIGNALING_CID,
|
|
||||||
L2CAP_Control_Frame,
|
|
||||||
L2CAP_Connection_Request,
|
|
||||||
L2CAP_Connection_Response,
|
|
||||||
)
|
|
||||||
from bumble.hci import (
|
from bumble.hci import (
|
||||||
Address,
|
|
||||||
HCI_EVENT_PACKET,
|
|
||||||
HCI_ACL_DATA_PACKET,
|
HCI_ACL_DATA_PACKET,
|
||||||
HCI_DISCONNECTION_COMPLETE_EVENT,
|
HCI_DISCONNECTION_COMPLETE_EVENT,
|
||||||
HCI_AclDataPacketAssembler,
|
HCI_EVENT_PACKET,
|
||||||
HCI_Packet,
|
Address,
|
||||||
HCI_Event,
|
|
||||||
HCI_AclDataPacket,
|
HCI_AclDataPacket,
|
||||||
|
HCI_AclDataPacketAssembler,
|
||||||
HCI_Disconnection_Complete_Event,
|
HCI_Disconnection_Complete_Event,
|
||||||
|
HCI_Event,
|
||||||
|
HCI_Packet,
|
||||||
)
|
)
|
||||||
|
from bumble.l2cap import (
|
||||||
|
L2CAP_LE_SIGNALING_CID,
|
||||||
|
L2CAP_PDU,
|
||||||
|
L2CAP_SIGNALING_CID,
|
||||||
|
CommandCode,
|
||||||
|
L2CAP_Connection_Request,
|
||||||
|
L2CAP_Connection_Response,
|
||||||
|
L2CAP_Control_Frame,
|
||||||
|
)
|
||||||
|
from bumble.smp import SMP_CID, SMP_Command
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
# Logging
|
# Logging
|
||||||
@@ -77,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]
|
||||||
|
|
||||||
@@ -208,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(
|
||||||
@@ -237,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)
|
||||||
@@ -269,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)
|
||||||
|
|||||||
248
bumble/hfp.py
248
bumble/hfp.py
@@ -17,45 +17,35 @@
|
|||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
import collections
|
import collections
|
||||||
import collections.abc
|
import collections.abc
|
||||||
import logging
|
|
||||||
import asyncio
|
|
||||||
import dataclasses
|
import dataclasses
|
||||||
import enum
|
import enum
|
||||||
import traceback
|
import logging
|
||||||
import re
|
import re
|
||||||
from typing import (
|
import traceback
|
||||||
Union,
|
from collections.abc import Iterable
|
||||||
Any,
|
from typing import Any, ClassVar, Literal, overload
|
||||||
Optional,
|
|
||||||
ClassVar,
|
|
||||||
Iterable,
|
|
||||||
TYPE_CHECKING,
|
|
||||||
)
|
|
||||||
from typing_extensions import Self
|
from typing_extensions import Self
|
||||||
|
|
||||||
from bumble import at
|
from bumble import at, device, rfcomm, sdp, utils
|
||||||
from bumble import device
|
|
||||||
from bumble import rfcomm
|
|
||||||
from bumble import sdp
|
|
||||||
from bumble import utils
|
|
||||||
from bumble.colors import color
|
from bumble.colors import color
|
||||||
from bumble.core import (
|
from bumble.core import (
|
||||||
ProtocolError,
|
|
||||||
BT_GENERIC_AUDIO_SERVICE,
|
BT_GENERIC_AUDIO_SERVICE,
|
||||||
BT_HANDSFREE_SERVICE,
|
|
||||||
BT_HANDSFREE_AUDIO_GATEWAY_SERVICE,
|
BT_HANDSFREE_AUDIO_GATEWAY_SERVICE,
|
||||||
|
BT_HANDSFREE_SERVICE,
|
||||||
BT_L2CAP_PROTOCOL_ID,
|
BT_L2CAP_PROTOCOL_ID,
|
||||||
BT_RFCOMM_PROTOCOL_ID,
|
BT_RFCOMM_PROTOCOL_ID,
|
||||||
|
ProtocolError,
|
||||||
)
|
)
|
||||||
from bumble.hci import (
|
from bumble.hci import (
|
||||||
HCI_Enhanced_Setup_Synchronous_Connection_Command,
|
|
||||||
CodingFormat,
|
|
||||||
CodecID,
|
CodecID,
|
||||||
|
CodingFormat,
|
||||||
|
HCI_Enhanced_Setup_Synchronous_Connection_Command,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
# Logging
|
# Logging
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
@@ -78,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
|
||||||
@@ -91,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:
|
||||||
@@ -335,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
|
||||||
@@ -364,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:
|
||||||
@@ -430,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",
|
||||||
@@ -500,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
|
||||||
@@ -595,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
|
||||||
@@ -608,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:
|
||||||
@@ -737,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
|
||||||
|
|
||||||
@@ -764,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)
|
||||||
@@ -815,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.
|
||||||
@@ -845,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."""
|
||||||
@@ -1077,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:
|
||||||
@@ -1362,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."""
|
||||||
@@ -1422,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
|
||||||
@@ -1492,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 (
|
||||||
@@ -1600,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}'
|
||||||
@@ -1855,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:
|
||||||
@@ -1875,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:
|
||||||
@@ -1907,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:
|
||||||
@@ -1926,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:
|
||||||
|
|||||||
@@ -16,22 +16,20 @@
|
|||||||
# Imports
|
# Imports
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
from dataclasses import dataclass
|
|
||||||
import logging
|
|
||||||
import enum
|
|
||||||
import struct
|
|
||||||
|
|
||||||
|
import enum
|
||||||
|
import logging
|
||||||
|
import struct
|
||||||
from abc import ABC, abstractmethod
|
from abc import ABC, abstractmethod
|
||||||
from typing import Optional, Callable
|
from collections.abc import Callable
|
||||||
|
from dataclasses import dataclass
|
||||||
|
|
||||||
from typing_extensions import override
|
from typing_extensions import override
|
||||||
|
|
||||||
from bumble import l2cap
|
from bumble import device, l2cap, utils
|
||||||
from bumble import device
|
|
||||||
from bumble import utils
|
|
||||||
from bumble.core import InvalidStateError, ProtocolError
|
from bumble.core import InvalidStateError, ProtocolError
|
||||||
from bumble.hci import Address
|
from bumble.hci import Address
|
||||||
|
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
# Logging
|
# Logging
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
@@ -197,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"
|
||||||
@@ -214,38 +212,46 @@ 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
|
||||||
|
|
||||||
# Register ourselves with the L2CAP channel manager
|
# Register ourselves with the L2CAP channel manager
|
||||||
device.register_l2cap_server(HID_CONTROL_PSM, self.on_l2cap_connection)
|
device.create_l2cap_server(
|
||||||
device.register_l2cap_server(HID_INTERRUPT_PSM, self.on_l2cap_connection)
|
l2cap.ClassicChannelSpec(HID_CONTROL_PSM), self.on_l2cap_connection
|
||||||
|
)
|
||||||
|
device.create_l2cap_server(
|
||||||
|
l2cap.ClassicChannelSpec(HID_INTERRUPT_PSM), self.on_l2cap_connection
|
||||||
|
)
|
||||||
|
|
||||||
device.on(device.EVENT_CONNECTION, self.on_device_connection)
|
device.on(device.EVENT_CONNECTION, self.on_device_connection)
|
||||||
|
|
||||||
async def connect_control_channel(self) -> None:
|
async def connect_control_channel(self) -> None:
|
||||||
|
if not self.connection:
|
||||||
|
raise InvalidStateError("Connection is not established!")
|
||||||
# Create a new L2CAP connection - control channel
|
# Create a new L2CAP connection - control channel
|
||||||
try:
|
try:
|
||||||
channel = await self.device.l2cap_channel_manager.connect(
|
channel = await self.connection.create_l2cap_channel(
|
||||||
self.connection, HID_CONTROL_PSM
|
l2cap.ClassicChannelSpec(HID_CONTROL_PSM)
|
||||||
)
|
)
|
||||||
channel.sink = self.on_ctrl_pdu
|
channel.sink = self.on_ctrl_pdu
|
||||||
self.l2cap_ctrl_channel = channel
|
self.l2cap_ctrl_channel = channel
|
||||||
except ProtocolError:
|
except ProtocolError:
|
||||||
logging.exception(f'L2CAP connection failed.')
|
logging.exception('L2CAP connection failed.')
|
||||||
raise
|
raise
|
||||||
|
|
||||||
async def connect_interrupt_channel(self) -> None:
|
async def connect_interrupt_channel(self) -> None:
|
||||||
|
if not self.connection:
|
||||||
|
raise InvalidStateError("Connection is not established!")
|
||||||
# Create a new L2CAP connection - interrupt channel
|
# Create a new L2CAP connection - interrupt channel
|
||||||
try:
|
try:
|
||||||
channel = await self.device.l2cap_channel_manager.connect(
|
channel = await self.connection.create_l2cap_channel(
|
||||||
self.connection, HID_INTERRUPT_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
|
||||||
except ProtocolError:
|
except ProtocolError:
|
||||||
logging.exception(f'L2CAP connection failed.')
|
logging.exception('L2CAP connection failed.')
|
||||||
raise
|
raise
|
||||||
|
|
||||||
async def disconnect_interrupt_channel(self) -> None:
|
async def disconnect_interrupt_channel(self) -> None:
|
||||||
@@ -306,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:
|
||||||
@@ -347,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)
|
||||||
|
|||||||
886
bumble/host.py
886
bumble/host.py
File diff suppressed because it is too large
Load Diff
@@ -21,16 +21,19 @@
|
|||||||
# Imports
|
# Imports
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
import dataclasses
|
import dataclasses
|
||||||
|
import json
|
||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
import json
|
import pathlib
|
||||||
from typing import TYPE_CHECKING, Optional, Any
|
from typing import TYPE_CHECKING, Any
|
||||||
|
|
||||||
from typing_extensions import Self
|
from typing_extensions import Self
|
||||||
|
|
||||||
from bumble.colors import color
|
|
||||||
from bumble import hci
|
from bumble import hci
|
||||||
|
from bumble.colors import color
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from bumble.device import Device
|
from bumble.device import Device
|
||||||
@@ -49,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:
|
||||||
@@ -72,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
|
||||||
@@ -154,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]]:
|
||||||
@@ -246,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
|
||||||
@@ -291,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 = {}
|
||||||
@@ -310,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)
|
||||||
|
|
||||||
@@ -332,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
|
||||||
@@ -368,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]]:
|
||||||
|
|||||||
1531
bumble/l2cap.py
1531
bumble/l2cap.py
File diff suppressed because it is too large
Load Diff
343
bumble/link.py
343
bumble/link.py
@@ -11,26 +11,20 @@
|
|||||||
# 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
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
# Imports
|
# Imports
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
import logging
|
import logging
|
||||||
import asyncio
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
from bumble import core
|
from bumble import core, hci, ll, lmp
|
||||||
from bumble.hci import (
|
|
||||||
Address,
|
|
||||||
Role,
|
|
||||||
HCI_SUCCESS,
|
|
||||||
HCI_CONNECTION_ACCEPT_TIMEOUT_ERROR,
|
|
||||||
HCI_UNKNOWN_CONNECTION_IDENTIFIER_ERROR,
|
|
||||||
HCI_PAGE_TIMEOUT_ERROR,
|
|
||||||
HCI_Connection_Complete_Event,
|
|
||||||
)
|
|
||||||
from bumble import controller
|
|
||||||
|
|
||||||
from typing import Optional
|
if TYPE_CHECKING:
|
||||||
|
from bumble import controller
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
# Logging
|
# Logging
|
||||||
@@ -38,18 +32,6 @@ from typing import Optional
|
|||||||
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)
|
||||||
@@ -63,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
|
||||||
############################################################
|
############################################################
|
||||||
@@ -101,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)
|
||||||
@@ -119,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, central_address, peripheral_address, disconnect_command
|
|
||||||
):
|
):
|
||||||
# Find the controller that initiated the disconnection
|
loop = asyncio.get_running_loop()
|
||||||
if not (central_controller := self.find_controller(central_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 peripheral_controller := self.find_controller(peripheral_address):
|
self,
|
||||||
peripheral_controller.on_link_central_disconnected(
|
sender_address: hci.Address,
|
||||||
central_address, disconnect_command.reason
|
receiver_address: hci.Address,
|
||||||
)
|
packet: ll.ControlPdu,
|
||||||
|
|
||||||
central_controller.on_link_peripheral_disconnection_complete(
|
|
||||||
disconnect_command, HCI_SUCCESS
|
|
||||||
)
|
|
||||||
|
|
||||||
def disconnect(self, central_address, peripheral_address, disconnect_command):
|
|
||||||
logger.debug(
|
|
||||||
f'$$$ DISCONNECTION {central_address} -> '
|
|
||||||
f'{peripheral_address}: reason = {disconnect_command.reason}'
|
|
||||||
)
|
|
||||||
args = [central_address, peripheral_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))
|
||||||
65
bumble/logging.py
Normal file
65
bumble/logging.py
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
# Copyright 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
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
import functools
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
|
||||||
|
from bumble import colors
|
||||||
|
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
class ColorFormatter(logging.Formatter):
|
||||||
|
_colorizers = {
|
||||||
|
logging.DEBUG: functools.partial(colors.color, fg="white"),
|
||||||
|
logging.INFO: functools.partial(colors.color, fg="green"),
|
||||||
|
logging.WARNING: functools.partial(colors.color, fg="yellow"),
|
||||||
|
logging.ERROR: functools.partial(colors.color, fg="red"),
|
||||||
|
logging.CRITICAL: functools.partial(colors.color, fg="black", bg="red"),
|
||||||
|
}
|
||||||
|
|
||||||
|
_formatters = {
|
||||||
|
level: logging.Formatter(
|
||||||
|
fmt=colorizer("{asctime}.{msecs:03.0f} {levelname:.1} {name}: ")
|
||||||
|
+ "{message}",
|
||||||
|
datefmt="%H:%M:%S",
|
||||||
|
style="{",
|
||||||
|
)
|
||||||
|
for level, colorizer in _colorizers.items()
|
||||||
|
}
|
||||||
|
|
||||||
|
def format(self, record: logging.LogRecord) -> str:
|
||||||
|
return self._formatters[record.levelno].format(record)
|
||||||
|
|
||||||
|
|
||||||
|
def setup_basic_logging(default_level: str = "INFO") -> None:
|
||||||
|
"""
|
||||||
|
Set up basic logging with logging.basicConfig, configured with a simple formatter
|
||||||
|
that prints out the date and log level in color.
|
||||||
|
If the BUMBLE_LOGLEVEL environment variable is set to the name of a log level, it
|
||||||
|
is used. Otherwise the default_level argument is used.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
default_level: default logging level
|
||||||
|
|
||||||
|
"""
|
||||||
|
handler = logging.StreamHandler()
|
||||||
|
handler.setFormatter(ColorFormatter())
|
||||||
|
logging.basicConfig(
|
||||||
|
level=os.environ.get("BUMBLE_LOGLEVEL", default_level).upper(),
|
||||||
|
handlers=[handler],
|
||||||
|
)
|
||||||
@@ -16,27 +16,18 @@
|
|||||||
# Imports
|
# Imports
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
import enum
|
|
||||||
from dataclasses import dataclass
|
|
||||||
import secrets
|
|
||||||
from typing import Optional
|
|
||||||
|
|
||||||
from bumble import hci
|
import enum
|
||||||
|
import secrets
|
||||||
|
from dataclasses import dataclass
|
||||||
|
|
||||||
|
from bumble import hci, smp
|
||||||
|
from bumble.core import AdvertisingData, LeRole
|
||||||
from bumble.smp import (
|
from bumble.smp import (
|
||||||
SMP_NO_INPUT_NO_OUTPUT_IO_CAPABILITY,
|
|
||||||
SMP_KEYBOARD_ONLY_IO_CAPABILITY,
|
|
||||||
SMP_DISPLAY_ONLY_IO_CAPABILITY,
|
|
||||||
SMP_DISPLAY_YES_NO_IO_CAPABILITY,
|
|
||||||
SMP_KEYBOARD_DISPLAY_IO_CAPABILITY,
|
|
||||||
SMP_ENC_KEY_DISTRIBUTION_FLAG,
|
|
||||||
SMP_ID_KEY_DISTRIBUTION_FLAG,
|
|
||||||
SMP_SIGN_KEY_DISTRIBUTION_FLAG,
|
|
||||||
SMP_LINK_KEY_DISTRIBUTION_FLAG,
|
|
||||||
OobContext,
|
OobContext,
|
||||||
OobLegacyContext,
|
OobLegacyContext,
|
||||||
OobSharedData,
|
OobSharedData,
|
||||||
)
|
)
|
||||||
from bumble.core import AdvertisingData, LeRole
|
|
||||||
|
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
@@ -44,16 +35,16 @@ from bumble.core import AdvertisingData, LeRole
|
|||||||
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)
|
||||||
@@ -96,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
|
||||||
@@ -111,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
|
||||||
@@ -180,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.
|
||||||
"""
|
"""
|
||||||
@@ -238,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,21 +19,22 @@ This module implement the Pandora Bluetooth test APIs for the Bumble stack.
|
|||||||
|
|
||||||
__version__ = "0.0.1"
|
__version__ = "0.0.1"
|
||||||
|
|
||||||
|
from collections.abc import Callable
|
||||||
|
|
||||||
import grpc
|
import grpc
|
||||||
import grpc.aio
|
import grpc.aio
|
||||||
|
|
||||||
from bumble.pandora.config import Config
|
|
||||||
from bumble.pandora.device import PandoraDevice
|
|
||||||
from bumble.pandora.host import HostService
|
|
||||||
from bumble.pandora.l2cap import L2CAPService
|
|
||||||
from bumble.pandora.security import SecurityService, SecurityStorageService
|
|
||||||
from pandora.host_grpc_aio import add_HostServicer_to_server
|
from pandora.host_grpc_aio import add_HostServicer_to_server
|
||||||
from pandora.l2cap_grpc_aio import add_L2CAPServicer_to_server
|
from pandora.l2cap_grpc_aio import add_L2CAPServicer_to_server
|
||||||
from pandora.security_grpc_aio import (
|
from pandora.security_grpc_aio import (
|
||||||
add_SecurityServicer_to_server,
|
add_SecurityServicer_to_server,
|
||||||
add_SecurityStorageServicer_to_server,
|
add_SecurityStorageServicer_to_server,
|
||||||
)
|
)
|
||||||
from typing import Callable, List, Optional
|
|
||||||
|
from bumble.pandora.config import Config
|
||||||
|
from bumble.pandora.device import PandoraDevice
|
||||||
|
from bumble.pandora.host import HostService
|
||||||
|
from bumble.pandora.l2cap import L2CAPService
|
||||||
|
from bumble.pandora.security import SecurityService, SecurityStorageService
|
||||||
|
|
||||||
# public symbols
|
# public symbols
|
||||||
__all__ = [
|
__all__ = [
|
||||||
@@ -49,7 +50,7 @@ _SERVICERS_HOOKS: list[Callable[[PandoraDevice, Config, grpc.aio.Server], None]]
|
|||||||
|
|
||||||
|
|
||||||
def register_servicer_hook(
|
def register_servicer_hook(
|
||||||
hook: Callable[[PandoraDevice, Config, grpc.aio.Server], None]
|
hook: Callable[[PandoraDevice, Config, grpc.aio.Server], None],
|
||||||
) -> None:
|
) -> None:
|
||||||
_SERVICERS_HOOKS.append(hook)
|
_SERVICERS_HOOKS.append(hook)
|
||||||
|
|
||||||
@@ -57,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.
|
||||||
|
|||||||
@@ -13,10 +13,12 @@
|
|||||||
# limitations under the License.
|
# limitations under the License.
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
from bumble.pairing import PairingConfig, PairingDelegate
|
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
|
from bumble.pairing import PairingConfig, PairingDelegate
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class Config:
|
class Config:
|
||||||
|
|||||||
@@ -15,6 +15,9 @@
|
|||||||
"""Generic & dependency free Bumble (reference) device."""
|
"""Generic & dependency free Bumble (reference) device."""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
from bumble import transport
|
from bumble import transport
|
||||||
from bumble.core import (
|
from bumble.core import (
|
||||||
BT_GENERIC_AUDIO_SERVICE,
|
BT_GENERIC_AUDIO_SERVICE,
|
||||||
@@ -32,8 +35,6 @@ from bumble.sdp import (
|
|||||||
DataElement,
|
DataElement,
|
||||||
ServiceAttribute,
|
ServiceAttribute,
|
||||||
)
|
)
|
||||||
from typing import Any, Optional
|
|
||||||
|
|
||||||
|
|
||||||
# Default rootcanal HCI TCP address
|
# Default rootcanal HCI TCP address
|
||||||
ROOTCANAL_HCI_ADDRESS = "localhost:6402"
|
ROOTCANAL_HCI_ADDRESS = "localhost:6402"
|
||||||
@@ -53,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
|
||||||
@@ -73,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()
|
||||||
@@ -95,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),
|
||||||
|
|||||||
@@ -13,51 +13,26 @@
|
|||||||
# limitations under the License.
|
# limitations under the License.
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
import bumble.device
|
|
||||||
import grpc
|
|
||||||
import grpc.aio
|
|
||||||
import logging
|
import logging
|
||||||
import struct
|
import struct
|
||||||
|
from collections.abc import AsyncGenerator
|
||||||
|
from typing import cast
|
||||||
|
|
||||||
import bumble.utils
|
import grpc
|
||||||
from bumble.pandora import utils
|
import grpc.aio
|
||||||
from bumble.pandora.config import Config
|
from google.protobuf import (
|
||||||
from bumble.core import (
|
any_pb2, # pytype: disable=pyi-error
|
||||||
PhysicalTransport,
|
empty_pb2, # pytype: disable=pyi-error
|
||||||
UUID,
|
|
||||||
AdvertisingData,
|
|
||||||
Appearance,
|
|
||||||
ConnectionError,
|
|
||||||
)
|
)
|
||||||
from bumble.device import (
|
|
||||||
DEVICE_DEFAULT_SCAN_INTERVAL,
|
|
||||||
DEVICE_DEFAULT_SCAN_WINDOW,
|
|
||||||
Advertisement,
|
|
||||||
AdvertisingParameters,
|
|
||||||
AdvertisingEventProperties,
|
|
||||||
AdvertisingType,
|
|
||||||
Device,
|
|
||||||
)
|
|
||||||
from bumble.gatt import Service
|
|
||||||
from bumble.hci import (
|
|
||||||
HCI_CONNECTION_ALREADY_EXISTS_ERROR,
|
|
||||||
HCI_PAGE_TIMEOUT_ERROR,
|
|
||||||
HCI_REMOTE_USER_TERMINATED_CONNECTION_ERROR,
|
|
||||||
Address,
|
|
||||||
Phy,
|
|
||||||
Role,
|
|
||||||
OwnAddressType,
|
|
||||||
)
|
|
||||||
from google.protobuf import any_pb2 # pytype: disable=pyi-error
|
|
||||||
from google.protobuf import empty_pb2 # pytype: disable=pyi-error
|
|
||||||
from pandora.host_grpc_aio import HostServicer
|
|
||||||
from pandora import host_pb2
|
from pandora import host_pb2
|
||||||
|
from pandora.host_grpc_aio import HostServicer
|
||||||
from pandora.host_pb2 import (
|
from pandora.host_pb2 import (
|
||||||
|
DISCOVERABLE_GENERAL,
|
||||||
|
DISCOVERABLE_LIMITED,
|
||||||
NOT_CONNECTABLE,
|
NOT_CONNECTABLE,
|
||||||
NOT_DISCOVERABLE,
|
NOT_DISCOVERABLE,
|
||||||
DISCOVERABLE_LIMITED,
|
|
||||||
DISCOVERABLE_GENERAL,
|
|
||||||
PRIMARY_1M,
|
PRIMARY_1M,
|
||||||
PRIMARY_CODED,
|
PRIMARY_CODED,
|
||||||
SECONDARY_1M,
|
SECONDARY_1M,
|
||||||
@@ -85,7 +60,37 @@ from pandora.host_pb2 import (
|
|||||||
WaitConnectionResponse,
|
WaitConnectionResponse,
|
||||||
WaitDisconnectionRequest,
|
WaitDisconnectionRequest,
|
||||||
)
|
)
|
||||||
from typing import AsyncGenerator, Optional, cast
|
|
||||||
|
import bumble.device
|
||||||
|
import bumble.utils
|
||||||
|
from bumble.core import (
|
||||||
|
UUID,
|
||||||
|
AdvertisingData,
|
||||||
|
Appearance,
|
||||||
|
ConnectionError,
|
||||||
|
PhysicalTransport,
|
||||||
|
)
|
||||||
|
from bumble.device import (
|
||||||
|
DEVICE_DEFAULT_SCAN_INTERVAL,
|
||||||
|
DEVICE_DEFAULT_SCAN_WINDOW,
|
||||||
|
Advertisement,
|
||||||
|
AdvertisingEventProperties,
|
||||||
|
AdvertisingParameters,
|
||||||
|
AdvertisingType,
|
||||||
|
Device,
|
||||||
|
)
|
||||||
|
from bumble.gatt import Service
|
||||||
|
from bumble.hci import (
|
||||||
|
HCI_CONNECTION_ALREADY_EXISTS_ERROR,
|
||||||
|
HCI_PAGE_TIMEOUT_ERROR,
|
||||||
|
HCI_REMOTE_USER_TERMINATED_CONNECTION_ERROR,
|
||||||
|
Address,
|
||||||
|
OwnAddressType,
|
||||||
|
Phy,
|
||||||
|
Role,
|
||||||
|
)
|
||||||
|
from bumble.pandora import utils
|
||||||
|
from bumble.pandora.config import Config
|
||||||
|
|
||||||
PRIMARY_PHY_MAP: dict[int, PrimaryPhy] = {
|
PRIMARY_PHY_MAP: dict[int, PrimaryPhy] = {
|
||||||
# Default value reported by Bumble for legacy Advertising reports.
|
# Default value reported by Bumble for legacy Advertising reports.
|
||||||
@@ -300,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()
|
||||||
|
|
||||||
@@ -537,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
|
||||||
@@ -607,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
|
||||||
@@ -617,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)
|
||||||
@@ -642,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
|
||||||
|
|||||||
@@ -12,31 +12,21 @@
|
|||||||
# 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
|
from __future__ import annotations
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
import grpc
|
|
||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
|
from asyncio import Future
|
||||||
|
from asyncio import Queue as AsyncQueue
|
||||||
|
from collections.abc import AsyncGenerator
|
||||||
|
from dataclasses import dataclass
|
||||||
|
|
||||||
from asyncio import Queue as AsyncQueue, Future
|
import grpc
|
||||||
|
|
||||||
from bumble.pandora import utils
|
|
||||||
from bumble.pandora.config import Config
|
|
||||||
from bumble.core import OutOfResourcesError, InvalidArgumentError
|
|
||||||
from bumble.device import Device
|
|
||||||
from bumble.l2cap import (
|
|
||||||
ClassicChannel,
|
|
||||||
ClassicChannelServer,
|
|
||||||
ClassicChannelSpec,
|
|
||||||
LeCreditBasedChannel,
|
|
||||||
LeCreditBasedChannelServer,
|
|
||||||
LeCreditBasedChannelSpec,
|
|
||||||
)
|
|
||||||
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 ( # pytype: disable=pyi-error
|
from pandora.l2cap_pb2 import (
|
||||||
COMMAND_NOT_UNDERSTOOD,
|
COMMAND_NOT_UNDERSTOOD,
|
||||||
INVALID_CID_IN_REQUEST,
|
INVALID_CID_IN_REQUEST,
|
||||||
Channel as PandoraChannel,
|
|
||||||
ConnectRequest,
|
ConnectRequest,
|
||||||
ConnectResponse,
|
ConnectResponse,
|
||||||
CreditBasedChannelRequest,
|
CreditBasedChannelRequest,
|
||||||
@@ -51,10 +41,22 @@ from pandora.l2cap_pb2 import ( # pytype: disable=pyi-error
|
|||||||
WaitDisconnectionRequest,
|
WaitDisconnectionRequest,
|
||||||
WaitDisconnectionResponse,
|
WaitDisconnectionResponse,
|
||||||
)
|
)
|
||||||
from typing import AsyncGenerator, Optional, Union
|
from pandora.l2cap_pb2 import Channel as PandoraChannel # pytype: disable=pyi-error
|
||||||
from dataclasses import dataclass
|
|
||||||
|
|
||||||
L2capChannel = Union[ClassicChannel, LeCreditBasedChannel]
|
from bumble.core import InvalidArgumentError, OutOfResourcesError
|
||||||
|
from bumble.device import Device
|
||||||
|
from bumble.l2cap import (
|
||||||
|
ClassicChannel,
|
||||||
|
ClassicChannelServer,
|
||||||
|
ClassicChannelSpec,
|
||||||
|
LeCreditBasedChannel,
|
||||||
|
LeCreditBasedChannelServer,
|
||||||
|
LeCreditBasedChannelSpec,
|
||||||
|
)
|
||||||
|
from bumble.pandora import utils
|
||||||
|
from bumble.pandora.config import Config
|
||||||
|
|
||||||
|
L2capChannel = ClassicChannel | LeCreditBasedChannel
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
@@ -105,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,
|
||||||
@@ -215,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,
|
||||||
@@ -278,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())
|
||||||
|
|||||||
@@ -13,27 +13,19 @@
|
|||||||
# limitations under the License.
|
# limitations under the License.
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
import contextlib
|
import contextlib
|
||||||
from collections.abc import Awaitable
|
|
||||||
import grpc
|
|
||||||
import logging
|
import logging
|
||||||
|
from collections.abc import AsyncGenerator, AsyncIterator, Awaitable, Callable
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
from bumble.pandora import utils
|
import grpc
|
||||||
from bumble.pandora.config import Config
|
from google.protobuf import (
|
||||||
from bumble import hci
|
any_pb2, # pytype: disable=pyi-error
|
||||||
from bumble.core import (
|
empty_pb2, # pytype: disable=pyi-error
|
||||||
PhysicalTransport,
|
wrappers_pb2, # pytype: disable=pyi-error
|
||||||
ProtocolError,
|
|
||||||
InvalidArgumentError,
|
|
||||||
)
|
)
|
||||||
import bumble.utils
|
|
||||||
from bumble.device import Connection as BumbleConnection, Device
|
|
||||||
from bumble.hci import HCI_Error, Role
|
|
||||||
from bumble.pairing import PairingConfig, PairingDelegate as BasePairingDelegate
|
|
||||||
from google.protobuf import any_pb2 # pytype: disable=pyi-error
|
|
||||||
from google.protobuf import empty_pb2 # pytype: disable=pyi-error
|
|
||||||
from google.protobuf import 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 (
|
||||||
@@ -57,14 +49,24 @@ from pandora.security_pb2 import (
|
|||||||
WaitSecurityRequest,
|
WaitSecurityRequest,
|
||||||
WaitSecurityResponse,
|
WaitSecurityResponse,
|
||||||
)
|
)
|
||||||
from typing import Any, AsyncGenerator, AsyncIterator, Callable, Optional, Union
|
|
||||||
|
import bumble.utils
|
||||||
|
from bumble import hci
|
||||||
|
from bumble.core import InvalidArgumentError, PhysicalTransport, ProtocolError
|
||||||
|
from bumble.device import Connection as BumbleConnection
|
||||||
|
from bumble.device import Device
|
||||||
|
from bumble.hci import HCI_Error, Role
|
||||||
|
from bumble.pairing import PairingConfig
|
||||||
|
from bumble.pairing import PairingDelegate as BasePairingDelegate
|
||||||
|
from bumble.pandora import utils
|
||||||
|
from bumble.pandora.config import Config
|
||||||
|
|
||||||
|
|
||||||
class PairingDelegate(BasePairingDelegate):
|
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(
|
||||||
|
|||||||
@@ -13,16 +13,19 @@
|
|||||||
# limitations under the License.
|
# limitations under the License.
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import contextlib
|
import contextlib
|
||||||
import functools
|
import functools
|
||||||
import grpc
|
|
||||||
import inspect
|
import inspect
|
||||||
import logging
|
import logging
|
||||||
|
from collections.abc import Generator, MutableMapping
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import grpc
|
||||||
|
from google.protobuf.message import Message # pytype: disable=pyi-error
|
||||||
|
|
||||||
from bumble.device import Device
|
from bumble.device import Device
|
||||||
from bumble.hci import Address, AddressType
|
from bumble.hci import Address, AddressType
|
||||||
from google.protobuf.message import Message # pytype: disable=pyi-error
|
|
||||||
from typing import Any, Generator, MutableMapping, Optional
|
|
||||||
|
|
||||||
ADDRESS_TYPES: dict[str, AddressType] = {
|
ADDRESS_TYPES: dict[str, AddressType] = {
|
||||||
"public": Address.PUBLIC_DEVICE_ADDRESS,
|
"public": Address.PUBLIC_DEVICE_ADDRESS,
|
||||||
@@ -32,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])
|
||||||
@@ -93,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:
|
||||||
|
|||||||
@@ -18,26 +18,26 @@
|
|||||||
# Imports
|
# Imports
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
from __future__ import annotations
|
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.device import Connection
|
from bumble import utils
|
||||||
from bumble.att import ATT_Error
|
from bumble.att import ATT_Error
|
||||||
|
from bumble.device import Connection
|
||||||
from bumble.gatt import (
|
from bumble.gatt import (
|
||||||
|
GATT_AUDIO_INPUT_CONTROL_POINT_CHARACTERISTIC,
|
||||||
|
GATT_AUDIO_INPUT_CONTROL_SERVICE,
|
||||||
|
GATT_AUDIO_INPUT_DESCRIPTION_CHARACTERISTIC,
|
||||||
|
GATT_AUDIO_INPUT_STATE_CHARACTERISTIC,
|
||||||
|
GATT_AUDIO_INPUT_STATUS_CHARACTERISTIC,
|
||||||
|
GATT_AUDIO_INPUT_TYPE_CHARACTERISTIC,
|
||||||
|
GATT_GAIN_SETTINGS_ATTRIBUTE_CHARACTERISTIC,
|
||||||
Attribute,
|
Attribute,
|
||||||
Characteristic,
|
Characteristic,
|
||||||
TemplateService,
|
|
||||||
CharacteristicValue,
|
CharacteristicValue,
|
||||||
GATT_AUDIO_INPUT_CONTROL_SERVICE,
|
TemplateService,
|
||||||
GATT_AUDIO_INPUT_STATE_CHARACTERISTIC,
|
|
||||||
GATT_GAIN_SETTINGS_ATTRIBUTE_CHARACTERISTIC,
|
|
||||||
GATT_AUDIO_INPUT_TYPE_CHARACTERISTIC,
|
|
||||||
GATT_AUDIO_INPUT_STATUS_CHARACTERISTIC,
|
|
||||||
GATT_AUDIO_INPUT_CONTROL_POINT_CHARACTERISTIC,
|
|
||||||
GATT_AUDIO_INPUT_DESCRIPTION_CHARACTERISTIC,
|
|
||||||
)
|
)
|
||||||
from bumble.gatt_adapters import (
|
from bumble.gatt_adapters import (
|
||||||
CharacteristicProxy,
|
CharacteristicProxy,
|
||||||
@@ -48,7 +48,6 @@ from bumble.gatt_adapters import (
|
|||||||
UTF8CharacteristicProxyAdapter,
|
UTF8CharacteristicProxyAdapter,
|
||||||
)
|
)
|
||||||
from bumble.gatt_client import ProfileServiceProxy, ServiceProxy
|
from bumble.gatt_client import ProfileServiceProxy, ServiceProxy
|
||||||
from bumble import utils
|
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
# Logging
|
# Logging
|
||||||
@@ -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
|
||||||
|
|||||||
401
bumble/profiles/ams.py
Normal file
401
bumble/profiles/ams.py
Normal file
@@ -0,0 +1,401 @@
|
|||||||
|
# Copyright 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.
|
||||||
|
|
||||||
|
"""
|
||||||
|
Apple Media Service (AMS).
|
||||||
|
"""
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
# Imports
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import dataclasses
|
||||||
|
import enum
|
||||||
|
import logging
|
||||||
|
from collections.abc import Iterable
|
||||||
|
|
||||||
|
from bumble import utils
|
||||||
|
from bumble.device import Peer
|
||||||
|
from bumble.gatt import (
|
||||||
|
GATT_AMS_ENTITY_ATTRIBUTE_CHARACTERISTIC,
|
||||||
|
GATT_AMS_ENTITY_UPDATE_CHARACTERISTIC,
|
||||||
|
GATT_AMS_REMOTE_COMMAND_CHARACTERISTIC,
|
||||||
|
GATT_AMS_SERVICE,
|
||||||
|
Characteristic,
|
||||||
|
TemplateService,
|
||||||
|
)
|
||||||
|
from bumble.gatt_client import CharacteristicProxy, ProfileServiceProxy, ServiceProxy
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
# Logging
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
# Protocol
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
class RemoteCommandId(utils.OpenIntEnum):
|
||||||
|
PLAY = 0
|
||||||
|
PAUSE = 1
|
||||||
|
TOGGLE_PLAY_PAUSE = 2
|
||||||
|
NEXT_TRACK = 3
|
||||||
|
PREVIOUS_TRACK = 4
|
||||||
|
VOLUME_UP = 5
|
||||||
|
VOLUME_DOWN = 6
|
||||||
|
ADVANCE_REPEAT_MODE = 7
|
||||||
|
ADVANCE_SHUFFLE_MODE = 8
|
||||||
|
SKIP_FORWARD = 9
|
||||||
|
SKIP_BACKWARD = 10
|
||||||
|
LIKE_TRACK = 11
|
||||||
|
DISLIKE_TRACK = 12
|
||||||
|
BOOKMARK_TRACK = 13
|
||||||
|
|
||||||
|
|
||||||
|
class EntityId(utils.OpenIntEnum):
|
||||||
|
PLAYER = 0
|
||||||
|
QUEUE = 1
|
||||||
|
TRACK = 2
|
||||||
|
|
||||||
|
|
||||||
|
class ActionId(utils.OpenIntEnum):
|
||||||
|
POSITIVE = 0
|
||||||
|
NEGATIVE = 1
|
||||||
|
|
||||||
|
|
||||||
|
class EntityUpdateFlags(enum.IntFlag):
|
||||||
|
TRUNCATED = 1
|
||||||
|
|
||||||
|
|
||||||
|
class PlayerAttributeId(utils.OpenIntEnum):
|
||||||
|
NAME = 0
|
||||||
|
PLAYBACK_INFO = 1
|
||||||
|
VOLUME = 2
|
||||||
|
|
||||||
|
|
||||||
|
class QueueAttributeId(utils.OpenIntEnum):
|
||||||
|
INDEX = 0
|
||||||
|
COUNT = 1
|
||||||
|
SHUFFLE_MODE = 2
|
||||||
|
REPEAT_MODE = 3
|
||||||
|
|
||||||
|
|
||||||
|
class ShuffleMode(utils.OpenIntEnum):
|
||||||
|
OFF = 0
|
||||||
|
ONE = 1
|
||||||
|
ALL = 2
|
||||||
|
|
||||||
|
|
||||||
|
class RepeatMode(utils.OpenIntEnum):
|
||||||
|
OFF = 0
|
||||||
|
ONE = 1
|
||||||
|
ALL = 2
|
||||||
|
|
||||||
|
|
||||||
|
class TrackAttributeId(utils.OpenIntEnum):
|
||||||
|
ARTIST = 0
|
||||||
|
ALBUM = 1
|
||||||
|
TITLE = 2
|
||||||
|
DURATION = 3
|
||||||
|
|
||||||
|
|
||||||
|
class PlaybackState(utils.OpenIntEnum):
|
||||||
|
PAUSED = 0
|
||||||
|
PLAYING = 1
|
||||||
|
REWINDING = 2
|
||||||
|
FAST_FORWARDING = 3
|
||||||
|
|
||||||
|
|
||||||
|
@dataclasses.dataclass
|
||||||
|
class PlaybackInfo:
|
||||||
|
playback_state: PlaybackState = PlaybackState.PAUSED
|
||||||
|
playback_rate: float = 1.0
|
||||||
|
elapsed_time: float = 0.0
|
||||||
|
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
# GATT Server-side
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
class Ams(TemplateService):
|
||||||
|
UUID = GATT_AMS_SERVICE
|
||||||
|
|
||||||
|
remote_command_characteristic: Characteristic
|
||||||
|
entity_update_characteristic: Characteristic
|
||||||
|
entity_attribute_characteristic: Characteristic
|
||||||
|
|
||||||
|
def __init__(self) -> None:
|
||||||
|
# TODO not the final implementation
|
||||||
|
self.remote_command_characteristic = Characteristic(
|
||||||
|
GATT_AMS_REMOTE_COMMAND_CHARACTERISTIC,
|
||||||
|
Characteristic.Properties.NOTIFY
|
||||||
|
| Characteristic.Properties.WRITE_WITHOUT_RESPONSE,
|
||||||
|
Characteristic.Permissions.WRITEABLE,
|
||||||
|
)
|
||||||
|
|
||||||
|
# TODO not the final implementation
|
||||||
|
self.entity_update_characteristic = Characteristic(
|
||||||
|
GATT_AMS_ENTITY_UPDATE_CHARACTERISTIC,
|
||||||
|
Characteristic.Properties.NOTIFY | Characteristic.Properties.WRITE,
|
||||||
|
Characteristic.Permissions.WRITEABLE,
|
||||||
|
)
|
||||||
|
|
||||||
|
# TODO not the final implementation
|
||||||
|
self.entity_attribute_characteristic = Characteristic(
|
||||||
|
GATT_AMS_ENTITY_ATTRIBUTE_CHARACTERISTIC,
|
||||||
|
Characteristic.Properties.READ
|
||||||
|
| Characteristic.Properties.WRITE_WITHOUT_RESPONSE,
|
||||||
|
Characteristic.Permissions.WRITEABLE | Characteristic.Permissions.READABLE,
|
||||||
|
)
|
||||||
|
|
||||||
|
super().__init__(
|
||||||
|
[
|
||||||
|
self.remote_command_characteristic,
|
||||||
|
self.entity_update_characteristic,
|
||||||
|
self.entity_attribute_characteristic,
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
# GATT Client-side
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
class AmsProxy(ProfileServiceProxy):
|
||||||
|
SERVICE_CLASS = Ams
|
||||||
|
|
||||||
|
# NOTE: these don't use adapters, because the format for write and notifications
|
||||||
|
# are different.
|
||||||
|
remote_command: CharacteristicProxy[bytes]
|
||||||
|
entity_update: CharacteristicProxy[bytes]
|
||||||
|
entity_attribute: CharacteristicProxy[bytes]
|
||||||
|
|
||||||
|
def __init__(self, service_proxy: ServiceProxy):
|
||||||
|
self.remote_command = service_proxy.get_required_characteristic_by_uuid(
|
||||||
|
GATT_AMS_REMOTE_COMMAND_CHARACTERISTIC
|
||||||
|
)
|
||||||
|
|
||||||
|
self.entity_update = service_proxy.get_required_characteristic_by_uuid(
|
||||||
|
GATT_AMS_ENTITY_UPDATE_CHARACTERISTIC
|
||||||
|
)
|
||||||
|
|
||||||
|
self.entity_attribute = service_proxy.get_required_characteristic_by_uuid(
|
||||||
|
GATT_AMS_ENTITY_ATTRIBUTE_CHARACTERISTIC
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class AmsClient(utils.EventEmitter):
|
||||||
|
EVENT_SUPPORTED_COMMANDS = "supported_commands"
|
||||||
|
EVENT_PLAYER_NAME = "player_name"
|
||||||
|
EVENT_PLAYER_PLAYBACK_INFO = "player_playback_info"
|
||||||
|
EVENT_PLAYER_VOLUME = "player_volume"
|
||||||
|
EVENT_QUEUE_COUNT = "queue_count"
|
||||||
|
EVENT_QUEUE_INDEX = "queue_index"
|
||||||
|
EVENT_QUEUE_SHUFFLE_MODE = "queue_shuffle_mode"
|
||||||
|
EVENT_QUEUE_REPEAT_MODE = "queue_repeat_mode"
|
||||||
|
EVENT_TRACK_ARTIST = "track_artist"
|
||||||
|
EVENT_TRACK_ALBUM = "track_album"
|
||||||
|
EVENT_TRACK_TITLE = "track_title"
|
||||||
|
EVENT_TRACK_DURATION = "track_duration"
|
||||||
|
|
||||||
|
supported_commands: set[RemoteCommandId]
|
||||||
|
player_name: str = ""
|
||||||
|
player_playback_info: PlaybackInfo = PlaybackInfo(PlaybackState.PAUSED, 0.0, 0.0)
|
||||||
|
player_volume: float = 1.0
|
||||||
|
queue_count: int = 0
|
||||||
|
queue_index: int = 0
|
||||||
|
queue_shuffle_mode: ShuffleMode = ShuffleMode.OFF
|
||||||
|
queue_repeat_mode: RepeatMode = RepeatMode.OFF
|
||||||
|
track_artist: str = ""
|
||||||
|
track_album: str = ""
|
||||||
|
track_title: str = ""
|
||||||
|
track_duration: float = 0.0
|
||||||
|
|
||||||
|
def __init__(self, ams_proxy: AmsProxy) -> None:
|
||||||
|
super().__init__()
|
||||||
|
self._ams_proxy = ams_proxy
|
||||||
|
self._started = False
|
||||||
|
self._read_attribute_semaphore = asyncio.Semaphore()
|
||||||
|
self.supported_commands = set()
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
async def for_peer(cls, peer: Peer) -> AmsClient | None:
|
||||||
|
ams_proxy = await peer.discover_service_and_create_proxy(AmsProxy)
|
||||||
|
if ams_proxy is None:
|
||||||
|
return None
|
||||||
|
return cls(ams_proxy)
|
||||||
|
|
||||||
|
async def start(self) -> None:
|
||||||
|
logger.debug("subscribing to remote command characteristic")
|
||||||
|
await self._ams_proxy.remote_command.subscribe(
|
||||||
|
self._on_remote_command_notification
|
||||||
|
)
|
||||||
|
|
||||||
|
logger.debug("subscribing to entity update characteristic")
|
||||||
|
await self._ams_proxy.entity_update.subscribe(
|
||||||
|
lambda data: utils.AsyncRunner.spawn(
|
||||||
|
self._on_entity_update_notification(data)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
self._started = True
|
||||||
|
|
||||||
|
async def stop(self) -> None:
|
||||||
|
await self._ams_proxy.remote_command.unsubscribe(
|
||||||
|
self._on_remote_command_notification
|
||||||
|
)
|
||||||
|
await self._ams_proxy.entity_update.unsubscribe(
|
||||||
|
self._on_entity_update_notification
|
||||||
|
)
|
||||||
|
self._started = False
|
||||||
|
|
||||||
|
async def observe(
|
||||||
|
self,
|
||||||
|
entity: EntityId,
|
||||||
|
attributes: Iterable[PlayerAttributeId | QueueAttributeId | TrackAttributeId],
|
||||||
|
) -> None:
|
||||||
|
await self._ams_proxy.entity_update.write_value(
|
||||||
|
bytes([entity] + list(attributes)), with_response=True
|
||||||
|
)
|
||||||
|
|
||||||
|
async def command(self, command: RemoteCommandId) -> None:
|
||||||
|
await self._ams_proxy.remote_command.write_value(
|
||||||
|
bytes([command]), with_response=True
|
||||||
|
)
|
||||||
|
|
||||||
|
async def play(self) -> None:
|
||||||
|
await self.command(RemoteCommandId.PLAY)
|
||||||
|
|
||||||
|
async def pause(self) -> None:
|
||||||
|
await self.command(RemoteCommandId.PAUSE)
|
||||||
|
|
||||||
|
async def toggle_play_pause(self) -> None:
|
||||||
|
await self.command(RemoteCommandId.TOGGLE_PLAY_PAUSE)
|
||||||
|
|
||||||
|
async def next_track(self) -> None:
|
||||||
|
await self.command(RemoteCommandId.NEXT_TRACK)
|
||||||
|
|
||||||
|
async def previous_track(self) -> None:
|
||||||
|
await self.command(RemoteCommandId.PREVIOUS_TRACK)
|
||||||
|
|
||||||
|
async def volume_up(self) -> None:
|
||||||
|
await self.command(RemoteCommandId.VOLUME_UP)
|
||||||
|
|
||||||
|
async def volume_down(self) -> None:
|
||||||
|
await self.command(RemoteCommandId.VOLUME_DOWN)
|
||||||
|
|
||||||
|
async def advance_repeat_mode(self) -> None:
|
||||||
|
await self.command(RemoteCommandId.ADVANCE_REPEAT_MODE)
|
||||||
|
|
||||||
|
async def advance_shuffle_mode(self) -> None:
|
||||||
|
await self.command(RemoteCommandId.ADVANCE_SHUFFLE_MODE)
|
||||||
|
|
||||||
|
async def skip_forward(self) -> None:
|
||||||
|
await self.command(RemoteCommandId.SKIP_FORWARD)
|
||||||
|
|
||||||
|
async def skip_backward(self) -> None:
|
||||||
|
await self.command(RemoteCommandId.SKIP_BACKWARD)
|
||||||
|
|
||||||
|
async def like_track(self) -> None:
|
||||||
|
await self.command(RemoteCommandId.LIKE_TRACK)
|
||||||
|
|
||||||
|
async def dislike_track(self) -> None:
|
||||||
|
await self.command(RemoteCommandId.DISLIKE_TRACK)
|
||||||
|
|
||||||
|
async def bookmark_track(self) -> None:
|
||||||
|
await self.command(RemoteCommandId.BOOKMARK_TRACK)
|
||||||
|
|
||||||
|
def _on_remote_command_notification(self, data: bytes) -> None:
|
||||||
|
supported_commands = [RemoteCommandId(command) for command in data]
|
||||||
|
logger.debug(
|
||||||
|
f"supported commands: {[command.name for command in supported_commands]}"
|
||||||
|
)
|
||||||
|
for command in supported_commands:
|
||||||
|
self.supported_commands.add(command)
|
||||||
|
|
||||||
|
self.emit(self.EVENT_SUPPORTED_COMMANDS)
|
||||||
|
|
||||||
|
async def _on_entity_update_notification(self, data: bytes) -> None:
|
||||||
|
entity = EntityId(data[0])
|
||||||
|
flags = EntityUpdateFlags(data[2])
|
||||||
|
value = data[3:]
|
||||||
|
|
||||||
|
if flags & EntityUpdateFlags.TRUNCATED:
|
||||||
|
logger.debug("truncated attribute, fetching full value")
|
||||||
|
|
||||||
|
# Write the entity and attribute we're interested in
|
||||||
|
# (protected by a semaphore, so that we only read one attribute at a time)
|
||||||
|
async with self._read_attribute_semaphore:
|
||||||
|
await self._ams_proxy.entity_attribute.write_value(
|
||||||
|
data[:2], with_response=True
|
||||||
|
)
|
||||||
|
value = await self._ams_proxy.entity_attribute.read_value()
|
||||||
|
|
||||||
|
if entity == EntityId.PLAYER:
|
||||||
|
player_attribute = PlayerAttributeId(data[1])
|
||||||
|
if player_attribute == PlayerAttributeId.NAME:
|
||||||
|
self.player_name = value.decode()
|
||||||
|
self.emit(self.EVENT_PLAYER_NAME)
|
||||||
|
elif player_attribute == PlayerAttributeId.PLAYBACK_INFO:
|
||||||
|
playback_state_str, playback_rate_str, elapsed_time_str = (
|
||||||
|
value.decode().split(",")
|
||||||
|
)
|
||||||
|
self.player_playback_info = PlaybackInfo(
|
||||||
|
PlaybackState(int(playback_state_str)),
|
||||||
|
float(playback_rate_str),
|
||||||
|
float(elapsed_time_str),
|
||||||
|
)
|
||||||
|
self.emit(self.EVENT_PLAYER_PLAYBACK_INFO)
|
||||||
|
elif player_attribute == PlayerAttributeId.VOLUME:
|
||||||
|
self.player_volume = float(value.decode())
|
||||||
|
self.emit(self.EVENT_PLAYER_VOLUME)
|
||||||
|
else:
|
||||||
|
logger.warning(f"received unknown player attribute {player_attribute}")
|
||||||
|
|
||||||
|
elif entity == EntityId.QUEUE:
|
||||||
|
queue_attribute = QueueAttributeId(data[1])
|
||||||
|
if queue_attribute == QueueAttributeId.COUNT:
|
||||||
|
self.queue_count = int(value)
|
||||||
|
self.emit(self.EVENT_QUEUE_COUNT)
|
||||||
|
elif queue_attribute == QueueAttributeId.INDEX:
|
||||||
|
self.queue_index = int(value)
|
||||||
|
self.emit(self.EVENT_QUEUE_INDEX)
|
||||||
|
elif queue_attribute == QueueAttributeId.REPEAT_MODE:
|
||||||
|
self.queue_repeat_mode = RepeatMode(int(value))
|
||||||
|
self.emit(self.EVENT_QUEUE_REPEAT_MODE)
|
||||||
|
elif queue_attribute == QueueAttributeId.SHUFFLE_MODE:
|
||||||
|
self.queue_shuffle_mode = ShuffleMode(int(value))
|
||||||
|
self.emit(self.EVENT_QUEUE_SHUFFLE_MODE)
|
||||||
|
else:
|
||||||
|
logger.warning(f"received unknown queue attribute {queue_attribute}")
|
||||||
|
|
||||||
|
elif entity == EntityId.TRACK:
|
||||||
|
track_attribute = TrackAttributeId(data[1])
|
||||||
|
if track_attribute == TrackAttributeId.ARTIST:
|
||||||
|
self.track_artist = value.decode()
|
||||||
|
self.emit(self.EVENT_TRACK_ARTIST)
|
||||||
|
elif track_attribute == TrackAttributeId.ALBUM:
|
||||||
|
self.track_album = value.decode()
|
||||||
|
self.emit(self.EVENT_TRACK_ALBUM)
|
||||||
|
elif track_attribute == TrackAttributeId.TITLE:
|
||||||
|
self.track_title = value.decode()
|
||||||
|
self.emit(self.EVENT_TRACK_TITLE)
|
||||||
|
elif track_attribute == TrackAttributeId.DURATION:
|
||||||
|
self.track_duration = float(value.decode())
|
||||||
|
self.emit(self.EVENT_TRACK_DURATION)
|
||||||
|
else:
|
||||||
|
logger.warning(f"received unknown track attribute {track_attribute}")
|
||||||
|
|
||||||
|
else:
|
||||||
|
logger.warning(f"received unknown attribute ID {data[1]}")
|
||||||
@@ -20,29 +20,28 @@ Apple Notification Center Service (ANCS).
|
|||||||
# Imports
|
# Imports
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
import dataclasses
|
import dataclasses
|
||||||
import datetime
|
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.att import ATT_Error
|
from bumble.att import ATT_Error
|
||||||
from bumble.device import Peer
|
from bumble.device import Peer
|
||||||
from bumble.gatt import (
|
from bumble.gatt import (
|
||||||
Characteristic,
|
|
||||||
GATT_ANCS_SERVICE,
|
|
||||||
GATT_ANCS_NOTIFICATION_SOURCE_CHARACTERISTIC,
|
|
||||||
GATT_ANCS_CONTROL_POINT_CHARACTERISTIC,
|
GATT_ANCS_CONTROL_POINT_CHARACTERISTIC,
|
||||||
GATT_ANCS_DATA_SOURCE_CHARACTERISTIC,
|
GATT_ANCS_DATA_SOURCE_CHARACTERISTIC,
|
||||||
|
GATT_ANCS_NOTIFICATION_SOURCE_CHARACTERISTIC,
|
||||||
|
GATT_ANCS_SERVICE,
|
||||||
|
Characteristic,
|
||||||
TemplateService,
|
TemplateService,
|
||||||
)
|
)
|
||||||
from bumble.gatt_client import CharacteristicProxy, ProfileServiceProxy, ServiceProxy
|
|
||||||
from bumble.gatt_adapters import SerializableCharacteristicProxyAdapter
|
from bumble.gatt_adapters import SerializableCharacteristicProxyAdapter
|
||||||
from bumble import utils
|
from bumble.gatt_client import CharacteristicProxy, ProfileServiceProxy, ServiceProxy
|
||||||
|
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
# Constants
|
# Constants
|
||||||
@@ -117,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
|
||||||
@@ -243,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
|
||||||
|
|
||||||
@@ -256,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
|
||||||
@@ -317,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(
|
||||||
@@ -343,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:
|
||||||
@@ -416,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:
|
||||||
|
|||||||
@@ -18,22 +18,17 @@
|
|||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from dataclasses import dataclass, field
|
|
||||||
import enum
|
import enum
|
||||||
import functools
|
import functools
|
||||||
import logging
|
import logging
|
||||||
import struct
|
import struct
|
||||||
from typing import Any, Optional, Union, TypeVar
|
|
||||||
from collections.abc import Sequence
|
from collections.abc import Sequence
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from typing import Any, TypeVar
|
||||||
|
|
||||||
from bumble import utils
|
from bumble import colors, device, gatt, gatt_client, hci, utils
|
||||||
from bumble import colors
|
|
||||||
from bumble.profiles.bap import CodecSpecificConfiguration
|
|
||||||
from bumble.profiles import le_audio
|
from bumble.profiles import le_audio
|
||||||
from bumble import device
|
from bumble.profiles.bap import CodecSpecificConfiguration
|
||||||
from bumble import gatt
|
|
||||||
from bumble import gatt_client
|
|
||||||
from bumble import hci
|
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
# Logging
|
# Logging
|
||||||
@@ -54,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):
|
||||||
@@ -283,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
|
||||||
@@ -295,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
|
||||||
@@ -452,6 +447,16 @@ class AseStateMachine(gatt.Characteristic):
|
|||||||
|
|
||||||
self.metadata = le_audio.Metadata.from_bytes(metadata)
|
self.metadata = le_audio.Metadata.from_bytes(metadata)
|
||||||
self.state = self.State.ENABLING
|
self.state = self.State.ENABLING
|
||||||
|
# CIS could be established before enable.
|
||||||
|
if cis_link := next(
|
||||||
|
(
|
||||||
|
cis_link
|
||||||
|
for cis_link in self.service.device.cis_links.values()
|
||||||
|
if cis_link.cig_id == self.cig_id and cis_link.cis_id == self.cis_id
|
||||||
|
),
|
||||||
|
None,
|
||||||
|
):
|
||||||
|
self.on_cis_establishment(cis_link)
|
||||||
|
|
||||||
return (AseResponseCode.SUCCESS, AseReasonCode.NONE)
|
return (AseResponseCode.SUCCESS, AseReasonCode.NONE)
|
||||||
|
|
||||||
@@ -605,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,
|
||||||
@@ -659,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)]
|
||||||
|
|||||||
@@ -17,16 +17,14 @@
|
|||||||
# Imports
|
# Imports
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
import enum
|
import enum
|
||||||
import struct
|
|
||||||
import logging
|
import logging
|
||||||
from typing import Optional, Callable, Union, Any
|
import struct
|
||||||
|
from collections.abc import Callable
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
from bumble import l2cap
|
from bumble import data_types, gatt, gatt_client, l2cap, utils
|
||||||
from bumble import utils
|
|
||||||
from bumble import gatt
|
|
||||||
from bumble import gatt_client
|
|
||||||
from bumble.core import AdvertisingData
|
from bumble.core import AdvertisingData
|
||||||
from bumble.device import Device, Connection
|
from bumble.device import Connection, Device
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
# Logging
|
# Logging
|
||||||
@@ -93,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,
|
||||||
@@ -188,12 +186,11 @@ class AshaService(gatt.TemplateService):
|
|||||||
return bytes(
|
return bytes(
|
||||||
AdvertisingData(
|
AdvertisingData(
|
||||||
[
|
[
|
||||||
(
|
data_types.ServiceData16BitUUID(
|
||||||
AdvertisingData.SERVICE_DATA_16_BIT_UUID,
|
gatt.GATT_ASHA_SERVICE,
|
||||||
bytes(gatt.GATT_ASHA_SERVICE)
|
bytes([self.protocol_version, self.capability])
|
||||||
+ bytes([self.protocol_version, self.capability])
|
|
||||||
+ self.hisyncid[:4],
|
+ self.hisyncid[:4],
|
||||||
),
|
)
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -18,21 +18,18 @@
|
|||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from collections.abc import Sequence
|
|
||||||
import dataclasses
|
import dataclasses
|
||||||
import enum
|
import enum
|
||||||
import struct
|
|
||||||
import functools
|
import functools
|
||||||
import logging
|
import logging
|
||||||
|
import struct
|
||||||
|
from collections.abc import Sequence
|
||||||
|
|
||||||
from typing_extensions import Self
|
from typing_extensions import Self
|
||||||
|
|
||||||
from bumble import core
|
from bumble import core, data_types, gatt, hci, utils
|
||||||
from bumble import hci
|
|
||||||
from bumble import gatt
|
|
||||||
from bumble import utils
|
|
||||||
from bumble.profiles import le_audio
|
from bumble.profiles import le_audio
|
||||||
|
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
# Logging
|
# Logging
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
@@ -260,11 +257,10 @@ class UnicastServerAdvertisingData:
|
|||||||
return bytes(
|
return bytes(
|
||||||
core.AdvertisingData(
|
core.AdvertisingData(
|
||||||
[
|
[
|
||||||
(
|
data_types.ServiceData16BitUUID(
|
||||||
core.AdvertisingData.SERVICE_DATA_16_BIT_UUID,
|
gatt.GATT_AUDIO_STREAM_CONTROL_SERVICE,
|
||||||
struct.pack(
|
struct.pack(
|
||||||
'<2sBIB',
|
'<BIB',
|
||||||
bytes(gatt.GATT_AUDIO_STREAM_CONTROL_SERVICE),
|
|
||||||
self.announcement_type,
|
self.announcement_type,
|
||||||
self.available_audio_contexts,
|
self.available_audio_contexts,
|
||||||
len(self.metadata),
|
len(self.metadata),
|
||||||
@@ -337,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
|
||||||
@@ -493,12 +490,8 @@ class BroadcastAudioAnnouncement:
|
|||||||
return bytes(
|
return bytes(
|
||||||
core.AdvertisingData(
|
core.AdvertisingData(
|
||||||
[
|
[
|
||||||
(
|
data_types.ServiceData16BitUUID(
|
||||||
core.AdvertisingData.SERVICE_DATA_16_BIT_UUID,
|
gatt.GATT_BROADCAST_AUDIO_ANNOUNCEMENT_SERVICE, bytes(self)
|
||||||
(
|
|
||||||
bytes(gatt.GATT_BROADCAST_AUDIO_ANNOUNCEMENT_SERVICE)
|
|
||||||
+ bytes(self)
|
|
||||||
),
|
|
||||||
)
|
)
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
@@ -610,12 +603,8 @@ class BasicAudioAnnouncement:
|
|||||||
return bytes(
|
return bytes(
|
||||||
core.AdvertisingData(
|
core.AdvertisingData(
|
||||||
[
|
[
|
||||||
(
|
data_types.ServiceData16BitUUID(
|
||||||
core.AdvertisingData.SERVICE_DATA_16_BIT_UUID,
|
gatt.GATT_BASIC_AUDIO_ANNOUNCEMENT_SERVICE, bytes(self)
|
||||||
(
|
|
||||||
bytes(gatt.GATT_BASIC_AUDIO_ANNOUNCEMENT_SERVICE)
|
|
||||||
+ bytes(self)
|
|
||||||
),
|
|
||||||
)
|
)
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -17,18 +17,14 @@
|
|||||||
# Imports
|
# Imports
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
from __future__ import annotations
|
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
|
from bumble import core, device, gatt, gatt_adapters, gatt_client, hci, utils
|
||||||
from bumble import device
|
|
||||||
from bumble import gatt
|
|
||||||
from bumble import gatt_adapters
|
|
||||||
from bumble import gatt_client
|
|
||||||
from bumble import hci
|
|
||||||
from bumble import utils
|
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
# Logging
|
# Logging
|
||||||
@@ -342,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
|
||||||
@@ -356,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,37 +16,28 @@
|
|||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
# Imports
|
# Imports
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
from typing import Optional
|
from collections.abc import Callable
|
||||||
|
|
||||||
from bumble.gatt_client import ProfileServiceProxy
|
from bumble import device, gatt, gatt_adapters, gatt_client
|
||||||
from bumble.gatt import (
|
|
||||||
GATT_BATTERY_SERVICE,
|
|
||||||
GATT_BATTERY_LEVEL_CHARACTERISTIC,
|
|
||||||
TemplateService,
|
|
||||||
Characteristic,
|
|
||||||
CharacteristicValue,
|
|
||||||
)
|
|
||||||
from bumble.gatt_client import CharacteristicProxy
|
|
||||||
from bumble.gatt_adapters import (
|
|
||||||
PackedCharacteristicAdapter,
|
|
||||||
PackedCharacteristicProxyAdapter,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
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,
|
||||||
)
|
)
|
||||||
@@ -54,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
|
|
||||||
|
|||||||
@@ -18,8 +18,7 @@
|
|||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from bumble import gatt
|
from bumble import gatt, gatt_client
|
||||||
from bumble import gatt_client
|
|
||||||
from bumble.profiles import csip
|
from bumble.profiles import csip
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -17,16 +17,11 @@
|
|||||||
# Imports
|
# Imports
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import enum
|
import enum
|
||||||
import struct
|
import struct
|
||||||
from typing import Optional
|
|
||||||
|
|
||||||
from bumble import core
|
|
||||||
from bumble import crypto
|
|
||||||
from bumble import device
|
|
||||||
from bumble import gatt
|
|
||||||
from bumble import gatt_client
|
|
||||||
|
|
||||||
|
from bumble import core, crypto, device, gatt, gatt_client
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
# Constants
|
# Constants
|
||||||
@@ -100,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(
|
||||||
@@ -202,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,
|
||||||
@@ -25,12 +24,12 @@ from bumble.gatt import (
|
|||||||
GATT_HARDWARE_REVISION_STRING_CHARACTERISTIC,
|
GATT_HARDWARE_REVISION_STRING_CHARACTERISTIC,
|
||||||
GATT_MANUFACTURER_NAME_STRING_CHARACTERISTIC,
|
GATT_MANUFACTURER_NAME_STRING_CHARACTERISTIC,
|
||||||
GATT_MODEL_NUMBER_STRING_CHARACTERISTIC,
|
GATT_MODEL_NUMBER_STRING_CHARACTERISTIC,
|
||||||
|
GATT_REGULATORY_CERTIFICATION_DATA_LIST_CHARACTERISTIC,
|
||||||
GATT_SERIAL_NUMBER_STRING_CHARACTERISTIC,
|
GATT_SERIAL_NUMBER_STRING_CHARACTERISTIC,
|
||||||
GATT_SOFTWARE_REVISION_STRING_CHARACTERISTIC,
|
GATT_SOFTWARE_REVISION_STRING_CHARACTERISTIC,
|
||||||
GATT_SYSTEM_ID_CHARACTERISTIC,
|
GATT_SYSTEM_ID_CHARACTERISTIC,
|
||||||
GATT_REGULATORY_CERTIFICATION_DATA_LIST_CHARACTERISTIC,
|
|
||||||
TemplateService,
|
|
||||||
Characteristic,
|
Characteristic,
|
||||||
|
TemplateService,
|
||||||
)
|
)
|
||||||
from bumble.gatt_adapters import (
|
from bumble.gatt_adapters import (
|
||||||
DelegatedCharacteristicProxyAdapter,
|
DelegatedCharacteristicProxyAdapter,
|
||||||
@@ -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,15 +19,14 @@
|
|||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
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 (
|
||||||
TemplateService,
|
|
||||||
Characteristic,
|
|
||||||
GATT_GENERIC_ACCESS_SERVICE,
|
|
||||||
GATT_DEVICE_NAME_CHARACTERISTIC,
|
|
||||||
GATT_APPEARANCE_CHARACTERISTIC,
|
GATT_APPEARANCE_CHARACTERISTIC,
|
||||||
|
GATT_DEVICE_NAME_CHARACTERISTIC,
|
||||||
|
GATT_GENERIC_ACCESS_SERVICE,
|
||||||
|
Characteristic,
|
||||||
|
TemplateService,
|
||||||
)
|
)
|
||||||
from bumble.gatt_adapters import (
|
from bumble.gatt_adapters import (
|
||||||
DelegatedCharacteristicProxyAdapter,
|
DelegatedCharacteristicProxyAdapter,
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -17,10 +17,7 @@ from __future__ import annotations
|
|||||||
import struct
|
import struct
|
||||||
from typing import TYPE_CHECKING
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
from bumble import att
|
from bumble import att, crypto, gatt, gatt_client
|
||||||
from bumble import gatt
|
|
||||||
from bumble import gatt_client
|
|
||||||
from bumble import crypto
|
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from bumble import device
|
from bumble import device
|
||||||
@@ -43,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,
|
||||||
|
|||||||
@@ -18,21 +18,20 @@
|
|||||||
# Imports
|
# Imports
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
import struct
|
import struct
|
||||||
from typing import Optional
|
from enum import IntFlag
|
||||||
|
|
||||||
from bumble.gatt import (
|
from bumble.gatt import (
|
||||||
TemplateService,
|
GATT_BGR_FEATURES_CHARACTERISTIC,
|
||||||
Characteristic,
|
GATT_BGS_FEATURES_CHARACTERISTIC,
|
||||||
GATT_GAMING_AUDIO_SERVICE,
|
GATT_GAMING_AUDIO_SERVICE,
|
||||||
GATT_GMAP_ROLE_CHARACTERISTIC,
|
GATT_GMAP_ROLE_CHARACTERISTIC,
|
||||||
GATT_UGG_FEATURES_CHARACTERISTIC,
|
GATT_UGG_FEATURES_CHARACTERISTIC,
|
||||||
GATT_UGT_FEATURES_CHARACTERISTIC,
|
GATT_UGT_FEATURES_CHARACTERISTIC,
|
||||||
GATT_BGS_FEATURES_CHARACTERISTIC,
|
Characteristic,
|
||||||
GATT_BGR_FEATURES_CHARACTERISTIC,
|
TemplateService,
|
||||||
)
|
)
|
||||||
from bumble.gatt_adapters import DelegatedCharacteristicProxyAdapter
|
from bumble.gatt_adapters import DelegatedCharacteristicProxyAdapter
|
||||||
from bumble.gatt_client import CharacteristicProxy, ProfileServiceProxy, ServiceProxy
|
from bumble.gatt_client import CharacteristicProxy, ProfileServiceProxy, ServiceProxy
|
||||||
from enum import IntFlag
|
|
||||||
|
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -16,16 +16,15 @@
|
|||||||
# Imports
|
# Imports
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
import asyncio
|
|
||||||
import functools
|
|
||||||
from dataclasses import dataclass, field
|
|
||||||
import logging
|
|
||||||
from typing import Any, Optional, Union
|
|
||||||
|
|
||||||
from bumble import att, gatt, gatt_adapters, gatt_client
|
import asyncio
|
||||||
|
import logging
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from bumble import att, gatt, gatt_adapters, gatt_client, utils
|
||||||
from bumble.core import InvalidArgumentError, InvalidStateError
|
from bumble.core import InvalidArgumentError, InvalidStateError
|
||||||
from bumble.device import Device, Connection
|
from bumble.device import Connection, Device
|
||||||
from bumble import utils
|
|
||||||
from bumble.hci import Address
|
from bumble.hci import Address
|
||||||
|
|
||||||
|
|
||||||
@@ -146,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):
|
||||||
@@ -236,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]
|
||||||
@@ -272,14 +271,21 @@ class HearingAccessService(gatt.TemplateService):
|
|||||||
def on_connection(connection: Connection) -> None:
|
def on_connection(connection: Connection) -> None:
|
||||||
@connection.on(connection.EVENT_DISCONNECTION)
|
@connection.on(connection.EVENT_DISCONNECTION)
|
||||||
def on_disconnection(_reason) -> None:
|
def on_disconnection(_reason) -> None:
|
||||||
self.currently_connected_clients.remove(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,
|
||||||
@@ -316,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
|
||||||
@@ -373,8 +400,7 @@ class HearingAccessService(gatt.TemplateService):
|
|||||||
self.preset_records[key]
|
self.preset_records[key]
|
||||||
for key in sorted(self.preset_records.keys())
|
for key in sorted(self.preset_records.keys())
|
||||||
if self.preset_records[key].index >= start_index
|
if self.preset_records[key].index >= start_index
|
||||||
]
|
][:num_presets]
|
||||||
del presets[num_presets:]
|
|
||||||
if len(presets) == 0:
|
if len(presets) == 0:
|
||||||
raise att.ATT_Error(att.ErrorCode.OUT_OF_RANGE)
|
raise att.ATT_Error(att.ErrorCode.OUT_OF_RANGE)
|
||||||
|
|
||||||
@@ -383,7 +409,10 @@ class HearingAccessService(gatt.TemplateService):
|
|||||||
async def _read_preset_response(
|
async def _read_preset_response(
|
||||||
self, connection: Connection, presets: list[PresetRecord]
|
self, connection: Connection, presets: list[PresetRecord]
|
||||||
):
|
):
|
||||||
# If the ATT bearer is terminated before all notifications or indications are sent, then the server shall consider the Read Presets Request operation aborted and shall not either continue or restart the operation when the client reconnects.
|
# If the ATT bearer is terminated before all notifications or indications are
|
||||||
|
# sent, then the server shall consider the Read Presets Request operation
|
||||||
|
# aborted and shall not either continue or restart the operation when the client
|
||||||
|
# reconnects.
|
||||||
try:
|
try:
|
||||||
for i, preset in enumerate(presets):
|
for i, preset in enumerate(presets):
|
||||||
await connection.device.indicate_subscriber(
|
await connection.device.indicate_subscriber(
|
||||||
@@ -404,7 +433,7 @@ class HearingAccessService(gatt.TemplateService):
|
|||||||
|
|
||||||
async def generic_update(self, op: PresetChangedOperation) -> None:
|
async def generic_update(self, op: PresetChangedOperation) -> None:
|
||||||
'''Server API to perform a generic update. It is the responsibility of the caller to modify the preset_records to match the PresetChangedOperation being sent'''
|
'''Server API to perform a generic update. It is the responsibility of the caller to modify the preset_records to match the PresetChangedOperation being sent'''
|
||||||
await self._notifyPresetOperations(op)
|
await self._notify_preset_operations(op)
|
||||||
|
|
||||||
async def delete_preset(self, index: int) -> None:
|
async def delete_preset(self, index: int) -> None:
|
||||||
'''Server API to delete a preset. It should not be the current active preset'''
|
'''Server API to delete a preset. It should not be the current active preset'''
|
||||||
@@ -413,14 +442,14 @@ class HearingAccessService(gatt.TemplateService):
|
|||||||
raise InvalidStateError('Cannot delete active preset')
|
raise InvalidStateError('Cannot delete active preset')
|
||||||
|
|
||||||
del self.preset_records[index]
|
del self.preset_records[index]
|
||||||
await self._notifyPresetOperations(PresetChangedOperationDeleted(index))
|
await self._notify_preset_operations(PresetChangedOperationDeleted(index))
|
||||||
|
|
||||||
async def available_preset(self, index: int) -> None:
|
async def available_preset(self, index: int) -> None:
|
||||||
'''Server API to make a preset available'''
|
'''Server API to make a preset available'''
|
||||||
|
|
||||||
preset = self.preset_records[index]
|
preset = self.preset_records[index]
|
||||||
preset.properties.is_available = PresetRecord.Property.IsAvailable.IS_AVAILABLE
|
preset.properties.is_available = PresetRecord.Property.IsAvailable.IS_AVAILABLE
|
||||||
await self._notifyPresetOperations(PresetChangedOperationAvailable(index))
|
await self._notify_preset_operations(PresetChangedOperationAvailable(index))
|
||||||
|
|
||||||
async def unavailable_preset(self, index: int) -> None:
|
async def unavailable_preset(self, index: int) -> None:
|
||||||
'''Server API to make a preset unavailable. It should not be the current active preset'''
|
'''Server API to make a preset unavailable. It should not be the current active preset'''
|
||||||
@@ -432,7 +461,7 @@ class HearingAccessService(gatt.TemplateService):
|
|||||||
preset.properties.is_available = (
|
preset.properties.is_available = (
|
||||||
PresetRecord.Property.IsAvailable.IS_UNAVAILABLE
|
PresetRecord.Property.IsAvailable.IS_UNAVAILABLE
|
||||||
)
|
)
|
||||||
await self._notifyPresetOperations(PresetChangedOperationUnavailable(index))
|
await self._notify_preset_operations(PresetChangedOperationUnavailable(index))
|
||||||
|
|
||||||
async def _preset_changed_operation(self, connection: Connection) -> None:
|
async def _preset_changed_operation(self, connection: Connection) -> None:
|
||||||
'''Send all PresetChangedOperation saved for a given connection'''
|
'''Send all PresetChangedOperation saved for a given connection'''
|
||||||
@@ -447,27 +476,31 @@ class HearingAccessService(gatt.TemplateService):
|
|||||||
return op.additional_parameters
|
return op.additional_parameters
|
||||||
|
|
||||||
op_list.sort(key=get_op_index)
|
op_list.sort(key=get_op_index)
|
||||||
# If the ATT bearer is terminated before all notifications or indications are sent, then the server shall consider the Preset Changed operation aborted and shall continue the operation when the client reconnects.
|
# If the ATT bearer is terminated before all notifications or indications are
|
||||||
while len(op_list) > 0:
|
# sent, then the server shall consider the Preset Changed operation aborted and
|
||||||
|
# shall continue the operation when the client reconnects.
|
||||||
|
while op_list:
|
||||||
try:
|
try:
|
||||||
await connection.device.indicate_subscriber(
|
await connection.device.indicate_subscriber(
|
||||||
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)
|
||||||
except TimeoutError:
|
except TimeoutError:
|
||||||
break
|
break
|
||||||
|
|
||||||
async def _notifyPresetOperations(self, op: PresetChangedOperation) -> None:
|
async def _notify_preset_operations(self, op: PresetChangedOperation) -> None:
|
||||||
for historyList in self.preset_changed_operations_history_per_device.values():
|
for history_list in self.preset_changed_operations_history_per_device.values():
|
||||||
historyList.append(op)
|
history_list.append(op)
|
||||||
|
|
||||||
for connection in self.currently_connected_clients:
|
for connection in self.currently_connected_clients:
|
||||||
await self._preset_changed_operation(connection)
|
await self._preset_changed_operation(connection)
|
||||||
|
|
||||||
async def _on_write_preset_name(self, connection: Connection, value: bytes):
|
async def _on_write_preset_name(self, connection: Connection, value: bytes):
|
||||||
|
del connection # Unused
|
||||||
|
|
||||||
if self.read_presets_request_in_progress:
|
if self.read_presets_request_in_progress:
|
||||||
raise att.ATT_Error(att.ErrorCode.PROCEDURE_ALREADY_IN_PROGRESS)
|
raise att.ATT_Error(att.ErrorCode.PROCEDURE_ALREADY_IN_PROGRESS)
|
||||||
@@ -532,48 +565,51 @@ class HearingAccessService(gatt.TemplateService):
|
|||||||
self.active_preset_index = index
|
self.active_preset_index = index
|
||||||
await self.notify_active_preset()
|
await self.notify_active_preset()
|
||||||
|
|
||||||
async def _on_set_active_preset(self, _: Connection, value: bytes):
|
async def _on_set_active_preset(self, connection: Connection, value: bytes):
|
||||||
|
del connection # Unused
|
||||||
await self.set_active_preset(value)
|
await self.set_active_preset(value)
|
||||||
|
|
||||||
async def set_next_or_previous_preset(self, is_previous):
|
async def set_next_or_previous_preset(self, is_previous: bool) -> None:
|
||||||
'''Set the next or the previous preset as active'''
|
'''Set the next or the previous preset as active'''
|
||||||
|
|
||||||
if self.active_preset_index == 0x00:
|
if self.active_preset_index == 0x00:
|
||||||
raise att.ATT_Error(ErrorCode.PRESET_OPERATION_NOT_POSSIBLE)
|
raise att.ATT_Error(ErrorCode.PRESET_OPERATION_NOT_POSSIBLE)
|
||||||
|
|
||||||
first_preset: Optional[PresetRecord] = None # To loop to first preset
|
presets = sorted(
|
||||||
next_preset: Optional[PresetRecord] = None
|
[
|
||||||
for index, record in sorted(self.preset_records.items(), reverse=is_previous):
|
record
|
||||||
if not record.is_available():
|
for record in self.preset_records.values()
|
||||||
continue
|
if record.is_available()
|
||||||
if first_preset == None:
|
],
|
||||||
first_preset = record
|
key=lambda record: record.index,
|
||||||
if is_previous:
|
)
|
||||||
if index >= self.active_preset_index:
|
current_preset = self.preset_records[self.active_preset_index]
|
||||||
continue
|
current_preset_pos = presets.index(current_preset)
|
||||||
elif index <= self.active_preset_index:
|
if is_previous:
|
||||||
continue
|
new_preset = presets[(current_preset_pos - 1) % len(presets)]
|
||||||
next_preset = record
|
else:
|
||||||
break
|
new_preset = presets[(current_preset_pos + 1) % len(presets)]
|
||||||
|
|
||||||
if not first_preset: # If no other preset are available
|
if current_preset == new_preset: # If no other preset are available
|
||||||
raise att.ATT_Error(ErrorCode.PRESET_OPERATION_NOT_POSSIBLE)
|
raise att.ATT_Error(ErrorCode.PRESET_OPERATION_NOT_POSSIBLE)
|
||||||
|
|
||||||
if next_preset:
|
self.active_preset_index = new_preset.index
|
||||||
self.active_preset_index = next_preset.index
|
|
||||||
else:
|
|
||||||
self.active_preset_index = first_preset.index
|
|
||||||
await self.notify_active_preset()
|
await self.notify_active_preset()
|
||||||
|
|
||||||
async def _on_set_next_preset(self, _: Connection, __value__: bytes) -> None:
|
async def _on_set_next_preset(self, connection: Connection, value: bytes) -> None:
|
||||||
|
del connection, value # Unused.
|
||||||
await self.set_next_or_previous_preset(False)
|
await self.set_next_or_previous_preset(False)
|
||||||
|
|
||||||
async def _on_set_previous_preset(self, _: Connection, __value__: bytes) -> None:
|
async def _on_set_previous_preset(
|
||||||
|
self, connection: Connection, value: bytes
|
||||||
|
) -> None:
|
||||||
|
del connection, value # Unused.
|
||||||
await self.set_next_or_previous_preset(True)
|
await self.set_next_or_previous_preset(True)
|
||||||
|
|
||||||
async def _on_set_active_preset_synchronized_locally(
|
async def _on_set_active_preset_synchronized_locally(
|
||||||
self, _: Connection, value: bytes
|
self, connection: Connection, value: bytes
|
||||||
):
|
):
|
||||||
|
del connection # Unused.
|
||||||
if (
|
if (
|
||||||
self.server_features.preset_synchronization_support
|
self.server_features.preset_synchronization_support
|
||||||
== PresetSynchronizationSupport.PRESET_SYNCHRONIZATION_IS_NOT_SUPPORTED
|
== PresetSynchronizationSupport.PRESET_SYNCHRONIZATION_IS_NOT_SUPPORTED
|
||||||
@@ -584,8 +620,9 @@ class HearingAccessService(gatt.TemplateService):
|
|||||||
await self.other_server_in_binaural_set.set_active_preset(value)
|
await self.other_server_in_binaural_set.set_active_preset(value)
|
||||||
|
|
||||||
async def _on_set_next_preset_synchronized_locally(
|
async def _on_set_next_preset_synchronized_locally(
|
||||||
self, _: Connection, __value__: bytes
|
self, connection: Connection, value: bytes
|
||||||
):
|
):
|
||||||
|
del connection, value # Unused.
|
||||||
if (
|
if (
|
||||||
self.server_features.preset_synchronization_support
|
self.server_features.preset_synchronization_support
|
||||||
== PresetSynchronizationSupport.PRESET_SYNCHRONIZATION_IS_NOT_SUPPORTED
|
== PresetSynchronizationSupport.PRESET_SYNCHRONIZATION_IS_NOT_SUPPORTED
|
||||||
@@ -596,8 +633,9 @@ class HearingAccessService(gatt.TemplateService):
|
|||||||
await self.other_server_in_binaural_set.set_next_or_previous_preset(False)
|
await self.other_server_in_binaural_set.set_next_or_previous_preset(False)
|
||||||
|
|
||||||
async def _on_set_previous_preset_synchronized_locally(
|
async def _on_set_previous_preset_synchronized_locally(
|
||||||
self, _: Connection, __value__: bytes
|
self, connection: Connection, value: bytes
|
||||||
):
|
):
|
||||||
|
del connection, value # Unused.
|
||||||
if (
|
if (
|
||||||
self.server_features.preset_synchronization_support
|
self.server_features.preset_synchronization_support
|
||||||
== PresetSynchronizationSupport.PRESET_SYNCHRONIZATION_IS_NOT_SUPPORTED
|
== PresetSynchronizationSupport.PRESET_SYNCHRONIZATION_IS_NOT_SUPPORTED
|
||||||
@@ -615,11 +653,13 @@ class HearingAccessServiceProxy(gatt_client.ProfileServiceProxy):
|
|||||||
SERVICE_CLASS = HearingAccessService
|
SERVICE_CLASS = HearingAccessService
|
||||||
|
|
||||||
hearing_aid_preset_control_point: gatt_client.CharacteristicProxy
|
hearing_aid_preset_control_point: gatt_client.CharacteristicProxy
|
||||||
preset_control_point_indications: asyncio.Queue
|
preset_control_point_indications: asyncio.Queue[bytes]
|
||||||
active_preset_index_notification: asyncio.Queue
|
active_preset_index_notification: asyncio.Queue[bytes]
|
||||||
|
|
||||||
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
|
||||||
|
self.preset_control_point_indications = asyncio.Queue()
|
||||||
|
self.active_preset_index_notification = asyncio.Queue()
|
||||||
|
|
||||||
self.server_features = gatt_adapters.PackedCharacteristicProxyAdapter(
|
self.server_features = gatt_adapters.PackedCharacteristicProxyAdapter(
|
||||||
service_proxy.get_characteristics_by_uuid(
|
service_proxy.get_characteristics_by_uuid(
|
||||||
@@ -641,20 +681,12 @@ class HearingAccessServiceProxy(gatt_client.ProfileServiceProxy):
|
|||||||
'B',
|
'B',
|
||||||
)
|
)
|
||||||
|
|
||||||
async def setup_subscription(self):
|
async def setup_subscription(self) -> None:
|
||||||
self.preset_control_point_indications = asyncio.Queue()
|
|
||||||
self.active_preset_index_notification = asyncio.Queue()
|
|
||||||
|
|
||||||
def on_active_preset_index_notification(data: bytes):
|
|
||||||
self.active_preset_index_notification.put_nowait(data)
|
|
||||||
|
|
||||||
def on_preset_control_point_indication(data: bytes):
|
|
||||||
self.preset_control_point_indications.put_nowait(data)
|
|
||||||
|
|
||||||
await self.hearing_aid_preset_control_point.subscribe(
|
await self.hearing_aid_preset_control_point.subscribe(
|
||||||
functools.partial(on_preset_control_point_indication), prefer_notify=False
|
self.preset_control_point_indications.put_nowait,
|
||||||
|
prefer_notify=False,
|
||||||
)
|
)
|
||||||
|
|
||||||
await self.active_preset_index.subscribe(
|
await self.active_preset_index.subscribe(
|
||||||
functools.partial(on_active_preset_index_notification)
|
self.active_preset_index_notification.put_nowait
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -17,41 +17,31 @@
|
|||||||
# Imports
|
# Imports
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
from enum import IntEnum
|
|
||||||
import struct
|
|
||||||
from typing import Optional
|
|
||||||
|
|
||||||
from bumble import core
|
import dataclasses
|
||||||
from bumble.att import ATT_Error
|
import enum
|
||||||
from bumble.gatt import (
|
import struct
|
||||||
GATT_HEART_RATE_SERVICE,
|
from collections.abc import Callable, Sequence
|
||||||
GATT_HEART_RATE_MEASUREMENT_CHARACTERISTIC,
|
from typing import Any
|
||||||
GATT_BODY_SENSOR_LOCATION_CHARACTERISTIC,
|
|
||||||
GATT_HEART_RATE_CONTROL_POINT_CHARACTERISTIC,
|
from typing_extensions import Self
|
||||||
TemplateService,
|
|
||||||
Characteristic,
|
from bumble import att, core, device, gatt, gatt_adapters, gatt_client, utils
|
||||||
CharacteristicValue,
|
|
||||||
)
|
|
||||||
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
|
||||||
@@ -60,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))
|
||||||
@@ -145,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)
|
||||||
|
|
||||||
@@ -203,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
|
||||||
|
|||||||
@@ -16,14 +16,16 @@
|
|||||||
# Imports
|
# Imports
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import dataclasses
|
import dataclasses
|
||||||
import enum
|
import enum
|
||||||
import struct
|
import struct
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from typing_extensions import Self
|
from typing_extensions import Self
|
||||||
|
|
||||||
from bumble.profiles import bap
|
|
||||||
from bumble import utils
|
from bumble import utils
|
||||||
|
from bumble.profiles import bap
|
||||||
|
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
@@ -135,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,16 +22,12 @@ import asyncio
|
|||||||
import dataclasses
|
import dataclasses
|
||||||
import enum
|
import enum
|
||||||
import struct
|
import struct
|
||||||
|
from typing import TYPE_CHECKING, ClassVar
|
||||||
|
|
||||||
from bumble import core
|
|
||||||
from bumble import device
|
|
||||||
from bumble import gatt
|
|
||||||
from bumble import gatt_client
|
|
||||||
from bumble import utils
|
|
||||||
|
|
||||||
from typing import Optional, ClassVar, TYPE_CHECKING
|
|
||||||
from typing_extensions import Self
|
from typing_extensions import Self
|
||||||
|
|
||||||
|
from bumble import core, device, gatt, gatt_client, utils
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
# Constants
|
# Constants
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
@@ -200,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(
|
||||||
@@ -341,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]
|
||||||
|
|||||||
@@ -17,18 +17,15 @@
|
|||||||
# Imports
|
# Imports
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
from __future__ import annotations
|
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.profiles.bap import AudioLocation, CodecSpecificCapabilities, ContextType
|
from bumble import gatt, gatt_adapters, gatt_client, hci
|
||||||
from bumble.profiles import le_audio
|
from bumble.profiles import le_audio
|
||||||
from bumble import gatt
|
from bumble.profiles.bap import AudioLocation, CodecSpecificCapabilities, ContextType
|
||||||
from bumble import gatt_adapters
|
|
||||||
from bumble import gatt_client
|
|
||||||
from bumble import hci
|
|
||||||
|
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
# Logging
|
# Logging
|
||||||
@@ -42,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
|
||||||
@@ -59,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:
|
||||||
@@ -104,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]
|
||||||
|
|
||||||
@@ -118,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 = []
|
||||||
|
|
||||||
@@ -186,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, ...]]
|
||||||
|
|
||||||
|
|||||||
@@ -16,10 +16,13 @@
|
|||||||
# Imports
|
# Imports
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import dataclasses
|
import dataclasses
|
||||||
import enum
|
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
|
||||||
|
|
||||||
|
|
||||||
@@ -44,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
|
||||||
|
|||||||
@@ -22,15 +22,14 @@ import logging
|
|||||||
import struct
|
import struct
|
||||||
|
|
||||||
from bumble.gatt import (
|
from bumble.gatt import (
|
||||||
TemplateService,
|
|
||||||
Characteristic,
|
|
||||||
GATT_TELEPHONY_AND_MEDIA_AUDIO_SERVICE,
|
GATT_TELEPHONY_AND_MEDIA_AUDIO_SERVICE,
|
||||||
GATT_TMAP_ROLE_CHARACTERISTIC,
|
GATT_TMAP_ROLE_CHARACTERISTIC,
|
||||||
|
Characteristic,
|
||||||
|
TemplateService,
|
||||||
)
|
)
|
||||||
from bumble.gatt_adapters import DelegatedCharacteristicProxyAdapter
|
from bumble.gatt_adapters import DelegatedCharacteristicProxyAdapter
|
||||||
from bumble.gatt_client import CharacteristicProxy, ProfileServiceProxy, ServiceProxy
|
from bumble.gatt_client import CharacteristicProxy, ProfileServiceProxy, ServiceProxy
|
||||||
|
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
# Logging
|
# Logging
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -17,18 +17,12 @@
|
|||||||
# Imports
|
# Imports
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import dataclasses
|
import dataclasses
|
||||||
import enum
|
import enum
|
||||||
|
from collections.abc import Sequence
|
||||||
|
|
||||||
from typing import Sequence
|
from bumble import att, device, gatt, gatt_adapters, gatt_client
|
||||||
|
|
||||||
from bumble import att
|
|
||||||
from bumble import utils
|
|
||||||
from bumble import device
|
|
||||||
from bumble import gatt
|
|
||||||
from bumble import gatt_adapters
|
|
||||||
from bumble import gatt_client
|
|
||||||
|
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
# Constants
|
# Constants
|
||||||
|
|||||||
@@ -18,19 +18,19 @@
|
|||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
import struct
|
import struct
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from typing import Optional
|
|
||||||
|
|
||||||
from bumble.device import Connection
|
from bumble import utils
|
||||||
from bumble.att import ATT_Error
|
from bumble.att import ATT_Error
|
||||||
|
from bumble.device import Connection
|
||||||
from bumble.gatt import (
|
from bumble.gatt import (
|
||||||
Characteristic,
|
GATT_AUDIO_LOCATION_CHARACTERISTIC,
|
||||||
TemplateService,
|
GATT_AUDIO_OUTPUT_DESCRIPTION_CHARACTERISTIC,
|
||||||
CharacteristicValue,
|
GATT_VOLUME_OFFSET_CONTROL_POINT_CHARACTERISTIC,
|
||||||
GATT_VOLUME_OFFSET_CONTROL_SERVICE,
|
GATT_VOLUME_OFFSET_CONTROL_SERVICE,
|
||||||
GATT_VOLUME_OFFSET_STATE_CHARACTERISTIC,
|
GATT_VOLUME_OFFSET_STATE_CHARACTERISTIC,
|
||||||
GATT_AUDIO_LOCATION_CHARACTERISTIC,
|
Characteristic,
|
||||||
GATT_VOLUME_OFFSET_CONTROL_POINT_CHARACTERISTIC,
|
CharacteristicValue,
|
||||||
GATT_AUDIO_OUTPUT_DESCRIPTION_CHARACTERISTIC,
|
TemplateService,
|
||||||
)
|
)
|
||||||
from bumble.gatt_adapters import (
|
from bumble.gatt_adapters import (
|
||||||
DelegatedCharacteristicProxyAdapter,
|
DelegatedCharacteristicProxyAdapter,
|
||||||
@@ -38,7 +38,6 @@ from bumble.gatt_adapters import (
|
|||||||
UTF8CharacteristicProxyAdapter,
|
UTF8CharacteristicProxyAdapter,
|
||||||
)
|
)
|
||||||
from bumble.gatt_client import ProfileServiceProxy, ServiceProxy
|
from bumble.gatt_client import ProfileServiceProxy, ServiceProxy
|
||||||
from bumble import utils
|
|
||||||
from bumble.profiles.bap import AudioLocation
|
from bumble.profiles.bap import AudioLocation
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
@@ -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
|
||||||
)
|
)
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user