Compare commits

...

180 Commits

Author SHA1 Message Date
Gilles Boccon-Gibod
d35643524e allow specifying the address type 2023-12-08 18:46:25 -08:00
Gilles Boccon-Gibod
085f163c92 add support for 2M phy 2023-12-08 10:14:38 -08:00
zxzxwu
88b4cbdf1a Merge pull request #364 from zxzxwu/iso
Fix ISO packet issues
2023-12-05 00:41:56 +08:00
Josh Wu
d6afbc6f4e Fix ISO packet issues 2023-12-04 20:31:11 +08:00
Gilles Boccon-Gibod
fc90de3e7b Merge pull request #351 from google/dependabot/cargo/rust/openssl-0.10.60
Bump openssl from 0.10.57 to 0.10.60 in /rust
2023-12-04 00:41:27 -08:00
Gilles Boccon-Gibod
847c2ef114 Merge pull request #362 from google/gbg/more-le-features-constants
a few more HCI constants from the spec
2023-12-04 00:38:02 -08:00
Gilles Boccon-Gibod
a0bf0c1f4d Merge pull request #363 from google/gbg/android-remote-proxy-cli
android remote proxy cli
2023-12-04 00:37:49 -08:00
Gilles Boccon-Gibod
8400ff0802 shared usage printer 2023-12-04 00:37:28 -08:00
Gilles Boccon-Gibod
0ed6aa230b address PR comment 2023-12-04 00:32:04 -08:00
Gilles Boccon-Gibod
72d5360af9 keep projects compatible with Android Studio Hedgehog 2023-12-03 18:06:54 -08:00
Gilles Boccon-Gibod
ac3961e763 add doc 2023-12-03 17:50:42 -08:00
Gilles Boccon-Gibod
843466c822 a few more constants from the spec 2023-12-03 17:16:25 -08:00
Gilles Boccon-Gibod
8385035400 add CLI support 2023-12-03 16:35:14 -08:00
zxzxwu
3adcc8be09 Merge pull request #360 from zxzxwu/hci
Remove # type: ignore[call-arg] in HCI_Command builders
2023-12-03 19:18:04 +08:00
zxzxwu
c853d56302 Merge pull request #361 from zxzxwu/hci-bug
Fix typo
2023-12-03 04:22:59 +08:00
Josh Wu
dc97be5b35 Fix typo 2023-12-02 23:42:21 +08:00
zxzxwu
73dbdfff9f Merge pull request #356 from zxzxwu/bap
Add Published Audio Capabilities Service
2023-12-02 23:34:57 +08:00
Josh Wu
dff14e1258 Add Published Audio Capabilities Service 2023-12-02 23:16:37 +08:00
Josh Wu
10a3833893 Remove # type: ignore[call-arg] in HCI_Command builders 2023-12-02 19:18:54 +08:00
zxzxwu
247cb89332 Merge pull request #358 from zxzxwu/coding2
Add variable-length bytes field
2023-12-01 03:26:38 +08:00
Josh Wu
3fc71a0266 Add variable-length bytes field 2023-12-01 03:16:52 +08:00
zxzxwu
392dcc3a05 Merge pull request #357 from zxzxwu/coding
Refactor CodingFormat
2023-12-01 03:15:33 +08:00
Josh Wu
f27015d1b7 Refactor CodingFormat
As CodingFormat is now used by HFP and LEA, and vendor specific codecs
are introduced, this object needs to provide more information.
2023-12-01 02:58:09 +08:00
zxzxwu
86a19b41aa Merge pull request #344 from zxzxwu/cis
CIS and SCO responder support
2023-11-30 21:00:55 +08:00
Gilles Boccon-Gibod
320164d476 Merge pull request #355 from google/gbg/fix-gatt-unsubscribe
fix #354 (gatt unsubscribe)
2023-11-29 22:28:57 -08:00
Josh Wu
40ae661ee5 More SCO support and warnings and typo fix 2023-11-30 12:59:43 +08:00
Josh Wu
c5def93bb8 CIS and SCO responder support 2023-11-30 12:16:40 +08:00
zxzxwu
a9c4c5833d Merge pull request #350 from zxzxwu/csip
Add Coordinated Set Identification Service(CSIS)
2023-11-30 12:15:56 +08:00
Gilles Boccon-Gibod
58c9c4f590 fix #354 2023-11-29 19:19:40 -08:00
zxzxwu
24524d88cb Merge pull request #342 from zxzxwu/typing
Typing helper
2023-11-30 00:21:44 +08:00
zxzxwu
b8849ab311 Merge pull request #349 from zxzxwu/stack
Log track back in on_packet
2023-11-30 00:20:20 +08:00
Josh Wu
f3cd8f8ed0 Typing helper 2023-11-29 21:24:27 +08:00
zxzxwu
2b26de3f3a Merge pull request #348 from zxzxwu/gattc
Typing GATT Client and Device Peer
2023-11-29 15:09:40 +08:00
Josh Wu
0149c4c212 Log track back in on_packet
Many errors are raised in on_packet() callbacks, but currently it only
provides a very brief error message.
2023-11-29 15:01:15 +08:00
Gilles Boccon-Gibod
f2ed898784 Merge pull request #352 from google/gbg/more-gatt-uuids
add a few uuids
2023-11-28 22:44:40 -08:00
Josh Wu
464a476f9f Add CSIP 2023-11-29 14:09:31 +08:00
Gilles Boccon-Gibod
e85d067fb5 add a few uuids 2023-11-28 20:02:00 -08:00
dependabot[bot]
7eb493990f Bump openssl from 0.10.57 to 0.10.60 in /rust
Bumps [openssl](https://github.com/sfackler/rust-openssl) from 0.10.57 to 0.10.60.
- [Release notes](https://github.com/sfackler/rust-openssl/releases)
- [Commits](https://github.com/sfackler/rust-openssl/compare/openssl-v0.10.57...openssl-v0.10.60)

---
updated-dependencies:
- dependency-name: openssl
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2023-11-28 21:43:18 +00:00
Josh Wu
04d5bf3afc Typing GATT Client and Device Peer 2023-11-28 21:57:57 +08:00
zxzxwu
a13e193d3b Merge pull request #343 from zxzxwu/lea-gatt
Add LE Audio GATT services and characteristics definitions
2023-11-28 10:34:39 +08:00
Gilles Boccon-Gibod
28a1a5ebc2 Merge pull request #347 from akuker/main
Include transport.grpc_protobuf in the setup package.
2023-11-27 15:23:17 -08:00
Tony Kuker
6310dc777f Include transport.grpc_protobuf in the setup package. 2023-11-27 16:48:37 -06:00
Josh Wu
863de18877 Add LE Audio GATT definitions 2023-11-27 17:53:00 +08:00
zxzxwu
f0e5cdee1a Merge pull request #339 from zxzxwu/enc
Refactor crypto and fix CTKD
2023-11-27 14:05:37 +08:00
zxzxwu
7bc7d0f5af Merge pull request #334 from zxzxwu/extadv
Add support for LE Extended Advertising
2023-11-27 14:01:31 +08:00
Josh Wu
a65a215fd7 Provide IntFlag.name property fallback 2023-11-26 19:42:22 +08:00
Josh Wu
80d34a226d Slightly refactor and fix CTKD
It seems sample input data provided in the spec is big-endian (just
like other AES-CMAC-based functions), but all keys are in little-endian(
HCI standard), so they need to be reverse before and after applying
AES-CMAC.
2023-11-26 16:55:10 +08:00
Josh Wu
a9628f73e3 Add support for Extended Advertising 2023-11-26 15:03:09 +08:00
Lucas Abel
9bf2e03354 device: set authenticated and sc state on AES encryption change 2023-11-23 06:39:55 +01:00
Gilles Boccon-Gibod
2900b93bb3 Merge pull request #120 from google/gbg/usb-cleanup
minor cleanup of the internals of the usb transport implementation
2023-11-22 17:18:23 -08:00
Gilles Boccon-Gibod
284cc8a321 Merge pull request #326 from google/gbg/android-benchmark-app
Android benchmarking app
2023-11-22 15:39:52 -08:00
Gilles Boccon-Gibod
3dc2e4036c rebase 2023-11-22 15:32:37 -08:00
Gilles Boccon-Gibod
268f6b0d51 remove unneeded constructor parameters 2023-11-22 15:30:18 -08:00
Gilles Boccon-Gibod
46239b321b address PR comments 2023-11-22 15:30:18 -08:00
Gilles Boccon-Gibod
8a536cd522 fix missed merge 2023-11-22 15:30:18 -08:00
Gilles Boccon-Gibod
f9f5d7ccbd first implementation (+1 squashed commit)
Squashed commits:
[ee00d67] wip
2023-11-22 15:30:16 -08:00
zxzxwu
e08c84dd20 Merge pull request #333 from zxzxwu/iso
Add ISO related HCI packets
2023-11-21 15:55:00 +08:00
Josh Wu
8b46136703 Add ISO related HCI packets 2023-11-20 22:47:02 +08:00
Gilles Boccon-Gibod
9c7089c8ff terminate when unplugged 2023-11-19 11:36:38 -08:00
Gilles Boccon-Gibod
aac8d89cd0 Merge pull request #330 from benquike/main
Do not exit after pairing is finished
2023-11-18 08:57:58 -08:00
Hui Peng
24e75bfeab Do not exit after pairing is finished
Android performs additional service
discovery during pairing, otherwise
pairing fails.
2023-11-17 09:17:40 -08:00
zxzxwu
42868b08d3 Merge pull request #335 from zxzxwu/a2dp
Typing A2DP
2023-11-18 00:21:20 +08:00
zxzxwu
19b61d9ac0 Merge pull request #336 from zxzxwu/hid
Cleanup HID module
2023-11-17 23:34:03 +08:00
Josh Wu
db2a2e2bb9 Cleanup HID module
* Remove unused imports
* Replace typing exceptions by better assertions
2023-11-17 17:43:07 +08:00
Josh Wu
e1fdb12647 Typing A2DP 2023-11-17 17:29:35 +08:00
Gilles Boccon-Gibod
a8ec1b0949 minor cleanup of the internals of the usb transport implementation 2023-11-15 17:26:21 -08:00
Gilles Boccon-Gibod
2e30b2de77 Merge pull request #329 from google/gbg/le-oob
le oob
2023-11-15 16:10:20 -08:00
Gilles Boccon-Gibod
7e407ccae1 address PR comments 2023-11-15 15:48:19 -08:00
zxzxwu
0667e83919 Merge pull request #254 from zxzxwu/sco
eSCO codec/HCI definitions + Host support
2023-11-13 20:01:06 +08:00
Gilles Boccon-Gibod
1a6c9a4d04 improve help 2023-11-10 12:17:21 -08:00
Gilles Boccon-Gibod
14f5b912ad use ad_data directly 2023-11-10 11:53:54 -08:00
Gilles Boccon-Gibod
46d6242171 Merge pull request #316 from whitevegagabriel/extended
Add support for extended advertising via Rust-only API
2023-11-09 13:43:00 -08:00
Gilles Boccon-Gibod
753b966148 format 2023-11-09 12:44:02 -08:00
Gilles Boccon-Gibod
5a307c19b8 add oob data on command line 2023-11-07 20:38:35 -08:00
Lucas Abel
2cd4f84800 pandora: add annotations import 2023-11-06 14:06:56 -08:00
Gilles Boccon-Gibod
4ae612090b wip 2023-11-06 13:19:13 -08:00
Gilles Boccon-Gibod
c67ca4a09e Merge pull request #324 from google/gbg/hotfix-002
fix typo
2023-10-31 20:58:19 +01:00
Gilles Boccon-Gibod
94506220d3 fix typo 2023-10-31 12:18:28 -07:00
Gilles Boccon-Gibod
dbd865a484 Merge pull request #323 from google/gbg/device-hive
Device hive
2023-10-31 16:44:18 +01:00
Gilles Boccon-Gibod
9d2f3e932a format 2023-10-29 11:32:00 -07:00
Gilles Boccon-Gibod
49d32f5b5b add netsim.ini info 2023-10-29 10:26:34 -07:00
Gilles Boccon-Gibod
f7b74c0bcb add hive to index page 2023-10-29 10:03:31 -07:00
Gilles Boccon-Gibod
c75cb0c7b7 fix css 2023-10-29 09:58:37 -07:00
Gilles Boccon-Gibod
a63b335149 wip 2023-10-29 09:36:17 -07:00
Gilles Boccon-Gibod
d8517ce407 add links 2023-10-29 08:53:25 -07:00
Gilles Boccon-Gibod
ad13b11464 wip 2023-10-29 08:53:23 -07:00
Gilles Boccon-Gibod
99bc92d53d wip (+5 squashed commits)
Squashed commits:
[53c6c53] wip
[66f482c] wip
[b003315] wip
[f6f9d9e] wip
[4c95c7b] wip
2023-10-29 08:50:25 -07:00
Josh Wu
72199f5615 Add address resolution offload to config 2023-10-24 17:04:43 -07:00
skarnataki
78b8b50082 fixed lint errors 2023-10-19 17:19:49 -07:00
skarnataki
3ab64ce00d Fixed lint and pre-commit errors. 2023-10-19 17:19:49 -07:00
skarnataki
651e44e0b6 Submitting review comment fix: header function and extra lines.
Executed formatter on file.
2023-10-19 17:19:49 -07:00
skarnataki
963fa41a49 Submitting review comment fix: header function and extra lines. 2023-10-19 17:19:49 -07:00
skarnataki
493f4f8b95 Submitting review comment fix: header function and spacing 2023-10-19 17:19:49 -07:00
skarnataki
fc1bf36ace Review changes comment fix. Classes/Subclass/dataclass. Enum constants.
Naming conventions
2023-10-19 17:19:49 -07:00
skarnataki
5ddee17411 Commit to fix review comments for dataclass and subclass, shifting contants to Message Class
Commit for enum and dataclass
2023-10-19 17:19:49 -07:00
skarnataki
5ce353bcde Review comment Fix 2023-10-19 17:19:49 -07:00
SneKarnataki
16d33199eb Change in sdp.py file while testing hid profile,
TEXT_STRING: lambda x: DataElement(DataElement.TEXT_STRING, x.decode('utf8')) changed to
TEXT_STRING: lambda x: DataElement(DataElement.TEXT_STRING, x)
as we were facing error "UnicodeDecodeError: 'utf-8' codec can't decode byte 0xa1 in position 4: invalid start byte" while fetching sdp records.
2023-10-19 17:19:49 -07:00
SneKarnataki
e02303a448 Submitting the initial version of HID Profile files
Includes:
1. HID Host implementation - hid.py
2. HID application to test Host with 3rd party HID Device application - run_hid_host.py
3. HID supporting files for testing - hid_report_parser.py & hid_key_map.py

Commands to run the application:
Default application:
python run_hid_host.py classic1.json usb:0 <device bd-addr>

Menu options for testing (Get/Set):
python run_hid_host.py classic1.json usb:0 <device bd-addr> test-mode

CuttleFish:tcp-client:127.0.0.1:7300

Application used for testing as Device : Bluetooth Keyboard & Mouse-5.3.0.apk

Note: Change in sdp.py file while testing hid profile,
TEXT_STRING: lambda x: DataElement(DataElement.TEXT_STRING, x.decode('utf8')) changed to
TEXT_STRING: lambda x: DataElement(DataElement.TEXT_STRING, x)
as we were facing error "UnicodeDecodeError: 'utf-8' codec can't decode byte 0xa1 in position 4: invalid start byte" while fetching sdp records.
2023-10-19 17:19:49 -07:00
Fahad Afroze
36fc966ad6 Trial checkin code 2023-10-19 17:19:49 -07:00
skarnataki
644f74400d Trial to commit in dhavan repo 2023-10-19 17:19:49 -07:00
dhavan
b7cd451ddb Hid profile implemenation. Empty file 2023-10-19 17:19:49 -07:00
Gabriel White-Vega
59d7717963 Remove mutable ret pattern and test feature combinations
After adding test for feature combinations, I found a corner case where, when Transport is dropped and the process is terminated in a test, the `close` Python future is not awaited.
I don't know what other situations this issue may arise, so I have safe-guarded it via `block_on` instead of spawning a thread.
2023-10-18 15:39:37 -04:00
Gilles Boccon-Gibod
88392efca4 Merge pull request #312 from google/gbg/android-remote-hci
remote hci android app
2023-10-17 13:30:49 +02:00
zxzxwu
907f2acc7e Merge pull request #318 from zxzxwu/l2cap_refactor
Cleanup legacy L2CAP API usage
2023-10-17 14:22:45 +08:00
Gilles Boccon-Gibod
6616477bcf Merge pull request #319 from google/gbg/bt-spec-version-5-4
add constant for 5.4
2023-10-11 18:44:03 -07:00
Gilles Boccon-Gibod
5b173cb879 add constant for 5.4 2023-10-11 17:47:21 -07:00
Gilles Boccon-Gibod
dc6b466a42 add intent parameters 2023-10-11 16:52:15 -07:00
zxzxwu
8b04161da3 Merge pull request #317 from zxzxwu/pytest
Add missing @pytest.mark.asyncio decorator
2023-10-11 15:16:35 +08:00
Josh Wu
5a85765360 Cleanup legacy L2CAP API 2023-10-11 14:33:44 +08:00
Josh Wu
333940919b Add missing @pytest.mark.asyncio decorator 2023-10-11 13:52:06 +08:00
Gilles Boccon-Gibod
b9476be9ad Merge pull request #315 from google/gbg/company-ids
update to latest list of company ids
2023-10-10 22:13:16 -07:00
Gilles Boccon-Gibod
704c60491c Merge pull request #313 from benquike/pair_fix
Allow turning on BLE in classic pairing mode
2023-10-10 21:30:24 -07:00
Gilles Boccon-Gibod
4a8e612c6e update rust list 2023-10-10 21:29:39 -07:00
Gilles Boccon-Gibod
5e5c9c2580 fix byte order and packet accounting 2023-10-10 21:17:20 -07:00
Gilles Boccon-Gibod
4e71ec5738 remove stale comment 2023-10-10 20:36:48 -07:00
Gabriel White-Vega
1004f10384 Address PR comments 2023-10-10 16:45:02 -04:00
Gabriel White-Vega
1051648ffb Add support for extended advertising via Rust-only API
* Extended functionality is gated on an "unstable" feature
* Designed for very simple use and minimal interferance with existing legacy implementation
* Intended to be temporary, until bumble can integrate extended advertising into its core functionality
* Dropped `HciCommandWrapper` in favor of using bumble's `HCI_Command.from_bytes` for converting from PDL into bumble implementation
* Refactored Address and Device constructors to better match what the python constructors expect
2023-10-10 13:35:31 -04:00
uael
7255a09705 ci: add python avatar tests 2023-10-09 23:37:23 +02:00
zxzxwu
c2bf6b5f13 Merge pull request #289 from zxzxwu/l2cap_refactor
Refactor L2CAP API
2023-10-09 23:27:25 +08:00
Gilles Boccon-Gibod
d8e699b588 use the new yaml file instead of the previous CSV file 2023-10-07 23:10:49 -07:00
zxzxwu
3e4d4705f5 Merge pull request #314 from zxzxwu/sec_pandora
Pandora: Handle exception in WaitSecurity()
2023-10-08 01:42:45 +08:00
Josh Wu
c8b2804446 Pandora: Handle exception in WaitSecurity() 2023-10-07 21:17:01 +08:00
Josh Wu
e732f2589f Refactor L2CAP API 2023-10-07 20:01:15 +08:00
zxzxwu
aec5543081 Merge pull request #310 from zxzxwu/avdtp
Typing AVDTP
2023-10-07 19:50:56 +08:00
Josh Wu
e03d90ca57 Add typing for MediaCodecCapabilities members 2023-10-07 19:32:19 +08:00
Josh Wu
495ce62d9c Typing AVDTP 2023-10-07 19:32:19 +08:00
Hui Peng
fbc3959a5a Allow turning on BLE in classic pairing mode 2023-10-06 19:54:18 -07:00
Gilles Boccon-Gibod
246b11925c add remote hci android app 2023-10-06 14:10:51 -07:00
Gilles Boccon-Gibod
dfa9131192 Merge pull request #311 from zxzxwu/rust
Fix Rust lints
2023-10-06 13:37:47 -07:00
Josh Wu
88c801b4c2 Replace or_insert_with with or_default 2023-10-06 18:02:46 +08:00
Gilles Boccon-Gibod
a1b55b94e0 Merge pull request #301 from whitevegagabriel/simplify-event-loop-copy
Remove unncecesary steps for injecting Python event loop
2023-10-02 12:12:41 -07:00
Gilles Boccon-Gibod
80db9e2e2f Merge pull request #303 from whitevegagabriel/hci-command-rs
Ability to send HCI commands from Rust
2023-10-02 12:12:05 -07:00
Gabriel White-Vega
ce74690420 Update pdl to 0.2.0
- Allows removing impl PartialEq for pdl Error
2023-10-02 11:20:44 -04:00
Gilles Boccon-Gibod
50de4dfb5d Merge pull request #307 from google/gbg/hotfix-001
don't delete advertising prefs on disconnection
2023-09-30 17:46:53 -07:00
Gilles Boccon-Gibod
9bcdf860f4 don't delete advertising prefs on disconnection 2023-09-30 17:41:18 -07:00
Gabriel White-Vega
511ab4b630 Add python async wrapper, move hci non-wrapper to internal, add hci::internal tests 2023-09-29 10:23:19 -04:00
Gilles Boccon-Gibod
6f2b623e3c Merge pull request #290 from google/gbg/netsim-transport-injectable-channels
make grpc channels injectable
2023-09-27 22:16:05 -07:00
Gilles Boccon-Gibod
fa12165cd3 Merge pull request #298 from google/gbg/use-address-to-string
use Address.to_string instead of manual suffix replacement
2023-09-27 21:59:32 -07:00
Gilles Boccon-Gibod
c0c6f3329d minor cleanup 2023-09-27 21:53:54 -07:00
Gilles Boccon-Gibod
406a932467 make grpc channels injectable 2023-09-27 21:37:36 -07:00
Gilles Boccon-Gibod
cc96d4245f address PR comments 2023-09-27 21:25:13 -07:00
Sparkling Diva
c6cdca8923 device: return the psm value from register_l2cap 2023-09-27 16:41:38 -07:00
Josh Wu
45edcafb06 SCO: A loopback example 2023-09-27 23:30:26 +08:00
Josh Wu
9f0bcc131f eSCO support 2023-09-27 23:30:17 +08:00
Gabriel White-Vega
7e331c2944 Ability to send HCI commands from Rust
* Autogenerate packet code in Rust from PDL (packet file copied from rootcanal)
* Implement parsing of packets that have a type header
* Expose Python APIs for sending HCI commands
* Expose Python APIs for instantiating a local controller
2023-09-27 11:17:47 -04:00
Gilles Boccon-Gibod
10347765cb Merge pull request #302 from google/gbg/netsim-with-instance-num
support netsim instance numbers
2023-09-26 09:34:28 -07:00
Gilles Boccon-Gibod
c12dee4e76 Merge pull request #294 from mauricelam/wasm-cryptography
Make cryptography a valid dependency for emscripten targets
2023-09-25 19:29:09 -07:00
Maurice Lam
772c188674 Fix typo 2023-09-25 18:08:52 -07:00
Maurice Lam
7c1a3bb8f9 Separate version specifier for cryptography in Emscripten builds 2023-09-22 16:43:40 -07:00
Maurice Lam
8c3c0b1e13 Make cryptography a valid dependency for emscripten targets
Since only the special cryptography package bundled with pyodide can be
used, relax the version requirement to anything that's version 39.*.

Fix #284
2023-09-22 16:43:40 -07:00
Gilles Boccon-Gibod
1ad84ad51c fix linter errors 2023-09-22 15:08:10 -07:00
Gilles Boccon-Gibod
64937c3f77 support netsim instance numbers 2023-09-22 14:22:04 -07:00
Gabriel White-Vega
50fd2218fa Remove unncecesary steps for injecting Python event loop
* Context vars can be injected directly into Rust future and spawned with tokio
2023-09-22 15:23:01 -04:00
Gilles Boccon-Gibod
4c29a16271 Merge pull request #297 from google/gbg/websocket-full-url
ws-client: make implementation match the doc
2023-09-22 11:41:24 -07:00
Gilles Boccon-Gibod
762d3e92de Merge pull request #300 from google/gbg/issue-299
use correct own_address_type when restarting advertising
2023-09-22 11:41:04 -07:00
uael
2f97531d78 pandora: use public identity address for public addresses 2023-09-22 20:08:34 +02:00
Gilles Boccon-Gibod
f6c7cae661 use correct own_address_type when restarting advertising 2023-09-22 10:33:36 -07:00
Gilles Boccon-Gibod
f1777a5bd2 use .to_string instead of a manual suffix replacement 2023-09-21 19:03:54 -07:00
Gilles Boccon-Gibod
78a06ae8cf make implementation match the doc 2023-09-21 19:01:40 -07:00
zxzxwu
d290df4aa9 Merge pull request #278 from zxzxwu/gatt2
Typing GATT
2023-09-21 16:09:36 +08:00
Josh Wu
e559744f32 Typing att 2023-09-21 15:52:07 +08:00
zxzxwu
67418e649a Merge pull request #288 from zxzxwu/l2cap_states
L2CAP: Refactor states to enums
2023-09-21 15:42:21 +08:00
Gilles Boccon-Gibod
5adf9fab53 Merge pull request #275 from whitevegagabriel/file-header
Add license header check for rust files
2023-09-20 16:21:38 -07:00
Josh Wu
2491b686fa Handle SMP_Security_Request 2023-09-20 23:13:08 +02:00
Josh Wu
efd02b2f3e Adopt reviews 2023-09-20 23:03:23 +02:00
Josh Wu
3b14078646 Overload signatures 2023-09-20 23:03:23 +02:00
Josh Wu
eb9d5632bc Add utils_test type hint 2023-09-20 23:03:23 +02:00
Josh Wu
45f60edbb6 Pyee watcher context 2023-09-20 23:03:23 +02:00
David Duarte
393ea6a7bb pandora_server: Load server config
Pandora server has it's own config that we load from the 'server'
property of the current bumble config file
2023-09-18 14:28:42 -07:00
Gabriel White-Vega
6ec6f1efe5 Add license header check for rust files
Added binary that can check for and add Apache 2.0 licenses.
Run this binary during the build-rust workflow.
2023-09-14 14:29:47 -04:00
Josh Wu
5d9598ea51 L2CAP: Refactor states to enums 2023-09-14 20:52:33 +08:00
Gilles Boccon-Gibod
0d36d99a73 Merge pull request #287 from google/revert-286-gbg/package-depencencies-for-wasm
Revert "make cryptography a valid dependency for emscripten targets"
2023-09-13 23:37:42 -07:00
Gilles Boccon-Gibod
d8a9f5a724 Revert "make cryptography a valid dependency for emscripten targets" 2023-09-13 23:36:33 -07:00
Gilles Boccon-Gibod
2c66e1a042 Merge pull request #285 from google/gbg/fix-mypy-errors
mypy: ignore false positive errors
2023-09-13 23:30:50 -07:00
Gilles Boccon-Gibod
d5eccdb00f Merge pull request #286 from google/gbg/package-depencencies-for-wasm
make cryptography a valid dependency for emscripten targets
2023-09-13 23:30:28 -07:00
Gilles Boccon-Gibod
32626573a6 ignore false positive errors 2023-09-13 23:17:00 -07:00
Gilles Boccon-Gibod
caa82b8f7e make cryptography a valid dependency for emscripten targets 2023-09-13 22:38:28 -07:00
Gilles Boccon-Gibod
5af347b499 Merge pull request #282 from google/gbg/multi-python-pre-commit-check
run pre-commit tests with all supported Python versions
2023-09-13 07:47:32 -07:00
zxzxwu
4ed5bb5a9e Merge pull request #281 from zxzxwu/cleanup-transport
Replace | typing usage with Optional and Union
2023-09-13 13:31:41 +08:00
Josh Wu
f39f5f531c Replace | typing usage with Optional and Union 2023-09-12 15:50:51 +08:00
277 changed files with 25436 additions and 2337 deletions

43
.github/workflows/python-avatar.yml vendored Normal file
View File

@@ -0,0 +1,43 @@
name: Python Avatar
on:
push:
branches: [ main ]
pull_request:
branches: [ main ]
permissions:
contents: read
jobs:
test:
name: Avatar [${{ matrix.shard }}]
runs-on: ubuntu-latest
strategy:
matrix:
shard: [
1/24, 2/24, 3/24, 4/24,
5/24, 6/24, 7/24, 8/24,
9/24, 10/24, 11/24, 12/24,
13/24, 14/24, 15/24, 16/24,
17/24, 18/24, 19/24, 20/24,
21/24, 22/24, 23/24, 24/24,
]
steps:
- uses: actions/checkout@v3
- name: Set Up Python 3.11
uses: actions/setup-python@v4
with:
python-version: 3.11
- name: Install
run: |
python -m pip install --upgrade pip
python -m pip install .[avatar]
- name: Rootcanal
run: nohup python -m rootcanal > rootcanal.log &
- name: Test
run: |
avatar --list | grep -Ev '^=' > test-names.txt
timeout 5m avatar --test-beds bumble.bumbles --tests $(split test-names.txt -n l/${{ matrix.shard }})
- name: Rootcanal Logs
run: cat rootcanal.log

View File

@@ -56,7 +56,7 @@ jobs:
uses: actions/setup-python@v4 uses: actions/setup-python@v4
with: with:
python-version: ${{ matrix.python-version }} python-version: ${{ matrix.python-version }}
- name: Install dependencies - name: Install Python dependencies
run: | run: |
python -m pip install --upgrade pip python -m pip install --upgrade pip
python -m pip install ".[build,test,development,documentation]" python -m pip install ".[build,test,development,documentation]"
@@ -65,13 +65,17 @@ jobs:
with: with:
components: clippy,rustfmt components: clippy,rustfmt
toolchain: ${{ matrix.rust-version }} toolchain: ${{ matrix.rust-version }}
- name: Install Rust dependencies
run: cargo install cargo-all-features # allows building/testing combinations of features
- name: Check License Headers
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 --all-targets
# 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
- name: Rust Tests - name: Rust Tests
run: cd rust && cargo test run: cd rust && cargo test-all-features
# At some point, hook up publishing the binary. For now, just make sure it builds. # At some point, hook up publishing the binary. For now, just make sure it builds.
# Once we're ready to publish binaries, this should be built with `--release`. # Once we're ready to publish binaries, this should be built with `--release`.
- name: Build Bumble CLI - name: Build Bumble CLI

View File

@@ -21,7 +21,9 @@
"cccds", "cccds",
"cmac", "cmac",
"CONNECTIONLESS", "CONNECTIONLESS",
"csip",
"csrcs", "csrcs",
"CVSD",
"datagram", "datagram",
"DATALINK", "DATALINK",
"delayreport", "delayreport",
@@ -29,6 +31,7 @@
"deregistration", "deregistration",
"dhkey", "dhkey",
"diversifier", "diversifier",
"endianness",
"Fitbit", "Fitbit",
"GATTLINK", "GATTLINK",
"HANDSFREE", "HANDSFREE",
@@ -38,13 +41,18 @@
"libc", "libc",
"libusb", "libusb",
"MITM", "MITM",
"MSBC",
"NDIS", "NDIS",
"netsim",
"NONBLOCK", "NONBLOCK",
"NONCONN", "NONCONN",
"OXIMETER", "OXIMETER",
"popleft", "popleft",
"PRAND",
"protobuf",
"psms", "psms",
"pyee", "pyee",
"Pyodide",
"pyusb", "pyusb",
"rfcomm", "rfcomm",
"ROHC", "ROHC",
@@ -52,6 +60,7 @@
"SEID", "SEID",
"seids", "seids",
"SERV", "SERV",
"SIRK",
"ssrc", "ssrc",
"strerror", "strerror",
"subband", "subband",

View File

@@ -24,6 +24,7 @@ import time
import click import click
from bumble import l2cap
from bumble.core import ( from bumble.core import (
BT_BR_EDR_TRANSPORT, BT_BR_EDR_TRANSPORT,
BT_LE_TRANSPORT, BT_LE_TRANSPORT,
@@ -49,8 +50,10 @@ from bumble.sdp import (
SDP_PUBLIC_BROWSE_ROOT, SDP_PUBLIC_BROWSE_ROOT,
SDP_SERVICE_CLASS_ID_LIST_ATTRIBUTE_ID, SDP_SERVICE_CLASS_ID_LIST_ATTRIBUTE_ID,
SDP_SERVICE_RECORD_HANDLE_ATTRIBUTE_ID, SDP_SERVICE_RECORD_HANDLE_ATTRIBUTE_ID,
SDP_BLUETOOTH_PROFILE_DESCRIPTOR_LIST_ATTRIBUTE_ID,
DataElement, DataElement,
ServiceAttribute, ServiceAttribute,
Client as SdpClient,
) )
from bumble.transport import open_transport_or_link from bumble.transport import open_transport_or_link
import bumble.rfcomm import bumble.rfcomm
@@ -76,6 +79,7 @@ SPEED_SERVICE_UUID = '50DB505C-8AC4-4738-8448-3B1D9CC09CC5'
SPEED_TX_UUID = 'E789C754-41A1-45F4-A948-A0A1A90DBA53' SPEED_TX_UUID = 'E789C754-41A1-45F4-A948-A0A1A90DBA53'
SPEED_RX_UUID = '016A2CC7-E14B-4819-935F-1F56EAE4098D' SPEED_RX_UUID = '016A2CC7-E14B-4819-935F-1F56EAE4098D'
DEFAULT_RFCOMM_UUID = 'E6D55659-C8B4-4B85-96BB-B1143AF6D3AE'
DEFAULT_L2CAP_PSM = 1234 DEFAULT_L2CAP_PSM = 1234
DEFAULT_L2CAP_MAX_CREDITS = 128 DEFAULT_L2CAP_MAX_CREDITS = 128
DEFAULT_L2CAP_MTU = 1022 DEFAULT_L2CAP_MTU = 1022
@@ -85,6 +89,7 @@ DEFAULT_LINGER_TIME = 1.0
DEFAULT_RFCOMM_CHANNEL = 8 DEFAULT_RFCOMM_CHANNEL = 8
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
# Utils # Utils
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
@@ -126,11 +131,16 @@ def print_connection(connection):
if connection.transport == BT_LE_TRANSPORT: if connection.transport == BT_LE_TRANSPORT:
phy_state = ( phy_state = (
'PHY=' 'PHY='
f'RX:{le_phy_name(connection.phy.rx_phy)}/' f'TX:{le_phy_name(connection.phy.tx_phy)}/'
f'TX:{le_phy_name(connection.phy.tx_phy)}' f'RX:{le_phy_name(connection.phy.rx_phy)}'
) )
data_length = f'DL={connection.data_length}' data_length = (
'DL=('
f'TX:{connection.data_length[0]}/{connection.data_length[1]},'
f'RX:{connection.data_length[2]}/{connection.data_length[3]}'
')'
)
connection_parameters = ( connection_parameters = (
'Parameters=' 'Parameters='
f'{connection.parameters.connection_interval * 1.25:.2f}/' f'{connection.parameters.connection_interval * 1.25:.2f}/'
@@ -167,9 +177,7 @@ def make_sdp_records(channel):
), ),
ServiceAttribute( ServiceAttribute(
SDP_SERVICE_CLASS_ID_LIST_ATTRIBUTE_ID, SDP_SERVICE_CLASS_ID_LIST_ATTRIBUTE_ID,
DataElement.sequence( DataElement.sequence([DataElement.uuid(UUID(DEFAULT_RFCOMM_UUID))]),
[DataElement.uuid(UUID('E6D55659-C8B4-4B85-96BB-B1143AF6D3AE'))]
),
), ),
ServiceAttribute( ServiceAttribute(
SDP_PROTOCOL_DESCRIPTOR_LIST_ATTRIBUTE_ID, SDP_PROTOCOL_DESCRIPTOR_LIST_ATTRIBUTE_ID,
@@ -189,6 +197,48 @@ def make_sdp_records(channel):
} }
async def find_rfcomm_channel_with_uuid(connection: Connection, uuid: str) -> int:
# Connect to the SDP Server
sdp_client = SdpClient(connection)
await sdp_client.connect()
# Search for services with an L2CAP service attribute
search_result = await sdp_client.search_attributes(
[BT_L2CAP_PROTOCOL_ID],
[
SDP_PROTOCOL_DESCRIPTOR_LIST_ATTRIBUTE_ID,
SDP_BLUETOOTH_PROFILE_DESCRIPTOR_LIST_ATTRIBUTE_ID,
SDP_SERVICE_CLASS_ID_LIST_ATTRIBUTE_ID,
],
)
for attribute_list in search_result:
service_uuid = None
service_class_id_list = ServiceAttribute.find_attribute_in_list(
attribute_list, SDP_SERVICE_CLASS_ID_LIST_ATTRIBUTE_ID
)
if service_class_id_list:
if service_class_id_list.value:
for service_class_id in service_class_id_list.value:
service_uuid = service_class_id.value
if str(service_uuid) != uuid:
# This service doesn't have a UUID or isn't the right one.
continue
# Look for the RFCOMM Channel number
protocol_descriptor_list = ServiceAttribute.find_attribute_in_list(
attribute_list, SDP_PROTOCOL_DESCRIPTOR_LIST_ATTRIBUTE_ID
)
if protocol_descriptor_list:
for protocol_descriptor in protocol_descriptor_list.value:
if len(protocol_descriptor.value) >= 2:
if protocol_descriptor.value[0].value == BT_RFCOMM_PROTOCOL_ID:
await sdp_client.disconnect()
return protocol_descriptor.value[1].value
await sdp_client.disconnect()
return 0
class PacketType(enum.IntEnum): class PacketType(enum.IntEnum):
RESET = 0 RESET = 0
SEQUENCE = 1 SEQUENCE = 1
@@ -197,6 +247,7 @@ class PacketType(enum.IntEnum):
PACKET_FLAG_LAST = 1 PACKET_FLAG_LAST = 1
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
# Sender # Sender
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
@@ -221,7 +272,7 @@ class Sender:
if self.tx_start_delay: if self.tx_start_delay:
print(color(f'*** Startup delay: {self.tx_start_delay}', 'blue')) print(color(f'*** Startup delay: {self.tx_start_delay}', 'blue'))
await asyncio.sleep(self.tx_start_delay) # FIXME await asyncio.sleep(self.tx_start_delay)
print(color('=== Sending RESET', 'magenta')) print(color('=== Sending RESET', 'magenta'))
await self.packet_io.send_packet(bytes([PacketType.RESET])) await self.packet_io.send_packet(bytes([PacketType.RESET]))
@@ -361,7 +412,7 @@ class Ping:
if self.tx_start_delay: if self.tx_start_delay:
print(color(f'*** Startup delay: {self.tx_start_delay}', 'blue')) print(color(f'*** Startup delay: {self.tx_start_delay}', 'blue'))
await asyncio.sleep(self.tx_start_delay) # FIXME await asyncio.sleep(self.tx_start_delay)
print(color('=== Sending RESET', 'magenta')) print(color('=== Sending RESET', 'magenta'))
await self.packet_io.send_packet(bytes([PacketType.RESET])) await self.packet_io.send_packet(bytes([PacketType.RESET]))
@@ -659,17 +710,19 @@ class L2capClient(StreamedPacketIO):
self.mps = mps self.mps = mps
self.ready = asyncio.Event() self.ready = asyncio.Event()
async def on_connection(self, connection): async def on_connection(self, connection: Connection) -> None:
connection.on('disconnection', self.on_disconnection) connection.on('disconnection', self.on_disconnection)
# Connect a new L2CAP channel # Connect a new L2CAP channel
print(color(f'>>> Opening L2CAP channel on PSM = {self.psm}', 'yellow')) print(color(f'>>> Opening L2CAP channel on PSM = {self.psm}', 'yellow'))
try: try:
l2cap_channel = await connection.open_l2cap_channel( l2cap_channel = await connection.create_l2cap_channel(
psm=self.psm, spec=l2cap.LeCreditBasedChannelSpec(
max_credits=self.max_credits, psm=self.psm,
mtu=self.mtu, max_credits=self.max_credits,
mps=self.mps, mtu=self.mtu,
mps=self.mps,
)
) )
print(color('*** L2CAP channel:', 'cyan'), l2cap_channel) print(color('*** L2CAP channel:', 'cyan'), l2cap_channel)
except Exception as error: except Exception as error:
@@ -695,7 +748,7 @@ class L2capClient(StreamedPacketIO):
class L2capServer(StreamedPacketIO): class L2capServer(StreamedPacketIO):
def __init__( def __init__(
self, self,
device, device: Device,
psm=DEFAULT_L2CAP_PSM, psm=DEFAULT_L2CAP_PSM,
max_credits=DEFAULT_L2CAP_MAX_CREDITS, max_credits=DEFAULT_L2CAP_MAX_CREDITS,
mtu=DEFAULT_L2CAP_MTU, mtu=DEFAULT_L2CAP_MTU,
@@ -705,15 +758,14 @@ class L2capServer(StreamedPacketIO):
self.l2cap_channel = None self.l2cap_channel = None
self.ready = asyncio.Event() self.ready = asyncio.Event()
# Listen for incoming L2CAP CoC connections # Listen for incoming L2CAP connections
device.register_l2cap_channel_server( device.create_l2cap_server(
psm=psm, spec=l2cap.LeCreditBasedChannelSpec(
server=self.on_l2cap_channel, psm=psm, mtu=mtu, mps=mps, max_credits=max_credits
max_credits=max_credits, ),
mtu=mtu, handler=self.on_l2cap_channel,
mps=mps,
) )
print(color(f'### Listening for CoC connection on PSM {psm}', 'yellow')) print(color(f'### Listening for L2CAP connection on PSM {psm}', 'yellow'))
async def on_connection(self, connection): async def on_connection(self, connection):
connection.on('disconnection', self.on_disconnection) connection.on('disconnection', self.on_disconnection)
@@ -739,21 +791,35 @@ class L2capServer(StreamedPacketIO):
# RfcommClient # RfcommClient
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
class RfcommClient(StreamedPacketIO): class RfcommClient(StreamedPacketIO):
def __init__(self, device): def __init__(self, device, channel, uuid):
super().__init__() super().__init__()
self.device = device self.device = device
self.channel = channel
self.uuid = uuid
self.ready = asyncio.Event() self.ready = asyncio.Event()
async def on_connection(self, connection): async def on_connection(self, connection):
connection.on('disconnection', self.on_disconnection) connection.on('disconnection', self.on_disconnection)
# Find the channel number if not specified
channel = self.channel
if channel == 0:
print(
color(f'@@@ Discovering channel number from UUID {self.uuid}', 'cyan')
)
channel = await find_rfcomm_channel_with_uuid(connection, self.uuid)
print(color(f'@@@ Channel number = {channel}', 'cyan'))
if channel == 0:
print(color('!!! No RFComm service with this UUID found', 'red'))
await connection.disconnect()
return
# Create a client and start it # Create a client and start it
print(color('*** Starting RFCOMM client...', 'blue')) print(color('*** Starting RFCOMM client...', 'blue'))
rfcomm_client = bumble.rfcomm.Client(self.device, connection) rfcomm_client = bumble.rfcomm.Client(connection)
rfcomm_mux = await rfcomm_client.start() rfcomm_mux = await rfcomm_client.start()
print(color('*** Started', 'blue')) print(color('*** Started', 'blue'))
channel = DEFAULT_RFCOMM_CHANNEL
print(color(f'### Opening session for channel {channel}...', 'yellow')) print(color(f'### Opening session for channel {channel}...', 'yellow'))
try: try:
rfcomm_session = await rfcomm_mux.open_dlc(channel) rfcomm_session = await rfcomm_mux.open_dlc(channel)
@@ -776,7 +842,7 @@ class RfcommClient(StreamedPacketIO):
# RfcommServer # RfcommServer
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
class RfcommServer(StreamedPacketIO): class RfcommServer(StreamedPacketIO):
def __init__(self, device): def __init__(self, device, channel):
super().__init__() super().__init__()
self.ready = asyncio.Event() self.ready = asyncio.Event()
@@ -784,7 +850,7 @@ class RfcommServer(StreamedPacketIO):
rfcomm_server = bumble.rfcomm.Server(device) rfcomm_server = bumble.rfcomm.Server(device)
# Listen for incoming DLC connections # Listen for incoming DLC connections
channel_number = rfcomm_server.listen(self.on_dlc, DEFAULT_RFCOMM_CHANNEL) channel_number = rfcomm_server.listen(self.on_dlc, channel)
# Setup the SDP to advertise this channel # Setup the SDP to advertise this channel
device.sdp_service_records = make_sdp_records(channel_number) device.sdp_service_records = make_sdp_records(channel_number)
@@ -821,6 +887,9 @@ class Central(Connection.Listener):
mode_factory, mode_factory,
connection_interval, connection_interval,
phy, phy,
authenticate,
encrypt,
extended_data_length,
): ):
super().__init__() super().__init__()
self.transport = transport self.transport = transport
@@ -828,6 +897,9 @@ class Central(Connection.Listener):
self.classic = classic self.classic = classic
self.role_factory = role_factory self.role_factory = role_factory
self.mode_factory = mode_factory self.mode_factory = mode_factory
self.authenticate = authenticate
self.encrypt = encrypt or authenticate
self.extended_data_length = extended_data_length
self.device = None self.device = None
self.connection = None self.connection = None
@@ -900,7 +972,26 @@ class Central(Connection.Listener):
self.connection.listener = self self.connection.listener = self
print_connection(self.connection) print_connection(self.connection)
await mode.on_connection(self.connection) # Request a new data length if requested
if self.extended_data_length:
print(color('+++ Requesting extended data length', 'cyan'))
await self.connection.set_data_length(
self.extended_data_length[0], self.extended_data_length[1]
)
# Authenticate if requested
if self.authenticate:
# Request authentication
print(color('*** Authenticating...', 'cyan'))
await self.connection.authenticate()
print(color('*** Authenticated', 'cyan'))
# Encrypt if requested
if self.encrypt:
# Enable encryption
print(color('*** Enabling encryption...', 'cyan'))
await self.connection.encrypt()
print(color('*** Encryption on', 'cyan'))
# Set the PHY if requested # Set the PHY if requested
if self.phy is not None: if self.phy is not None:
@@ -915,6 +1006,8 @@ class Central(Connection.Listener):
) )
) )
await mode.on_connection(self.connection)
await role.run() await role.run()
await asyncio.sleep(DEFAULT_LINGER_TIME) await asyncio.sleep(DEFAULT_LINGER_TIME)
@@ -939,9 +1032,12 @@ class Central(Connection.Listener):
# Peripheral # Peripheral
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
class Peripheral(Device.Listener, Connection.Listener): class Peripheral(Device.Listener, Connection.Listener):
def __init__(self, transport, classic, role_factory, mode_factory): def __init__(
self, transport, classic, extended_data_length, role_factory, mode_factory
):
self.transport = transport self.transport = transport
self.classic = classic self.classic = classic
self.extended_data_length = extended_data_length
self.role_factory = role_factory self.role_factory = role_factory
self.role = None self.role = None
self.mode_factory = mode_factory self.mode_factory = mode_factory
@@ -1002,6 +1098,15 @@ class Peripheral(Device.Listener, Connection.Listener):
self.connection = connection self.connection = connection
self.connected.set() self.connected.set()
# Request a new data length if needed
if self.extended_data_length:
print("+++ Requesting extended data length")
AsyncRunner.spawn(
connection.set_data_length(
self.extended_data_length[0], self.extended_data_length[1]
)
)
def on_disconnection(self, reason): def on_disconnection(self, reason):
print(color(f'!!! Disconnection: reason={reason}', 'red')) print(color(f'!!! Disconnection: reason={reason}', 'red'))
self.connection = None self.connection = None
@@ -1034,16 +1139,18 @@ def create_mode_factory(ctx, default_mode):
return GattServer(device) return GattServer(device)
if mode == 'l2cap-client': if mode == 'l2cap-client':
return L2capClient(device) return L2capClient(device, psm=ctx.obj['l2cap_psm'])
if mode == 'l2cap-server': if mode == 'l2cap-server':
return L2capServer(device) return L2capServer(device, psm=ctx.obj['l2cap_psm'])
if mode == 'rfcomm-client': if mode == 'rfcomm-client':
return RfcommClient(device) return RfcommClient(
device, channel=ctx.obj['rfcomm_channel'], uuid=ctx.obj['rfcomm_uuid']
)
if mode == 'rfcomm-server': if mode == 'rfcomm-server':
return RfcommServer(device) return RfcommServer(device, channel=ctx.obj['rfcomm_channel'])
raise ValueError('invalid mode') raise ValueError('invalid mode')
@@ -1109,6 +1216,27 @@ def create_role_factory(ctx, default_role):
type=click.IntRange(23, 517), type=click.IntRange(23, 517),
help='GATT MTU (gatt-client mode)', help='GATT MTU (gatt-client mode)',
) )
@click.option(
'--extended-data-length',
help='Request a data length upon connection, specified as tx_octets/tx_time',
)
@click.option(
'--rfcomm-channel',
type=int,
default=DEFAULT_RFCOMM_CHANNEL,
help='RFComm channel to use',
)
@click.option(
'--rfcomm-uuid',
default=DEFAULT_RFCOMM_UUID,
help='RFComm service UUID to use (ignored is --rfcomm-channel is not 0)',
)
@click.option(
'--l2cap-psm',
type=int,
default=DEFAULT_L2CAP_PSM,
help='L2CAP PSM to use',
)
@click.option( @click.option(
'--packet-size', '--packet-size',
'-s', '-s',
@@ -1135,17 +1263,36 @@ def create_role_factory(ctx, default_role):
) )
@click.pass_context @click.pass_context
def bench( def bench(
ctx, device_config, role, mode, att_mtu, packet_size, packet_count, start_delay ctx,
device_config,
role,
mode,
att_mtu,
extended_data_length,
packet_size,
packet_count,
start_delay,
rfcomm_channel,
rfcomm_uuid,
l2cap_psm,
): ):
ctx.ensure_object(dict) ctx.ensure_object(dict)
ctx.obj['device_config'] = device_config ctx.obj['device_config'] = device_config
ctx.obj['role'] = role ctx.obj['role'] = role
ctx.obj['mode'] = mode ctx.obj['mode'] = mode
ctx.obj['att_mtu'] = att_mtu ctx.obj['att_mtu'] = att_mtu
ctx.obj['rfcomm_channel'] = rfcomm_channel
ctx.obj['rfcomm_uuid'] = rfcomm_uuid
ctx.obj['l2cap_psm'] = l2cap_psm
ctx.obj['packet_size'] = packet_size ctx.obj['packet_size'] = packet_size
ctx.obj['packet_count'] = packet_count ctx.obj['packet_count'] = packet_count
ctx.obj['start_delay'] = start_delay ctx.obj['start_delay'] = start_delay
ctx.obj['extended_data_length'] = (
[int(x) for x in extended_data_length.split('/')]
if extended_data_length
else None
)
ctx.obj['classic'] = mode in ('rfcomm-client', 'rfcomm-server') ctx.obj['classic'] = mode in ('rfcomm-client', 'rfcomm-server')
@@ -1166,8 +1313,12 @@ def bench(
help='Connection interval (in ms)', help='Connection interval (in ms)',
) )
@click.option('--phy', type=click.Choice(['1m', '2m', 'coded']), help='PHY to use') @click.option('--phy', type=click.Choice(['1m', '2m', 'coded']), help='PHY to use')
@click.option('--authenticate', is_flag=True, help='Authenticate (RFComm only)')
@click.option('--encrypt', is_flag=True, help='Encrypt the connection (RFComm only)')
@click.pass_context @click.pass_context
def central(ctx, transport, peripheral_address, connection_interval, phy): def central(
ctx, transport, peripheral_address, connection_interval, phy, authenticate, encrypt
):
"""Run as a central (initiates the connection)""" """Run as a central (initiates the connection)"""
role_factory = create_role_factory(ctx, 'sender') role_factory = create_role_factory(ctx, 'sender')
mode_factory = create_mode_factory(ctx, 'gatt-client') mode_factory = create_mode_factory(ctx, 'gatt-client')
@@ -1182,6 +1333,9 @@ def central(ctx, transport, peripheral_address, connection_interval, phy):
mode_factory, mode_factory,
connection_interval, connection_interval,
phy, phy,
authenticate,
encrypt or authenticate,
ctx.obj['extended_data_length'],
).run() ).run()
) )
@@ -1195,7 +1349,13 @@ def peripheral(ctx, transport):
mode_factory = create_mode_factory(ctx, 'gatt-server') mode_factory = create_mode_factory(ctx, 'gatt-server')
asyncio.run( asyncio.run(
Peripheral(transport, ctx.obj['classic'], role_factory, mode_factory).run() Peripheral(
transport,
ctx.obj['classic'],
ctx.obj['extended_data_length'],
role_factory,
mode_factory,
).run()
) )

View File

@@ -1172,7 +1172,7 @@ class ScanResult:
name = '' name = ''
# Remove any '/P' qualifier suffix from the address string # Remove any '/P' qualifier suffix from the address string
address_str = str(self.address).replace('/P', '') address_str = self.address.to_string(with_type_qualifier=False)
# RSSI bar # RSSI bar
bar_string = rssi_bar(self.rssi) bar_string = rssi_bar(self.rssi)

View File

@@ -42,6 +42,8 @@ from bumble.hci import (
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_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,
) )
from bumble.host import Host from bumble.host import Host
from bumble.transport import open_transport_or_link from bumble.transport import open_transport_or_link
@@ -63,7 +65,8 @@ async def get_classic_info(host):
if command_succeeded(response): if command_succeeded(response):
print() print()
print( print(
color('Classic Address:', 'yellow'), response.return_parameters.bd_addr color('Classic Address:', 'yellow'),
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):
@@ -116,6 +119,18 @@ async def get_le_info(host):
'\n', '\n',
) )
if host.supports_command(HCI_LE_READ_SUGGESTED_DEFAULT_DATA_LENGTH_COMMAND):
response = await host.send_command(
HCI_LE_Read_Suggested_Default_Data_Length_Command()
)
if command_succeeded(response):
print(
color('Suggested Default Data Length:', 'yellow'),
f'{response.return_parameters.suggested_max_tx_octets}/'
f'{response.return_parameters.suggested_max_tx_time}',
'\n',
)
print(color('LE Features:', 'yellow')) print(color('LE Features:', 'yellow'))
for feature in host.supported_le_features: for feature in host.supported_le_features:
print(' ', name_or_number(HCI_LE_SUPPORTED_FEATURES_NAMES, feature)) print(' ', name_or_number(HCI_LE_SUPPORTED_FEATURES_NAMES, feature))

View File

@@ -21,6 +21,7 @@ import struct
import logging import logging
import click import click
from bumble import l2cap
from bumble.colors import color from bumble.colors import color
from bumble.device import Device, Peer from bumble.device import Device, Peer
from bumble.core import AdvertisingData from bumble.core import AdvertisingData
@@ -204,7 +205,7 @@ class GattlinkHubBridge(GattlinkL2capEndpoint, Device.Listener):
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
class GattlinkNodeBridge(GattlinkL2capEndpoint, Device.Listener): class GattlinkNodeBridge(GattlinkL2capEndpoint, Device.Listener):
def __init__(self, device): def __init__(self, device: Device):
super().__init__() super().__init__()
self.device = device self.device = device
self.peer = None self.peer = None
@@ -218,7 +219,12 @@ class GattlinkNodeBridge(GattlinkL2capEndpoint, Device.Listener):
# Listen for incoming L2CAP CoC connections # Listen for incoming L2CAP CoC connections
psm = 0xFB psm = 0xFB
device.register_l2cap_channel_server(0xFB, self.on_coc) device.create_l2cap_server(
spec=l2cap.LeCreditBasedChannelSpec(
psm=0xFB,
),
handler=self.on_coc,
)
print(f'### Listening for CoC connection on PSM {psm}') print(f'### Listening for CoC connection on PSM {psm}')
# Setup the Gattlink service # Setup the Gattlink service

View File

@@ -20,6 +20,7 @@ import logging
import os import os
import click import click
from bumble import l2cap
from bumble.colors import color from bumble.colors import color
from bumble.transport import open_transport_or_link from bumble.transport import open_transport_or_link
from bumble.device import Device from bumble.device import Device
@@ -47,14 +48,13 @@ class ServerBridge:
self.tcp_host = tcp_host self.tcp_host = tcp_host
self.tcp_port = tcp_port self.tcp_port = tcp_port
async def start(self, device): async def start(self, device: Device) -> None:
# Listen for incoming L2CAP CoC connections # Listen for incoming L2CAP CoC connections
device.register_l2cap_channel_server( device.create_l2cap_server(
psm=self.psm, spec=l2cap.LeCreditBasedChannelSpec(
server=self.on_coc, psm=self.psm, mtu=self.mtu, mps=self.mps, max_credits=self.max_credits
max_credits=self.max_credits, ),
mtu=self.mtu, handler=self.on_coc,
mps=self.mps,
) )
print(color(f'### Listening for CoC connection on PSM {self.psm}', 'yellow')) print(color(f'### Listening for CoC connection on PSM {self.psm}', 'yellow'))
@@ -195,11 +195,13 @@ class ClientBridge:
# Connect a new L2CAP channel # Connect a new L2CAP channel
print(color(f'>>> Opening L2CAP channel on PSM = {self.psm}', 'yellow')) print(color(f'>>> Opening L2CAP channel on PSM = {self.psm}', 'yellow'))
try: try:
l2cap_channel = await connection.open_l2cap_channel( l2cap_channel = await connection.create_l2cap_channel(
psm=self.psm, spec=l2cap.LeCreditBasedChannelSpec(
max_credits=self.max_credits, psm=self.psm,
mtu=self.mtu, max_credits=self.max_credits,
mps=self.mps, mtu=self.mtu,
mps=self.mps,
)
) )
print(color('*** L2CAP channel:', 'cyan'), l2cap_channel) print(color('*** L2CAP channel:', 'cyan'), l2cap_channel)
except Exception as error: except Exception as error:

View File

@@ -24,10 +24,16 @@ from prompt_toolkit.shortcuts import PromptSession
from bumble.colors import color from bumble.colors import color
from bumble.device import Device, Peer from bumble.device import Device, Peer
from bumble.transport import open_transport_or_link from bumble.transport import open_transport_or_link
from bumble.pairing import PairingDelegate, PairingConfig 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.smp import error_name as smp_error_name
from bumble.keys import JsonKeyStore from bumble.keys import JsonKeyStore
from bumble.core import ProtocolError from bumble.core import (
AdvertisingData,
ProtocolError,
BT_LE_TRANSPORT,
BT_BR_EDR_TRANSPORT,
)
from bumble.gatt import ( from bumble.gatt import (
GATT_DEVICE_NAME_CHARACTERISTIC, GATT_DEVICE_NAME_CHARACTERISTIC,
GATT_GENERIC_ACCESS_SERVICE, GATT_GENERIC_ACCESS_SERVICE,
@@ -60,7 +66,7 @@ class Waiter:
class Delegate(PairingDelegate): class Delegate(PairingDelegate):
def __init__(self, mode, connection, capability_string, do_prompt): def __init__(self, mode, connection, capability_string, do_prompt):
super().__init__( super().__init__(
{ io_capability={
'keyboard': PairingDelegate.KEYBOARD_INPUT_ONLY, 'keyboard': PairingDelegate.KEYBOARD_INPUT_ONLY,
'display': PairingDelegate.DISPLAY_OUTPUT_ONLY, 'display': PairingDelegate.DISPLAY_OUTPUT_ONLY,
'display+keyboard': PairingDelegate.DISPLAY_OUTPUT_AND_KEYBOARD_INPUT, 'display+keyboard': PairingDelegate.DISPLAY_OUTPUT_AND_KEYBOARD_INPUT,
@@ -285,7 +291,9 @@ async def pair(
mitm, mitm,
bond, bond,
ctkd, ctkd,
linger,
io, io,
oob,
prompt, prompt,
request, request,
print_keys, print_keys,
@@ -306,6 +314,7 @@ async def pair(
# Expose a GATT characteristic that can be used to trigger pairing by # Expose a GATT characteristic that can be used to trigger pairing by
# responding with an authentication error when read # responding with an authentication error when read
if mode == 'le': if mode == 'le':
device.le_enabled = True
device.add_service( device.add_service(
Service( Service(
'50DB505C-8AC4-4738-8448-3B1D9CC09CC5', '50DB505C-8AC4-4738-8448-3B1D9CC09CC5',
@@ -326,7 +335,6 @@ async def pair(
# Select LE or Classic # Select LE or Classic
if mode == 'classic': if mode == 'classic':
device.classic_enabled = True device.classic_enabled = True
device.le_enabled = False
device.classic_smp_enabled = ctkd device.classic_smp_enabled = ctkd
# Get things going # Get things going
@@ -343,16 +351,52 @@ async def pair(
await device.keystore.print(prefix=color('@@@ ', 'blue')) await device.keystore.print(prefix=color('@@@ ', 'blue'))
print(color('@@@-----------------------------------', 'blue')) print(color('@@@-----------------------------------', 'blue'))
# Create an OOB context if needed
if oob:
our_oob_context = OobContext()
shared_data = (
None
if oob == '-'
else OobData.from_ad(AdvertisingData.from_bytes(bytes.fromhex(oob)))
)
legacy_context = OobLegacyContext()
oob_contexts = PairingConfig.OobConfig(
our_context=our_oob_context,
peer_data=shared_data,
legacy_context=legacy_context,
)
oob_data = OobData(
address=device.random_address,
shared_data=shared_data,
legacy_context=legacy_context,
)
print(color('@@@-----------------------------------', 'yellow'))
print(color('@@@ OOB Data:', 'yellow'))
print(color(f'@@@ {our_oob_context.share()}', 'yellow'))
print(color(f'@@@ TK={legacy_context.tk.hex()}', 'yellow'))
print(color(f'@@@ HEX: ({bytes(oob_data.to_ad()).hex()})', 'yellow'))
print(color('@@@-----------------------------------', 'yellow'))
else:
oob_contexts = None
# Set up a pairing config factory # Set up a pairing config factory
device.pairing_config_factory = lambda connection: PairingConfig( device.pairing_config_factory = lambda connection: PairingConfig(
sc, mitm, bond, Delegate(mode, connection, io, prompt) sc=sc,
mitm=mitm,
bonding=bond,
oob=oob_contexts,
delegate=Delegate(mode, connection, io, prompt),
) )
# Connect to a peer or wait for a connection # Connect to a peer or wait for a connection
device.on('connection', lambda connection: on_connection(connection, request)) device.on('connection', lambda connection: on_connection(connection, request))
if address_or_name is not None: if address_or_name is not None:
print(color(f'=== Connecting to {address_or_name}...', 'green')) print(color(f'=== Connecting to {address_or_name}...', 'green'))
connection = await device.connect(address_or_name) connection = await device.connect(
address_or_name,
transport=BT_LE_TRANSPORT if mode == 'le' else BT_BR_EDR_TRANSPORT,
)
pairing_failure = False
if not request: if not request:
try: try:
@@ -360,10 +404,12 @@ async def pair(
await connection.pair() await connection.pair()
else: else:
await connection.authenticate() await connection.authenticate()
return
except ProtocolError as error: except ProtocolError as error:
pairing_failure = True
print(color(f'Pairing failed: {error}', 'red')) print(color(f'Pairing failed: {error}', 'red'))
return
if not linger or pairing_failure:
return
else: else:
if mode == 'le': if mode == 'le':
# Advertise so that peers can find us and connect # Advertise so that peers can find us and connect
@@ -413,6 +459,7 @@ class LogHandler(logging.Handler):
help='Enable CTKD', help='Enable CTKD',
show_default=True, show_default=True,
) )
@click.option('--linger', default=True, is_flag=True, help='Linger after pairing')
@click.option( @click.option(
'--io', '--io',
type=click.Choice( type=click.Choice(
@@ -421,6 +468,14 @@ class LogHandler(logging.Handler):
default='display+keyboard', default='display+keyboard',
show_default=True, show_default=True,
) )
@click.option(
'--oob',
metavar='<oob-data-hex>',
help=(
'Use OOB pairing with this data from the peer '
'(use "-" to enable OOB without peer data)'
),
)
@click.option('--prompt', is_flag=True, help='Prompt to accept/reject pairing request') @click.option('--prompt', is_flag=True, help='Prompt to accept/reject pairing request')
@click.option( @click.option(
'--request', is_flag=True, help='Request that the connecting peer initiate pairing' '--request', is_flag=True, help='Request that the connecting peer initiate pairing'
@@ -440,7 +495,9 @@ def main(
mitm, mitm,
bond, bond,
ctkd, ctkd,
linger,
io, io,
oob,
prompt, prompt,
request, request,
print_keys, print_keys,
@@ -463,7 +520,9 @@ def main(
mitm, mitm,
bond, bond,
ctkd, ctkd,
linger,
io, io,
oob,
prompt, prompt,
request, request,
print_keys, print_keys,

View File

@@ -3,7 +3,7 @@ import click
import logging import logging
import json import json
from bumble.pandora import PandoraDevice, serve from bumble.pandora import PandoraDevice, Config, serve
from typing import Dict, Any from typing import Dict, Any
BUMBLE_SERVER_GRPC_PORT = 7999 BUMBLE_SERVER_GRPC_PORT = 7999
@@ -29,12 +29,14 @@ def main(grpc_port: int, rootcanal_port: int, transport: str, config: str) -> No
transport = transport.replace('<rootcanal-port>', str(rootcanal_port)) transport = transport.replace('<rootcanal-port>', str(rootcanal_port))
bumble_config = retrieve_config(config) bumble_config = retrieve_config(config)
if 'transport' not in bumble_config.keys(): bumble_config.setdefault('transport', transport)
bumble_config.update({'transport': transport})
device = PandoraDevice(bumble_config) device = PandoraDevice(bumble_config)
server_config = Config()
server_config.load_from_dict(bumble_config.get('server', {}))
logging.basicConfig(level=logging.DEBUG) logging.basicConfig(level=logging.DEBUG)
asyncio.run(serve(device, port=grpc_port)) asyncio.run(serve(device, config=server_config, port=grpc_port))
def retrieve_config(config: str) -> Dict[str, Any]: def retrieve_config(config: str) -> Dict[str, Any]:

View File

@@ -195,7 +195,7 @@ class WebSocketOutput(QueuedOutput):
except HCI_StatusError: except HCI_StatusError:
pass pass
peer_name = '' if connection.peer_name is None else connection.peer_name peer_name = '' if connection.peer_name is None else connection.peer_name
peer_address = str(connection.peer_address).replace('/P', '') peer_address = connection.peer_address.to_string(False)
await self.send_message( await self.send_message(
'connection', 'connection',
peer_address=peer_address, peer_address=peer_address,
@@ -376,7 +376,7 @@ class UiServer:
if connection := self.speaker().connection: if connection := self.speaker().connection:
await self.send_message( await self.send_message(
'connection', 'connection',
peer_address=str(connection.peer_address).replace('/P', ''), peer_address=connection.peer_address.to_string(False),
peer_name=connection.peer_name, peer_name=connection.peer_name,
) )
@@ -641,7 +641,7 @@ class Speaker:
self.device.on('connection', self.on_bluetooth_connection) self.device.on('connection', self.on_bluetooth_connection)
# Create a listener to wait for AVDTP connections # Create a listener to wait for AVDTP connections
self.listener = Listener(Listener.create_registrar(self.device)) self.listener = Listener.for_device(self.device)
self.listener.on('connection', self.on_avdtp_connection) self.listener.on('connection', self.on_avdtp_connection)
print(f'Speaker ready to play, codec={color(self.codec, "cyan")}') print(f'Speaker ready to play, codec={color(self.codec, "cyan")}')

View File

@@ -15,9 +15,13 @@
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
# Imports # Imports
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
from __future__ import annotations
import dataclasses
import struct import struct
import logging import logging
from collections import namedtuple from collections.abc import AsyncGenerator
from typing import List, Callable, Awaitable
from .company_ids import COMPANY_IDENTIFIERS from .company_ids import COMPANY_IDENTIFIERS
from .sdp import ( from .sdp import (
@@ -239,24 +243,20 @@ def make_audio_sink_service_sdp_records(service_record_handle, version=(1, 3)):
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
class SbcMediaCodecInformation( @dataclasses.dataclass
namedtuple( class SbcMediaCodecInformation:
'SbcMediaCodecInformation',
[
'sampling_frequency',
'channel_mode',
'block_length',
'subbands',
'allocation_method',
'minimum_bitpool_value',
'maximum_bitpool_value',
],
)
):
''' '''
A2DP spec - 4.3.2 Codec Specific Information Elements A2DP spec - 4.3.2 Codec Specific Information Elements
''' '''
sampling_frequency: int
channel_mode: int
block_length: int
subbands: int
allocation_method: int
minimum_bitpool_value: int
maximum_bitpool_value: int
SAMPLING_FREQUENCY_BITS = {16000: 1 << 3, 32000: 1 << 2, 44100: 1 << 1, 48000: 1} SAMPLING_FREQUENCY_BITS = {16000: 1 << 3, 32000: 1 << 2, 44100: 1 << 1, 48000: 1}
CHANNEL_MODE_BITS = { CHANNEL_MODE_BITS = {
SBC_MONO_CHANNEL_MODE: 1 << 3, SBC_MONO_CHANNEL_MODE: 1 << 3,
@@ -272,7 +272,7 @@ class SbcMediaCodecInformation(
} }
@staticmethod @staticmethod
def from_bytes(data: bytes) -> 'SbcMediaCodecInformation': def from_bytes(data: bytes) -> SbcMediaCodecInformation:
sampling_frequency = (data[0] >> 4) & 0x0F sampling_frequency = (data[0] >> 4) & 0x0F
channel_mode = (data[0] >> 0) & 0x0F channel_mode = (data[0] >> 0) & 0x0F
block_length = (data[1] >> 4) & 0x0F block_length = (data[1] >> 4) & 0x0F
@@ -293,14 +293,14 @@ class SbcMediaCodecInformation(
@classmethod @classmethod
def from_discrete_values( def from_discrete_values(
cls, cls,
sampling_frequency, sampling_frequency: int,
channel_mode, channel_mode: int,
block_length, block_length: int,
subbands, subbands: int,
allocation_method, allocation_method: int,
minimum_bitpool_value, minimum_bitpool_value: int,
maximum_bitpool_value, maximum_bitpool_value: int,
): ) -> SbcMediaCodecInformation:
return SbcMediaCodecInformation( return SbcMediaCodecInformation(
sampling_frequency=cls.SAMPLING_FREQUENCY_BITS[sampling_frequency], sampling_frequency=cls.SAMPLING_FREQUENCY_BITS[sampling_frequency],
channel_mode=cls.CHANNEL_MODE_BITS[channel_mode], channel_mode=cls.CHANNEL_MODE_BITS[channel_mode],
@@ -314,14 +314,14 @@ class SbcMediaCodecInformation(
@classmethod @classmethod
def from_lists( def from_lists(
cls, cls,
sampling_frequencies, sampling_frequencies: List[int],
channel_modes, channel_modes: List[int],
block_lengths, block_lengths: List[int],
subbands, subbands: List[int],
allocation_methods, allocation_methods: List[int],
minimum_bitpool_value, minimum_bitpool_value: int,
maximum_bitpool_value, maximum_bitpool_value: int,
): ) -> SbcMediaCodecInformation:
return SbcMediaCodecInformation( return SbcMediaCodecInformation(
sampling_frequency=sum( sampling_frequency=sum(
cls.SAMPLING_FREQUENCY_BITS[x] for x in sampling_frequencies cls.SAMPLING_FREQUENCY_BITS[x] for x in sampling_frequencies
@@ -348,7 +348,7 @@ class SbcMediaCodecInformation(
] ]
) )
def __str__(self): def __str__(self) -> str:
channel_modes = ['MONO', 'DUAL_CHANNEL', 'STEREO', 'JOINT_STEREO'] channel_modes = ['MONO', 'DUAL_CHANNEL', 'STEREO', 'JOINT_STEREO']
allocation_methods = ['SNR', 'Loudness'] allocation_methods = ['SNR', 'Loudness']
return '\n'.join( return '\n'.join(
@@ -367,16 +367,19 @@ class SbcMediaCodecInformation(
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
class AacMediaCodecInformation( @dataclasses.dataclass
namedtuple( class AacMediaCodecInformation:
'AacMediaCodecInformation',
['object_type', 'sampling_frequency', 'channels', 'rfa', 'vbr', 'bitrate'],
)
):
''' '''
A2DP spec - 4.5.2 Codec Specific Information Elements A2DP spec - 4.5.2 Codec Specific Information Elements
''' '''
object_type: int
sampling_frequency: int
channels: int
rfa: int
vbr: int
bitrate: int
OBJECT_TYPE_BITS = { OBJECT_TYPE_BITS = {
MPEG_2_AAC_LC_OBJECT_TYPE: 1 << 7, MPEG_2_AAC_LC_OBJECT_TYPE: 1 << 7,
MPEG_4_AAC_LC_OBJECT_TYPE: 1 << 6, MPEG_4_AAC_LC_OBJECT_TYPE: 1 << 6,
@@ -400,7 +403,7 @@ class AacMediaCodecInformation(
CHANNELS_BITS = {1: 1 << 1, 2: 1} CHANNELS_BITS = {1: 1 << 1, 2: 1}
@staticmethod @staticmethod
def from_bytes(data: bytes) -> 'AacMediaCodecInformation': def from_bytes(data: bytes) -> AacMediaCodecInformation:
object_type = data[0] object_type = data[0]
sampling_frequency = (data[1] << 4) | ((data[2] >> 4) & 0x0F) sampling_frequency = (data[1] << 4) | ((data[2] >> 4) & 0x0F)
channels = (data[2] >> 2) & 0x03 channels = (data[2] >> 2) & 0x03
@@ -413,8 +416,13 @@ class AacMediaCodecInformation(
@classmethod @classmethod
def from_discrete_values( def from_discrete_values(
cls, object_type, sampling_frequency, channels, vbr, bitrate cls,
): object_type: int,
sampling_frequency: int,
channels: int,
vbr: int,
bitrate: int,
) -> AacMediaCodecInformation:
return AacMediaCodecInformation( return AacMediaCodecInformation(
object_type=cls.OBJECT_TYPE_BITS[object_type], object_type=cls.OBJECT_TYPE_BITS[object_type],
sampling_frequency=cls.SAMPLING_FREQUENCY_BITS[sampling_frequency], sampling_frequency=cls.SAMPLING_FREQUENCY_BITS[sampling_frequency],
@@ -425,7 +433,14 @@ class AacMediaCodecInformation(
) )
@classmethod @classmethod
def from_lists(cls, object_types, sampling_frequencies, channels, vbr, bitrate): def from_lists(
cls,
object_types: List[int],
sampling_frequencies: List[int],
channels: List[int],
vbr: int,
bitrate: int,
) -> AacMediaCodecInformation:
return AacMediaCodecInformation( return AacMediaCodecInformation(
object_type=sum(cls.OBJECT_TYPE_BITS[x] for x in object_types), object_type=sum(cls.OBJECT_TYPE_BITS[x] for x in object_types),
sampling_frequency=sum( sampling_frequency=sum(
@@ -449,7 +464,7 @@ class AacMediaCodecInformation(
] ]
) )
def __str__(self): def __str__(self) -> str:
object_types = [ object_types = [
'MPEG_2_AAC_LC', 'MPEG_2_AAC_LC',
'MPEG_4_AAC_LC', 'MPEG_4_AAC_LC',
@@ -474,26 +489,26 @@ class AacMediaCodecInformation(
) )
@dataclasses.dataclass
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
class VendorSpecificMediaCodecInformation: class VendorSpecificMediaCodecInformation:
''' '''
A2DP spec - 4.7.2 Codec Specific Information Elements A2DP spec - 4.7.2 Codec Specific Information Elements
''' '''
vendor_id: int
codec_id: int
value: bytes
@staticmethod @staticmethod
def from_bytes(data): def from_bytes(data: bytes) -> VendorSpecificMediaCodecInformation:
(vendor_id, codec_id) = struct.unpack_from('<IH', data, 0) (vendor_id, codec_id) = struct.unpack_from('<IH', data, 0)
return VendorSpecificMediaCodecInformation(vendor_id, codec_id, data[6:]) return VendorSpecificMediaCodecInformation(vendor_id, codec_id, data[6:])
def __init__(self, vendor_id, codec_id, value): def __bytes__(self) -> bytes:
self.vendor_id = vendor_id
self.codec_id = codec_id
self.value = value
def __bytes__(self):
return struct.pack('<IH', self.vendor_id, self.codec_id, self.value) return struct.pack('<IH', self.vendor_id, self.codec_id, self.value)
def __str__(self): def __str__(self) -> str:
# pylint: disable=line-too-long # pylint: disable=line-too-long
return '\n'.join( return '\n'.join(
[ [
@@ -506,29 +521,27 @@ class VendorSpecificMediaCodecInformation:
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
@dataclasses.dataclass
class SbcFrame: class SbcFrame:
def __init__( sampling_frequency: int
self, sampling_frequency, block_count, channel_mode, subband_count, payload block_count: int
): channel_mode: int
self.sampling_frequency = sampling_frequency subband_count: int
self.block_count = block_count payload: bytes
self.channel_mode = channel_mode
self.subband_count = subband_count
self.payload = payload
@property @property
def sample_count(self): def sample_count(self) -> int:
return self.subband_count * self.block_count return self.subband_count * self.block_count
@property @property
def bitrate(self): def bitrate(self) -> int:
return 8 * ((len(self.payload) * self.sampling_frequency) // self.sample_count) return 8 * ((len(self.payload) * self.sampling_frequency) // self.sample_count)
@property @property
def duration(self): def duration(self) -> float:
return self.sample_count / self.sampling_frequency return self.sample_count / self.sampling_frequency
def __str__(self): def __str__(self) -> str:
return ( return (
f'SBC(sf={self.sampling_frequency},' f'SBC(sf={self.sampling_frequency},'
f'cm={self.channel_mode},' f'cm={self.channel_mode},'
@@ -540,12 +553,12 @@ class SbcFrame:
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
class SbcParser: class SbcParser:
def __init__(self, read): def __init__(self, read: Callable[[int], Awaitable[bytes]]) -> None:
self.read = read self.read = read
@property @property
def frames(self): def frames(self) -> AsyncGenerator[SbcFrame, None]:
async def generate_frames(): async def generate_frames() -> AsyncGenerator[SbcFrame, None]:
while True: while True:
# Read 4 bytes of header # Read 4 bytes of header
header = await self.read(4) header = await self.read(4)
@@ -589,7 +602,9 @@ class SbcParser:
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
class SbcPacketSource: class SbcPacketSource:
def __init__(self, read, mtu, codec_capabilities): def __init__(
self, read: Callable[[int], Awaitable[bytes]], mtu: int, codec_capabilities
) -> None:
self.read = read self.read = read
self.mtu = mtu self.mtu = mtu
self.codec_capabilities = codec_capabilities self.codec_capabilities = codec_capabilities

View File

@@ -23,13 +23,14 @@
# Imports # Imports
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
from __future__ import annotations from __future__ import annotations
import enum
import functools import functools
import struct import struct
from pyee import EventEmitter from pyee import EventEmitter
from typing import Dict, Type, TYPE_CHECKING from typing import Dict, Type, List, Protocol, Union, Optional, Any, TYPE_CHECKING
from bumble.core import UUID, name_or_number, get_dict_key_by_value, ProtocolError from bumble.core import UUID, name_or_number, ProtocolError
from bumble.hci import HCI_Object, key_with_value, HCI_Constant from bumble.hci import HCI_Object, key_with_value
from bumble.colors import color from bumble.colors import color
if TYPE_CHECKING: if TYPE_CHECKING:
@@ -182,6 +183,7 @@ UUID_2_FIELD_SPEC = lambda x, y: UUID.parse_uuid_2(x, y) # noqa: E731
# pylint: enable=line-too-long # pylint: enable=line-too-long
# pylint: disable=invalid-name # pylint: disable=invalid-name
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
# Exceptions # Exceptions
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
@@ -209,7 +211,7 @@ class ATT_PDU:
pdu_classes: Dict[int, Type[ATT_PDU]] = {} pdu_classes: Dict[int, Type[ATT_PDU]] = {}
op_code = 0 op_code = 0
name = None name: str
@staticmethod @staticmethod
def from_bytes(pdu): def from_bytes(pdu):
@@ -719,48 +721,68 @@ class ATT_Handle_Value_Confirmation(ATT_PDU):
''' '''
# -----------------------------------------------------------------------------
class ConnectionValue(Protocol):
def read(self, connection) -> bytes:
...
def write(self, connection, value: bytes) -> None:
...
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
class Attribute(EventEmitter): class Attribute(EventEmitter):
# Permission flags class Permissions(enum.IntFlag):
READABLE = 0x01 READABLE = 0x01
WRITEABLE = 0x02 WRITEABLE = 0x02
READ_REQUIRES_ENCRYPTION = 0x04 READ_REQUIRES_ENCRYPTION = 0x04
WRITE_REQUIRES_ENCRYPTION = 0x08 WRITE_REQUIRES_ENCRYPTION = 0x08
READ_REQUIRES_AUTHENTICATION = 0x10 READ_REQUIRES_AUTHENTICATION = 0x10
WRITE_REQUIRES_AUTHENTICATION = 0x20 WRITE_REQUIRES_AUTHENTICATION = 0x20
READ_REQUIRES_AUTHORIZATION = 0x40 READ_REQUIRES_AUTHORIZATION = 0x40
WRITE_REQUIRES_AUTHORIZATION = 0x80 WRITE_REQUIRES_AUTHORIZATION = 0x80
PERMISSION_NAMES = { @classmethod
READABLE: 'READABLE', def from_string(cls, permissions_str: str) -> Attribute.Permissions:
WRITEABLE: 'WRITEABLE', try:
READ_REQUIRES_ENCRYPTION: 'READ_REQUIRES_ENCRYPTION', return functools.reduce(
WRITE_REQUIRES_ENCRYPTION: 'WRITE_REQUIRES_ENCRYPTION', lambda x, y: x | Attribute.Permissions[y],
READ_REQUIRES_AUTHENTICATION: 'READ_REQUIRES_AUTHENTICATION', permissions_str.replace('|', ',').split(","),
WRITE_REQUIRES_AUTHENTICATION: 'WRITE_REQUIRES_AUTHENTICATION', Attribute.Permissions(0),
READ_REQUIRES_AUTHORIZATION: 'READ_REQUIRES_AUTHORIZATION', )
WRITE_REQUIRES_AUTHORIZATION: 'WRITE_REQUIRES_AUTHORIZATION', except TypeError as exc:
} # The check for `p.name is not None` here is needed because for InFlag
# enums, the .name property can be None, when the enum value is 0,
# so the type hint for .name is Optional[str].
enum_list: List[str] = [p.name for p in cls if p.name is not None]
enum_list_str = ",".join(enum_list)
raise TypeError(
f"Attribute::permissions error:\nExpected a string containing any of the keys, separated by commas: {enum_list_str }\nGot: {permissions_str}"
) from exc
@staticmethod # Permission flags(legacy-use only)
def string_to_permissions(permissions_str: str): READABLE = Permissions.READABLE
try: WRITEABLE = Permissions.WRITEABLE
return functools.reduce( READ_REQUIRES_ENCRYPTION = Permissions.READ_REQUIRES_ENCRYPTION
lambda x, y: x | get_dict_key_by_value(Attribute.PERMISSION_NAMES, y), WRITE_REQUIRES_ENCRYPTION = Permissions.WRITE_REQUIRES_ENCRYPTION
permissions_str.split(","), READ_REQUIRES_AUTHENTICATION = Permissions.READ_REQUIRES_AUTHENTICATION
0, WRITE_REQUIRES_AUTHENTICATION = Permissions.WRITE_REQUIRES_AUTHENTICATION
) READ_REQUIRES_AUTHORIZATION = Permissions.READ_REQUIRES_AUTHORIZATION
except TypeError as exc: WRITE_REQUIRES_AUTHORIZATION = Permissions.WRITE_REQUIRES_AUTHORIZATION
raise TypeError(
f"Attribute::permissions error:\nExpected a string containing any of the keys, separated by commas: {','.join(Attribute.PERMISSION_NAMES.values())}\nGot: {permissions_str}"
) from exc
def __init__(self, attribute_type, permissions, value=b''): value: Union[str, bytes, ConnectionValue]
def __init__(
self,
attribute_type: Union[str, bytes, UUID],
permissions: Union[str, Attribute.Permissions],
value: Union[str, bytes, ConnectionValue] = b'',
) -> None:
EventEmitter.__init__(self) EventEmitter.__init__(self)
self.handle = 0 self.handle = 0
self.end_group_handle = 0 self.end_group_handle = 0
if isinstance(permissions, str): if isinstance(permissions, str):
self.permissions = self.string_to_permissions(permissions) self.permissions = Attribute.Permissions.from_string(permissions)
else: else:
self.permissions = permissions self.permissions = permissions
@@ -778,22 +800,26 @@ class Attribute(EventEmitter):
else: else:
self.value = value self.value = value
def encode_value(self, value): def encode_value(self, value: Any) -> bytes:
return value return value
def decode_value(self, value_bytes): def decode_value(self, value_bytes: bytes) -> Any:
return value_bytes return value_bytes
def read_value(self, connection: Connection): def read_value(self, connection: Optional[Connection]) -> bytes:
if ( if (
self.permissions & self.READ_REQUIRES_ENCRYPTION (self.permissions & self.READ_REQUIRES_ENCRYPTION)
) and not connection.encryption: and connection is not None
and not connection.encryption
):
raise ATT_Error( raise ATT_Error(
error_code=ATT_INSUFFICIENT_ENCRYPTION_ERROR, att_handle=self.handle error_code=ATT_INSUFFICIENT_ENCRYPTION_ERROR, att_handle=self.handle
) )
if ( if (
self.permissions & self.READ_REQUIRES_AUTHENTICATION (self.permissions & self.READ_REQUIRES_AUTHENTICATION)
) and not connection.authenticated: and connection is not None
and not connection.authenticated
):
raise ATT_Error( raise ATT_Error(
error_code=ATT_INSUFFICIENT_AUTHENTICATION_ERROR, att_handle=self.handle error_code=ATT_INSUFFICIENT_AUTHENTICATION_ERROR, att_handle=self.handle
) )
@@ -803,9 +829,9 @@ class Attribute(EventEmitter):
error_code=ATT_INSUFFICIENT_AUTHORIZATION_ERROR, att_handle=self.handle error_code=ATT_INSUFFICIENT_AUTHORIZATION_ERROR, att_handle=self.handle
) )
if read := getattr(self.value, 'read', None): if hasattr(self.value, 'read'):
try: try:
value = read(connection) # pylint: disable=not-callable value = self.value.read(connection)
except ATT_Error as error: except ATT_Error as error:
raise ATT_Error( raise ATT_Error(
error_code=error.error_code, att_handle=self.handle error_code=error.error_code, att_handle=self.handle
@@ -815,7 +841,7 @@ class Attribute(EventEmitter):
return self.encode_value(value) return self.encode_value(value)
def write_value(self, connection: Connection, value_bytes): def write_value(self, connection: Connection, value_bytes: bytes) -> None:
if ( if (
self.permissions & self.WRITE_REQUIRES_ENCRYPTION self.permissions & self.WRITE_REQUIRES_ENCRYPTION
) and not connection.encryption: ) and not connection.encryption:
@@ -836,9 +862,9 @@ class Attribute(EventEmitter):
value = self.decode_value(value_bytes) value = self.decode_value(value_bytes)
if write := getattr(self.value, 'write', None): if hasattr(self.value, 'write'):
try: try:
write(connection, value) # pylint: disable=not-callable self.value.write(connection, value) # pylint: disable=not-callable
except ATT_Error as error: except ATT_Error as error:
raise ATT_Error( raise ATT_Error(
error_code=error.error_code, att_handle=self.handle error_code=error.error_code, att_handle=self.handle

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -1000,6 +1000,9 @@ class Controller:
''' '''
See Bluetooth spec Vol 4, Part E - 7.8.10 LE Set Scan Parameters Command See Bluetooth spec Vol 4, Part E - 7.8.10 LE Set Scan Parameters Command
''' '''
if self.le_scan_enable:
return bytes([HCI_COMMAND_DISALLOWED_ERROR])
self.le_scan_type = command.le_scan_type self.le_scan_type = command.le_scan_type
self.le_scan_interval = command.le_scan_interval self.le_scan_interval = command.le_scan_interval
self.le_scan_window = command.le_scan_window self.le_scan_window = command.le_scan_window

View File

@@ -16,6 +16,7 @@
# Imports # Imports
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
from __future__ import annotations from __future__ import annotations
import enum
import struct import struct
from typing import List, Optional, Tuple, Union, cast, Dict from typing import List, Optional, Tuple, Union, cast, Dict
@@ -80,7 +81,7 @@ class BaseError(Exception):
def __init__( def __init__(
self, self,
error_code: int | None, error_code: Optional[int],
error_namespace: str = '', error_namespace: str = '',
error_name: str = '', error_name: str = '',
details: str = '', details: str = '',
@@ -1051,3 +1052,13 @@ class ConnectionPHY:
def __str__(self): def __str__(self):
return f'ConnectionPHY(tx_phy={self.tx_phy}, rx_phy={self.rx_phy})' return f'ConnectionPHY(tx_phy={self.tx_phy}, rx_phy={self.rx_phy})'
# -----------------------------------------------------------------------------
# LE Role
# -----------------------------------------------------------------------------
class LeRole(enum.IntEnum):
PERIPHERAL_ONLY = 0x00
CENTRAL_ONLY = 0x01
BOTH_PERIPHERAL_PREFERRED = 0x02
BOTH_CENTRAL_PREFERRED = 0x03

View File

@@ -21,6 +21,8 @@
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
# Imports # Imports
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
from __future__ import annotations
import logging import logging
import operator import operator
@@ -29,11 +31,13 @@ from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
from cryptography.hazmat.primitives.asymmetric.ec import ( from cryptography.hazmat.primitives.asymmetric.ec import (
generate_private_key, generate_private_key,
ECDH, ECDH,
EllipticCurvePrivateKey,
EllipticCurvePublicNumbers, EllipticCurvePublicNumbers,
EllipticCurvePrivateNumbers, EllipticCurvePrivateNumbers,
SECP256R1, SECP256R1,
) )
from cryptography.hazmat.primitives import cmac from cryptography.hazmat.primitives import cmac
from typing import Tuple
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
@@ -46,16 +50,18 @@ logger = logging.getLogger(__name__)
# Classes # Classes
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
class EccKey: class EccKey:
def __init__(self, private_key): def __init__(self, private_key: EllipticCurvePrivateKey) -> None:
self.private_key = private_key self.private_key = private_key
@classmethod @classmethod
def generate(cls): def generate(cls) -> EccKey:
private_key = generate_private_key(SECP256R1()) private_key = generate_private_key(SECP256R1())
return cls(private_key) return cls(private_key)
@classmethod @classmethod
def from_private_key_bytes(cls, d_bytes, x_bytes, y_bytes): def from_private_key_bytes(
cls, d_bytes: bytes, x_bytes: bytes, y_bytes: bytes
) -> EccKey:
d = int.from_bytes(d_bytes, byteorder='big', signed=False) d = int.from_bytes(d_bytes, byteorder='big', signed=False)
x = int.from_bytes(x_bytes, byteorder='big', signed=False) x = int.from_bytes(x_bytes, byteorder='big', signed=False)
y = int.from_bytes(y_bytes, byteorder='big', signed=False) y = int.from_bytes(y_bytes, byteorder='big', signed=False)
@@ -65,7 +71,7 @@ class EccKey:
return cls(private_key) return cls(private_key)
@property @property
def x(self): def x(self) -> bytes:
return ( return (
self.private_key.public_key() self.private_key.public_key()
.public_numbers() .public_numbers()
@@ -73,14 +79,14 @@ class EccKey:
) )
@property @property
def y(self): def y(self) -> bytes:
return ( return (
self.private_key.public_key() self.private_key.public_key()
.public_numbers() .public_numbers()
.y.to_bytes(32, byteorder='big') .y.to_bytes(32, byteorder='big')
) )
def dh(self, public_key_x, public_key_y): def dh(self, public_key_x: bytes, public_key_y: bytes) -> bytes:
x = int.from_bytes(public_key_x, byteorder='big', signed=False) x = int.from_bytes(public_key_x, byteorder='big', signed=False)
y = int.from_bytes(public_key_y, byteorder='big', signed=False) y = int.from_bytes(public_key_y, byteorder='big', signed=False)
public_key = EllipticCurvePublicNumbers(x, y, SECP256R1()).public_key() public_key = EllipticCurvePublicNumbers(x, y, SECP256R1()).public_key()
@@ -93,14 +99,23 @@ class EccKey:
# Functions # Functions
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
def xor(x, y): def xor(x: bytes, y: bytes) -> bytes:
assert len(x) == len(y) assert len(x) == len(y)
return bytes(map(operator.xor, x, y)) return bytes(map(operator.xor, x, y))
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
def r(): def reverse(input: bytes) -> bytes:
'''
Returns bytes of input in reversed endianness.
'''
return input[::-1]
# -----------------------------------------------------------------------------
def r() -> bytes:
''' '''
Generate 16 bytes of random data Generate 16 bytes of random data
''' '''
@@ -108,20 +123,20 @@ def r():
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
def e(key, data): def e(key: bytes, data: bytes) -> bytes:
''' '''
AES-128 ECB, expecting byte-swapped inputs and producing a byte-swapped output. AES-128 ECB, expecting byte-swapped inputs and producing a byte-swapped output.
See Bluetooth spec Vol 3, Part H - 2.2.1 Security function e See Bluetooth spec Vol 3, Part H - 2.2.1 Security function e
''' '''
cipher = Cipher(algorithms.AES(bytes(reversed(key))), modes.ECB()) cipher = Cipher(algorithms.AES(reverse(key)), modes.ECB())
encryptor = cipher.encryptor() encryptor = cipher.encryptor()
return bytes(reversed(encryptor.update(bytes(reversed(data))))) return reverse(encryptor.update(reverse(data)))
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
def ah(k, r): # pylint: disable=redefined-outer-name def ah(k: bytes, r: bytes) -> bytes: # pylint: disable=redefined-outer-name
''' '''
See Bluetooth spec Vol 3, Part H - 2.2.2 Random Address Hash function ah See Bluetooth spec Vol 3, Part H - 2.2.2 Random Address Hash function ah
''' '''
@@ -132,7 +147,16 @@ def ah(k, r): # pylint: disable=redefined-outer-name
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
def c1(k, r, preq, pres, iat, rat, ia, ra): # pylint: disable=redefined-outer-name def c1(
k: bytes,
r: bytes,
preq: bytes,
pres: bytes,
iat: int,
rat: int,
ia: bytes,
ra: bytes,
) -> bytes: # pylint: disable=redefined-outer-name
''' '''
See Bluetooth spec, Vol 3, Part H - 2.2.3 Confirm value generation function c1 for See Bluetooth spec, Vol 3, Part H - 2.2.3 Confirm value generation function c1 for
LE Legacy Pairing LE Legacy Pairing
@@ -144,7 +168,7 @@ def c1(k, r, preq, pres, iat, rat, ia, ra): # pylint: disable=redefined-outer-n
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
def s1(k, r1, r2): def s1(k: bytes, r1: bytes, r2: bytes) -> bytes:
''' '''
See Bluetooth spec, Vol 3, Part H - 2.2.4 Key generation function s1 for LE Legacy See Bluetooth spec, Vol 3, Part H - 2.2.4 Key generation function s1 for LE Legacy
Pairing Pairing
@@ -154,7 +178,7 @@ def s1(k, r1, r2):
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
def aes_cmac(m, k): def aes_cmac(m: bytes, k: bytes) -> bytes:
''' '''
See Bluetooth spec, Vol 3, Part H - 2.2.5 FunctionAES-CMAC See Bluetooth spec, Vol 3, Part H - 2.2.5 FunctionAES-CMAC
@@ -166,20 +190,16 @@ def aes_cmac(m, k):
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
def f4(u, v, x, z): def f4(u: bytes, v: bytes, x: bytes, z: bytes) -> bytes:
''' '''
See Bluetooth spec, Vol 3, Part H - 2.2.6 LE Secure Connections Confirm Value See Bluetooth spec, Vol 3, Part H - 2.2.6 LE Secure Connections Confirm Value
Generation Function f4 Generation Function f4
''' '''
return bytes( return reverse(aes_cmac(reverse(u) + reverse(v) + z, reverse(x)))
reversed(
aes_cmac(bytes(reversed(u)) + bytes(reversed(v)) + z, bytes(reversed(x)))
)
)
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
def f5(w, n1, n2, a1, a2): def f5(w: bytes, n1: bytes, n2: bytes, a1: bytes, a2: bytes) -> Tuple[bytes, bytes]:
''' '''
See Bluetooth spec, Vol 3, Part H - 2.2.7 LE Secure Connections Key Generation See Bluetooth spec, Vol 3, Part H - 2.2.7 LE Secure Connections Key Generation
Function f5 Function f5
@@ -187,87 +207,83 @@ def f5(w, n1, n2, a1, a2):
NOTE: this returns a tuple: (MacKey, LTK) in little-endian byte order NOTE: this returns a tuple: (MacKey, LTK) in little-endian byte order
''' '''
salt = bytes.fromhex('6C888391AAF5A53860370BDB5A6083BE') salt = bytes.fromhex('6C888391AAF5A53860370BDB5A6083BE')
t = aes_cmac(bytes(reversed(w)), salt) t = aes_cmac(reverse(w), salt)
key_id = bytes([0x62, 0x74, 0x6C, 0x65]) key_id = bytes([0x62, 0x74, 0x6C, 0x65])
return ( return (
bytes( reverse(
reversed( aes_cmac(
aes_cmac( bytes([0])
bytes([0]) + key_id
+ key_id + reverse(n1)
+ bytes(reversed(n1)) + reverse(n2)
+ bytes(reversed(n2)) + reverse(a1)
+ bytes(reversed(a1)) + reverse(a2)
+ bytes(reversed(a2)) + bytes([1, 0]),
+ bytes([1, 0]), t,
t,
)
) )
), ),
bytes( reverse(
reversed( aes_cmac(
aes_cmac( bytes([1])
bytes([1]) + key_id
+ key_id + reverse(n1)
+ bytes(reversed(n1)) + reverse(n2)
+ bytes(reversed(n2)) + reverse(a1)
+ bytes(reversed(a1)) + reverse(a2)
+ bytes(reversed(a2)) + bytes([1, 0]),
+ bytes([1, 0]), t,
t,
)
) )
), ),
) )
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
def f6(w, n1, n2, r, io_cap, a1, a2): # pylint: disable=redefined-outer-name def f6(
w: bytes, n1: bytes, n2: bytes, r: bytes, io_cap: bytes, a1: bytes, a2: bytes
) -> bytes: # pylint: disable=redefined-outer-name
''' '''
See Bluetooth spec, Vol 3, Part H - 2.2.8 LE Secure Connections Check Value See Bluetooth spec, Vol 3, Part H - 2.2.8 LE Secure Connections Check Value
Generation Function f6 Generation Function f6
''' '''
return bytes( return reverse(
reversed( aes_cmac(
aes_cmac( reverse(n1)
bytes(reversed(n1)) + reverse(n2)
+ bytes(reversed(n2)) + reverse(r)
+ bytes(reversed(r)) + reverse(io_cap)
+ bytes(reversed(io_cap)) + reverse(a1)
+ bytes(reversed(a1)) + reverse(a2),
+ bytes(reversed(a2)), reverse(w),
bytes(reversed(w)),
)
) )
) )
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
def g2(u, v, x, y): def g2(u: bytes, v: bytes, x: bytes, y: bytes) -> int:
''' '''
See Bluetooth spec, Vol 3, Part H - 2.2.9 LE Secure Connections Numeric Comparison See Bluetooth spec, Vol 3, Part H - 2.2.9 LE Secure Connections Numeric Comparison
Value Generation Function g2 Value Generation Function g2
''' '''
return int.from_bytes( return int.from_bytes(
aes_cmac( aes_cmac(
bytes(reversed(u)) + bytes(reversed(v)) + bytes(reversed(y)), reverse(u) + reverse(v) + reverse(y),
bytes(reversed(x)), reverse(x),
)[-4:], )[-4:],
byteorder='big', byteorder='big',
) )
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
def h6(w, key_id): def h6(w: bytes, key_id: bytes) -> bytes:
''' '''
See Bluetooth spec, Vol 3, Part H - 2.2.10 Link key conversion function h6 See Bluetooth spec, Vol 3, Part H - 2.2.10 Link key conversion function h6
''' '''
return aes_cmac(key_id, w) return reverse(aes_cmac(key_id, reverse(w)))
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
def h7(salt, w): def h7(salt: bytes, w: bytes) -> bytes:
''' '''
See Bluetooth spec, Vol 3, Part H - 2.2.11 Link key conversion function h7 See Bluetooth spec, Vol 3, Part H - 2.2.11 Link key conversion function h7
''' '''
return aes_cmac(w, salt) return reverse(aes_cmac(reverse(w), salt))

File diff suppressed because it is too large Load Diff

View File

@@ -28,7 +28,7 @@ import enum
import functools import functools
import logging import logging
import struct import struct
from typing import Optional, Sequence, List from typing import Optional, Sequence, Iterable, List, Union
from .colors import color from .colors import color
from .core import UUID, get_dict_key_by_value from .core import UUID, get_dict_key_by_value
@@ -93,20 +93,35 @@ GATT_RECONNECTION_CONFIGURATION_SERVICE = UUID.from_16_bits(0x1829, 'Reconne
GATT_INSULIN_DELIVERY_SERVICE = UUID.from_16_bits(0x183A, 'Insulin Delivery') GATT_INSULIN_DELIVERY_SERVICE = UUID.from_16_bits(0x183A, 'Insulin Delivery')
GATT_BINARY_SENSOR_SERVICE = UUID.from_16_bits(0x183B, 'Binary Sensor') GATT_BINARY_SENSOR_SERVICE = UUID.from_16_bits(0x183B, 'Binary Sensor')
GATT_EMERGENCY_CONFIGURATION_SERVICE = UUID.from_16_bits(0x183C, 'Emergency Configuration') GATT_EMERGENCY_CONFIGURATION_SERVICE = UUID.from_16_bits(0x183C, 'Emergency Configuration')
GATT_AUTHORIZATION_CONTROL_SERVICE = UUID.from_16_bits(0x183D, 'Authorization Control')
GATT_PHYSICAL_ACTIVITY_MONITOR_SERVICE = UUID.from_16_bits(0x183E, 'Physical Activity Monitor') GATT_PHYSICAL_ACTIVITY_MONITOR_SERVICE = UUID.from_16_bits(0x183E, 'Physical Activity Monitor')
GATT_ELAPSED_TIME_SERVICE = UUID.from_16_bits(0x183F, 'Elapsed Time')
GATT_GENERIC_HEALTH_SENSOR_SERVICE = UUID.from_16_bits(0x1840, 'Generic Health Sensor')
GATT_AUDIO_INPUT_CONTROL_SERVICE = UUID.from_16_bits(0x1843, 'Audio Input Control') GATT_AUDIO_INPUT_CONTROL_SERVICE = UUID.from_16_bits(0x1843, 'Audio Input Control')
GATT_VOLUME_CONTROL_SERVICE = UUID.from_16_bits(0x1844, 'Volume Control') GATT_VOLUME_CONTROL_SERVICE = UUID.from_16_bits(0x1844, 'Volume Control')
GATT_VOLUME_OFFSET_CONTROL_SERVICE = UUID.from_16_bits(0x1845, 'Volume Offset Control') GATT_VOLUME_OFFSET_CONTROL_SERVICE = UUID.from_16_bits(0x1845, 'Volume Offset Control')
GATT_COORDINATED_SET_IDENTIFICATION_SERVICE = UUID.from_16_bits(0x1846, 'Coordinated Set Identification Service') GATT_COORDINATED_SET_IDENTIFICATION_SERVICE = UUID.from_16_bits(0x1846, 'Coordinated Set Identification')
GATT_DEVICE_TIME_SERVICE = UUID.from_16_bits(0x1847, 'Device Time') GATT_DEVICE_TIME_SERVICE = UUID.from_16_bits(0x1847, 'Device Time')
GATT_MEDIA_CONTROL_SERVICE = UUID.from_16_bits(0x1848, 'Media Control Service') GATT_MEDIA_CONTROL_SERVICE = UUID.from_16_bits(0x1848, 'Media Control')
GATT_GENERIC_MEDIA_CONTROL_SERVICE = UUID.from_16_bits(0x1849, 'Generic Media Control Service') GATT_GENERIC_MEDIA_CONTROL_SERVICE = UUID.from_16_bits(0x1849, 'Generic Media Control')
GATT_CONSTANT_TONE_EXTENSION_SERVICE = UUID.from_16_bits(0x184A, 'Constant Tone Extension') GATT_CONSTANT_TONE_EXTENSION_SERVICE = UUID.from_16_bits(0x184A, 'Constant Tone Extension')
GATT_TELEPHONE_BEARER_SERVICE = UUID.from_16_bits(0x184B, 'Telephone Bearer Service') GATT_TELEPHONE_BEARER_SERVICE = UUID.from_16_bits(0x184B, 'Telephone Bearer')
GATT_GENERIC_TELEPHONE_BEARER_SERVICE = UUID.from_16_bits(0x184C, 'Generic Telephone Bearer Service') GATT_GENERIC_TELEPHONE_BEARER_SERVICE = UUID.from_16_bits(0x184C, 'Generic Telephone Bearer')
GATT_MICROPHONE_CONTROL_SERVICE = UUID.from_16_bits(0x184D, 'Microphone Control') GATT_MICROPHONE_CONTROL_SERVICE = UUID.from_16_bits(0x184D, 'Microphone Control')
GATT_AUDIO_STREAM_CONTROL_SERVICE = UUID.from_16_bits(0x184E, 'Audio Stream Control')
GATT_BROADCAST_AUDIO_SCAN_SERVICE = UUID.from_16_bits(0x184F, 'Broadcast Audio Scan')
GATT_PUBLISHED_AUDIO_CAPABILITIES_SERVICE = UUID.from_16_bits(0x1850, 'Published Audio Capabilities')
GATT_BASIC_AUDIO_ANNOUNCEMENT_SERVICE = UUID.from_16_bits(0x1851, 'Basic Audio Announcement')
GATT_BROADCAST_AUDIO_ANNOUNCEMENT_SERVICE = UUID.from_16_bits(0x1852, 'Broadcast Audio Announcement')
GATT_COMMON_AUDIO_SERVICE = UUID.from_16_bits(0x1853, 'Common Audio')
GATT_HEARING_ACCESS_SERVICE = UUID.from_16_bits(0x1854, 'Hearing Access')
GATT_TELEPHONY_AND_MEDIA_AUDIO_SERVICE = UUID.from_16_bits(0x1855, 'Telephony and Media Audio')
GATT_PUBLIC_BROADCAST_ANNOUNCEMENT_SERVICE = UUID.from_16_bits(0x1856, 'Public Broadcast Announcement')
GATT_ELECTRONIC_SHELF_LABEL_SERVICE = UUID.from_16_bits(0X1857, 'Electronic Shelf Label')
GATT_GAMING_AUDIO_SERVICE = UUID.from_16_bits(0x1858, 'Gaming Audio')
GATT_MESH_PROXY_SOLICITATION_SERVICE = UUID.from_16_bits(0x1859, 'Mesh Audio Solicitation')
# Types # Attribute Types
GATT_PRIMARY_SERVICE_ATTRIBUTE_TYPE = UUID.from_16_bits(0x2800, 'Primary Service') GATT_PRIMARY_SERVICE_ATTRIBUTE_TYPE = UUID.from_16_bits(0x2800, 'Primary Service')
GATT_SECONDARY_SERVICE_ATTRIBUTE_TYPE = UUID.from_16_bits(0x2801, 'Secondary Service') GATT_SECONDARY_SERVICE_ATTRIBUTE_TYPE = UUID.from_16_bits(0x2801, 'Secondary Service')
GATT_INCLUDE_ATTRIBUTE_TYPE = UUID.from_16_bits(0x2802, 'Include') GATT_INCLUDE_ATTRIBUTE_TYPE = UUID.from_16_bits(0x2802, 'Include')
@@ -129,6 +144,8 @@ GATT_ENVIRONMENTAL_SENSING_MEASUREMENT_DESCRIPTOR = UUID.from_16_bits(0x290C,
GATT_ENVIRONMENTAL_SENSING_TRIGGER_DESCRIPTOR = UUID.from_16_bits(0x290D, 'Environmental Sensing Trigger Setting') GATT_ENVIRONMENTAL_SENSING_TRIGGER_DESCRIPTOR = UUID.from_16_bits(0x290D, 'Environmental Sensing Trigger Setting')
GATT_TIME_TRIGGER_DESCRIPTOR = UUID.from_16_bits(0x290E, 'Time Trigger Setting') GATT_TIME_TRIGGER_DESCRIPTOR = UUID.from_16_bits(0x290E, 'Time Trigger Setting')
GATT_COMPLETE_BR_EDR_TRANSPORT_BLOCK_DATA_DESCRIPTOR = UUID.from_16_bits(0x290F, 'Complete BR-EDR Transport Block Data') GATT_COMPLETE_BR_EDR_TRANSPORT_BLOCK_DATA_DESCRIPTOR = UUID.from_16_bits(0x290F, 'Complete BR-EDR Transport Block Data')
GATT_OBSERVATION_SCHEDULE_DESCRIPTOR = UUID.from_16_bits(0x290F, 'Observation Schedule')
GATT_VALID_RANGE_AND_ACCURACY_DESCRIPTOR = UUID.from_16_bits(0x290F, 'Valid Range And Accuracy')
# Device Information Service # Device Information Service
GATT_SYSTEM_ID_CHARACTERISTIC = UUID.from_16_bits(0x2A23, 'System ID') GATT_SYSTEM_ID_CHARACTERISTIC = UUID.from_16_bits(0x2A23, 'System ID')
@@ -156,6 +173,96 @@ GATT_HEART_RATE_CONTROL_POINT_CHARACTERISTIC = UUID.from_16_bits(0x2A39, 'Heart
# Battery Service # Battery Service
GATT_BATTERY_LEVEL_CHARACTERISTIC = UUID.from_16_bits(0x2A19, 'Battery Level') GATT_BATTERY_LEVEL_CHARACTERISTIC = UUID.from_16_bits(0x2A19, 'Battery Level')
# Telephony And Media Audio Service (TMAS)
GATT_TMAP_ROLE_CHARACTERISTIC = UUID.from_16_bits(0x2B51, 'TMAP Role')
# Audio Input Control Service (AICS)
GATT_AUDIO_INPUT_STATE_CHARACTERISTIC = UUID.from_16_bits(0x2B77, 'Audio Input State')
GATT_GAIN_SETTINGS_ATTRIBUTE_CHARACTERISTIC = UUID.from_16_bits(0x2B78, 'Gain Settings Attribute')
GATT_AUDIO_INPUT_TYPE_CHARACTERISTIC = UUID.from_16_bits(0x2B79, 'Audio Input Type')
GATT_AUDIO_INPUT_STATUS_CHARACTERISTIC = UUID.from_16_bits(0x2B7A, 'Audio Input Status')
GATT_AUDIO_INPUT_CONTROL_POINT_CHARACTERISTIC = UUID.from_16_bits(0x2B7B, 'Audio Input Control Point')
GATT_AUDIO_INPUT_DESCRIPTION_CHARACTERISTIC = UUID.from_16_bits(0x2B7C, 'Audio Input Description')
# Volume Control Service (VCS)
GATT_VOLUME_STATE_CHARACTERISTIC = UUID.from_16_bits(0x2B7D, 'Volume State')
GATT_VOLUME_CONTROL_POINT_CHARACTERISTIC = UUID.from_16_bits(0x2B7E, 'Volume Control Point')
GATT_VOLUME_FLAGS_CHARACTERISTIC = UUID.from_16_bits(0x2B7F, 'Volume Flags')
# Volume Offset Control Service (VOCS)
GATT_VOLUME_OFFSET_STATE_CHARACTERISTIC = UUID.from_16_bits(0x2B80, 'Volume Offset State')
GATT_AUDIO_LOCATION_CHARACTERISTIC = UUID.from_16_bits(0x2B81, 'Audio Location')
GATT_VOLUME_OFFSET_CONTROL_POINT_CHARACTERISTIC = UUID.from_16_bits(0x2B82, 'Volume Offset Control Point')
GATT_AUDIO_OUTPUT_DESCRIPTION_CHARACTERISTIC = UUID.from_16_bits(0x2B83, 'Audio Output Description')
# Coordinated Set Identification Service (CSIS)
GATT_SET_IDENTITY_RESOLVING_KEY_CHARACTERISTIC = UUID.from_16_bits(0x2B84, 'Set Identity Resolving Key')
GATT_COORDINATED_SET_SIZE_CHARACTERISTIC = UUID.from_16_bits(0x2B85, 'Coordinated Set Size')
GATT_SET_MEMBER_LOCK_CHARACTERISTIC = UUID.from_16_bits(0x2B86, 'Set Member Lock')
GATT_SET_MEMBER_RANK_CHARACTERISTIC = UUID.from_16_bits(0x2B87, 'Set Member Rank')
# Media Control Service (MCS)
GATT_MEDIA_PLAYER_NAME_CHARACTERISTIC = UUID.from_16_bits(0x2B93, 'Media Player Name')
GATT_MEDIA_PLAYER_ICON_OBJECT_ID_CHARACTERISTIC = UUID.from_16_bits(0x2B94, 'Media Player Icon Object ID')
GATT_MEDIA_PLAYER_ICON_URL_CHARACTERISTIC = UUID.from_16_bits(0x2B95, 'Media Player Icon URL')
GATT_TRACK_CHANGED_CHARACTERISTIC = UUID.from_16_bits(0x2B96, 'Track Changed')
GATT_TRACK_TITLE_CHARACTERISTIC = UUID.from_16_bits(0x2B97, 'Track Title')
GATT_TRACK_DURATION_CHARACTERISTIC = UUID.from_16_bits(0x2B98, 'Track Duration')
GATT_TRACK_POSITION_CHARACTERISTIC = UUID.from_16_bits(0x2B99, 'Track Position')
GATT_PLAYBACK_SPEED_CHARACTERISTIC = UUID.from_16_bits(0x2B9A, 'Playback Speed')
GATT_SEEKING_SPEED_CHARACTERISTIC = UUID.from_16_bits(0x2B9B, 'Seeking Speed')
GATT_CURRENT_TRACK_SEGMENTS_OBJECT_ID_CHARACTERISTIC = UUID.from_16_bits(0x2B9C, 'Current Track Segments Object ID')
GATT_CURRENT_TRACK_OBJECT_ID_CHARACTERISTIC = UUID.from_16_bits(0x2B9D, 'Current Track Object ID')
GATT_NEXT_TRACK_OBJECT_ID_CHARACTERISTIC = UUID.from_16_bits(0x2B9E, 'Next Track Object ID')
GATT_PARENT_GROUP_OBJECT_ID_CHARACTERISTIC = UUID.from_16_bits(0x2B9F, 'Parent Group Object ID')
GATT_CURRENT_GROUP_OBJECT_ID_CHARACTERISTIC = UUID.from_16_bits(0x2BA0, 'Current Group Object ID')
GATT_PLAYING_ORDER_CHARACTERISTIC = UUID.from_16_bits(0x2BA1, 'Playing Order')
GATT_PLAYING_ORDERS_SUPPORTED_CHARACTERISTIC = UUID.from_16_bits(0x2BA2, 'Playing Orders Supported')
GATT_MEDIA_STATE_CHARACTERISTIC = UUID.from_16_bits(0x2BA3, 'Media State')
GATT_MEDIA_CONTROL_POINT_CHARACTERISTIC = UUID.from_16_bits(0x2BA4, 'Media Control Point')
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_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)
GATT_BEARER_PROVIDER_NAME_CHARACTERISTIC = UUID.from_16_bits(0x2BB4, 'Bearer Provider Name')
GATT_BEARER_UCI_CHARACTERISTIC = UUID.from_16_bits(0x2BB5, 'Bearer UCI')
GATT_BEARER_TECHNOLOGY_CHARACTERISTIC = UUID.from_16_bits(0x2BB6, 'Bearer Technology')
GATT_BEARER_URI_SCHEMES_SUPPORTED_LIST_CHARACTERISTIC = UUID.from_16_bits(0x2BB7, 'Bearer URI Schemes Supported List')
GATT_BEARER_SIGNAL_STRENGTH_CHARACTERISTIC = UUID.from_16_bits(0x2BB8, 'Bearer Signal Strength')
GATT_BEARER_SIGNAL_STRENGTH_REPORTING_INTERVAL_CHARACTERISTIC = UUID.from_16_bits(0x2BB9, 'Bearer Signal Strength Reporting Interval')
GATT_BEARER_LIST_CURRENT_CALLS_CHARACTERISTIC = UUID.from_16_bits(0x2BBA, 'Bearer List Current Calls')
GATT_CONTENT_CONTROL_ID_CHARACTERISTIC = UUID.from_16_bits(0x2BBB, 'Content Control ID')
GATT_STATUS_FLAGS_CHARACTERISTIC = UUID.from_16_bits(0x2BBC, 'Status Flags')
GATT_INCOMING_CALL_TARGET_BEARER_URI_CHARACTERISTIC = UUID.from_16_bits(0x2BBD, 'Incoming Call Target Bearer URI')
GATT_CALL_STATE_CHARACTERISTIC = UUID.from_16_bits(0x2BBE, 'Call State')
GATT_CALL_CONTROL_POINT_CHARACTERISTIC = UUID.from_16_bits(0x2BBF, 'Call Control Point')
GATT_CALL_CONTROL_POINT_OPTIONAL_OPCODES_CHARACTERISTIC = UUID.from_16_bits(0x2BC0, 'Call Control Point Optional Opcodes')
GATT_TERMINATION_REASON_CHARACTERISTIC = UUID.from_16_bits(0x2BC1, 'Termination Reason')
GATT_INCOMING_CALL_CHARACTERISTIC = UUID.from_16_bits(0x2BC2, 'Incoming Call')
GATT_CALL_FRIENDLY_NAME_CHARACTERISTIC = UUID.from_16_bits(0x2BC3, 'Call Friendly Name')
# Microphone Control Service (MICS)
GATT_MUTE_CHARACTERISTIC = UUID.from_16_bits(0x2BC3, 'Mute')
# Audio Stream Control Service (ASCS)
GATT_SINK_ASE_CHARACTERISTIC = UUID.from_16_bits(0x2BC4, 'Sink ASE')
GATT_SOURCE_ASE_CHARACTERISTIC = UUID.from_16_bits(0x2BC5, 'Source ASE')
GATT_ASE_CONTROL_POINT_CHARACTERISTIC = UUID.from_16_bits(0x2BC6, 'ASE Control Point')
# Broadcast Audio Scan Service (BASS)
GATT_BROADCAST_AUDIO_SCAN_CONTROL_POINT_CHARACTERISTIC = UUID.from_16_bits(0x2BC7, 'Broadcast Audio Scan Control Point')
GATT_BROADCAST_RECEIVE_STATE_CHARACTERISTIC = UUID.from_16_bits(0x2BC8, 'Broadcast Receive State')
# Published Audio Capabilities Service (PACS)
GATT_SINK_PAC_CHARACTERISTIC = UUID.from_16_bits(0x2BC9, 'Sink PAC')
GATT_SINK_AUDIO_LOCATION_CHARACTERISTIC = UUID.from_16_bits(0x2BCA, 'Sink Audio Location')
GATT_SOURCE_PAC_CHARACTERISTIC = UUID.from_16_bits(0x2BCB, 'Source PAC')
GATT_SOURCE_AUDIO_LOCATION_CHARACTERISTIC = UUID.from_16_bits(0x2BCC, 'Source Audio Location')
GATT_AVAILABLE_AUDIO_CONTEXTS_CHARACTERISTIC = UUID.from_16_bits(0x2BCD, 'Available Audio Contexts')
GATT_SUPPORTED_AUDIO_CONTEXTS_CHARACTERISTIC = UUID.from_16_bits(0x2BCE, 'Supported Audio Contexts')
# ASHA Service # ASHA Service
GATT_ASHA_SERVICE = UUID.from_16_bits(0xFDF0, 'Audio Streaming for Hearing Aid') GATT_ASHA_SERVICE = UUID.from_16_bits(0xFDF0, 'Audio Streaming for Hearing Aid')
GATT_ASHA_READ_ONLY_PROPERTIES_CHARACTERISTIC = UUID('6333651e-c481-4a3e-9169-7c902aad37bb', 'ReadOnlyProperties') GATT_ASHA_READ_ONLY_PROPERTIES_CHARACTERISTIC = UUID('6333651e-c481-4a3e-9169-7c902aad37bb', 'ReadOnlyProperties')
@@ -177,6 +284,9 @@ GATT_BOOT_KEYBOARD_INPUT_REPORT_CHARACTERISTIC = UUID.from_16_bi
GATT_CURRENT_TIME_CHARACTERISTIC = UUID.from_16_bits(0x2A2B, 'Current Time') GATT_CURRENT_TIME_CHARACTERISTIC = UUID.from_16_bits(0x2A2B, 'Current Time')
GATT_BOOT_KEYBOARD_OUTPUT_REPORT_CHARACTERISTIC = UUID.from_16_bits(0x2A32, 'Boot Keyboard Output Report') GATT_BOOT_KEYBOARD_OUTPUT_REPORT_CHARACTERISTIC = UUID.from_16_bits(0x2A32, 'Boot Keyboard Output Report')
GATT_CENTRAL_ADDRESS_RESOLUTION__CHARACTERISTIC = UUID.from_16_bits(0x2AA6, 'Central Address Resolution') GATT_CENTRAL_ADDRESS_RESOLUTION__CHARACTERISTIC = UUID.from_16_bits(0x2AA6, 'Central Address Resolution')
GATT_CLIENT_SUPPORTED_FEATURES_CHARACTERISTIC = UUID.from_16_bits(0x2B29, 'Client Supported Features')
GATT_DATABASE_HASH_CHARACTERISTIC = UUID.from_16_bits(0x2B2A, 'Database Hash')
GATT_SERVER_SUPPORTED_FEATURES_CHARACTERISTIC = UUID.from_16_bits(0x2B3A, 'Server Supported Features')
# fmt: on # fmt: on
# pylint: enable=line-too-long # pylint: enable=line-too-long
@@ -187,7 +297,7 @@ GATT_CENTRAL_ADDRESS_RESOLUTION__CHARACTERISTIC = UUID.from_16_bi
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
def show_services(services): def show_services(services: Iterable[Service]) -> None:
for service in services: for service in services:
print(color(str(service), 'cyan')) print(color(str(service), 'cyan'))
@@ -210,11 +320,11 @@ class Service(Attribute):
def __init__( def __init__(
self, self,
uuid, uuid: Union[str, UUID],
characteristics: List[Characteristic], characteristics: List[Characteristic],
primary=True, primary=True,
included_services: List[Service] = [], included_services: List[Service] = [],
): ) -> None:
# Convert the uuid to a UUID object if it isn't already # Convert the uuid to a UUID object if it isn't already
if isinstance(uuid, str): if isinstance(uuid, str):
uuid = UUID(uuid) uuid = UUID(uuid)
@@ -239,7 +349,7 @@ class Service(Attribute):
""" """
return None return None
def __str__(self): def __str__(self) -> str:
return ( return (
f'Service(handle=0x{self.handle:04X}, ' f'Service(handle=0x{self.handle:04X}, '
f'end=0x{self.end_group_handle:04X}, ' f'end=0x{self.end_group_handle:04X}, '
@@ -255,9 +365,11 @@ class TemplateService(Service):
to expose their UUID as a class property to expose their UUID as a class property
''' '''
UUID: Optional[UUID] = None UUID: UUID
def __init__(self, characteristics, primary=True): def __init__(
self, characteristics: List[Characteristic], primary: bool = True
) -> None:
super().__init__(self.UUID, characteristics, primary) super().__init__(self.UUID, characteristics, primary)
@@ -269,7 +381,7 @@ class IncludedServiceDeclaration(Attribute):
service: Service service: Service
def __init__(self, service): def __init__(self, service: Service) -> None:
declaration_bytes = struct.pack( declaration_bytes = struct.pack(
'<HH2s', service.handle, service.end_group_handle, service.uuid.to_bytes() '<HH2s', service.handle, service.end_group_handle, service.uuid.to_bytes()
) )
@@ -278,7 +390,7 @@ class IncludedServiceDeclaration(Attribute):
) )
self.service = service self.service = service
def __str__(self): def __str__(self) -> str:
return ( return (
f'IncludedServiceDefinition(handle=0x{self.handle:04X}, ' f'IncludedServiceDefinition(handle=0x{self.handle:04X}, '
f'group_starting_handle=0x{self.service.handle:04X}, ' f'group_starting_handle=0x{self.service.handle:04X}, '
@@ -326,7 +438,7 @@ class Characteristic(Attribute):
f"Characteristic.Properties::from_string() error:\nExpected a string containing any of the keys, separated by , or |: {enum_list_str}\nGot: {properties_str}" f"Characteristic.Properties::from_string() error:\nExpected a string containing any of the keys, separated by , or |: {enum_list_str}\nGot: {properties_str}"
) )
def __str__(self): def __str__(self) -> str:
# NOTE: we override this method to offer a consistent result between python # NOTE: we override this method to offer a consistent result between python
# versions: the value returned by IntFlag.__str__() changed in version 11. # versions: the value returned by IntFlag.__str__() changed in version 11.
return '|'.join( return '|'.join(
@@ -348,10 +460,10 @@ class Characteristic(Attribute):
def __init__( def __init__(
self, self,
uuid, uuid: Union[str, bytes, UUID],
properties: Characteristic.Properties, properties: Characteristic.Properties,
permissions, permissions: Union[str, Attribute.Permissions],
value=b'', value: Union[str, bytes, CharacteristicValue] = b'',
descriptors: Sequence[Descriptor] = (), descriptors: Sequence[Descriptor] = (),
): ):
super().__init__(uuid, permissions, value) super().__init__(uuid, permissions, value)
@@ -369,7 +481,7 @@ class Characteristic(Attribute):
def has_properties(self, properties: Characteristic.Properties) -> bool: def has_properties(self, properties: Characteristic.Properties) -> bool:
return self.properties & properties == properties return self.properties & properties == properties
def __str__(self): def __str__(self) -> str:
return ( return (
f'Characteristic(handle=0x{self.handle:04X}, ' f'Characteristic(handle=0x{self.handle:04X}, '
f'end=0x{self.end_group_handle:04X}, ' f'end=0x{self.end_group_handle:04X}, '
@@ -386,7 +498,7 @@ class CharacteristicDeclaration(Attribute):
characteristic: Characteristic characteristic: Characteristic
def __init__(self, characteristic, value_handle): def __init__(self, characteristic: Characteristic, value_handle: int) -> None:
declaration_bytes = ( declaration_bytes = (
struct.pack('<BH', characteristic.properties, value_handle) struct.pack('<BH', characteristic.properties, value_handle)
+ characteristic.uuid.to_pdu_bytes() + characteristic.uuid.to_pdu_bytes()
@@ -397,7 +509,7 @@ class CharacteristicDeclaration(Attribute):
self.value_handle = value_handle self.value_handle = value_handle
self.characteristic = characteristic self.characteristic = characteristic
def __str__(self): def __str__(self) -> str:
return ( return (
f'CharacteristicDeclaration(handle=0x{self.handle:04X}, ' f'CharacteristicDeclaration(handle=0x{self.handle:04X}, '
f'value_handle=0x{self.value_handle:04X}, ' f'value_handle=0x{self.value_handle:04X}, '
@@ -520,7 +632,7 @@ class CharacteristicAdapter:
return self.wrapped_characteristic.unsubscribe(subscriber) return self.wrapped_characteristic.unsubscribe(subscriber)
def __str__(self): def __str__(self) -> str:
wrapped = str(self.wrapped_characteristic) wrapped = str(self.wrapped_characteristic)
return f'{self.__class__.__name__}({wrapped})' return f'{self.__class__.__name__}({wrapped})'
@@ -600,10 +712,10 @@ class UTF8CharacteristicAdapter(CharacteristicAdapter):
Adapter that converts strings to/from bytes using UTF-8 encoding Adapter that converts strings to/from bytes using UTF-8 encoding
''' '''
def encode_value(self, value): def encode_value(self, value: str) -> bytes:
return value.encode('utf-8') return value.encode('utf-8')
def decode_value(self, value): def decode_value(self, value: bytes) -> str:
return value.decode('utf-8') return value.decode('utf-8')
@@ -613,7 +725,7 @@ class Descriptor(Attribute):
See Vol 3, Part G - 3.3.3 Characteristic Descriptor Declarations See Vol 3, Part G - 3.3.3 Characteristic Descriptor Declarations
''' '''
def __str__(self): def __str__(self) -> str:
return ( return (
f'Descriptor(handle=0x{self.handle:04X}, ' f'Descriptor(handle=0x{self.handle:04X}, '
f'type={self.type}, ' f'type={self.type}, '

View File

@@ -28,7 +28,19 @@ import asyncio
import logging import logging
import struct import struct
from datetime import datetime from datetime import datetime
from typing import List, Optional, Dict, Tuple, Callable, Union, Any from typing import (
List,
Optional,
Dict,
Tuple,
Callable,
Union,
Any,
Iterable,
Type,
Set,
TYPE_CHECKING,
)
from pyee import EventEmitter from pyee import EventEmitter
@@ -66,8 +78,12 @@ from .gatt import (
GATT_INCLUDE_ATTRIBUTE_TYPE, GATT_INCLUDE_ATTRIBUTE_TYPE,
Characteristic, Characteristic,
ClientCharacteristicConfigurationBits, ClientCharacteristicConfigurationBits,
TemplateService,
) )
if TYPE_CHECKING:
from bumble.device import Connection
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
# Logging # Logging
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
@@ -78,16 +94,16 @@ logger = logging.getLogger(__name__)
# Proxies # Proxies
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
class AttributeProxy(EventEmitter): class AttributeProxy(EventEmitter):
client: Client def __init__(
self, client: Client, handle: int, end_group_handle: int, attribute_type: UUID
def __init__(self, client, handle, end_group_handle, attribute_type): ) -> None:
EventEmitter.__init__(self) EventEmitter.__init__(self)
self.client = client self.client = client
self.handle = handle self.handle = handle
self.end_group_handle = end_group_handle self.end_group_handle = end_group_handle
self.type = attribute_type self.type = attribute_type
async def read_value(self, no_long_read=False): async def read_value(self, no_long_read: bool = False) -> bytes:
return self.decode_value( return self.decode_value(
await self.client.read_value(self.handle, no_long_read) await self.client.read_value(self.handle, no_long_read)
) )
@@ -97,13 +113,13 @@ class AttributeProxy(EventEmitter):
self.handle, self.encode_value(value), with_response self.handle, self.encode_value(value), with_response
) )
def encode_value(self, value): def encode_value(self, value: Any) -> bytes:
return value return value
def decode_value(self, value_bytes): def decode_value(self, value_bytes: bytes) -> Any:
return value_bytes return value_bytes
def __str__(self): def __str__(self) -> str:
return f'Attribute(handle=0x{self.handle:04X}, type={self.type})' return f'Attribute(handle=0x{self.handle:04X}, type={self.type})'
@@ -113,7 +129,7 @@ class ServiceProxy(AttributeProxy):
included_services: List[ServiceProxy] included_services: List[ServiceProxy]
@staticmethod @staticmethod
def from_client(service_class, client, service_uuid): def from_client(service_class, client: Client, service_uuid: UUID):
# The service and its characteristics are considered to have already been # The service and its characteristics are considered to have already been
# discovered # discovered
services = client.get_services_by_uuid(service_uuid) services = client.get_services_by_uuid(service_uuid)
@@ -136,14 +152,14 @@ class ServiceProxy(AttributeProxy):
def get_characteristics_by_uuid(self, uuid): def get_characteristics_by_uuid(self, uuid):
return self.client.get_characteristics_by_uuid(uuid, self) return self.client.get_characteristics_by_uuid(uuid, self)
def __str__(self): def __str__(self) -> str:
return f'Service(handle=0x{self.handle:04X}, uuid={self.uuid})' return f'Service(handle=0x{self.handle:04X}, uuid={self.uuid})'
class CharacteristicProxy(AttributeProxy): class CharacteristicProxy(AttributeProxy):
properties: Characteristic.Properties properties: Characteristic.Properties
descriptors: List[DescriptorProxy] descriptors: List[DescriptorProxy]
subscribers: Dict[Any, Callable] subscribers: Dict[Any, Callable[[bytes], Any]]
def __init__( def __init__(
self, self,
@@ -171,7 +187,9 @@ class CharacteristicProxy(AttributeProxy):
return await self.client.discover_descriptors(self) return await self.client.discover_descriptors(self)
async def subscribe( async def subscribe(
self, subscriber: Optional[Callable] = None, prefer_notify=True self,
subscriber: Optional[Callable[[bytes], Any]] = None,
prefer_notify: bool = True,
): ):
if subscriber is not None: if subscriber is not None:
if subscriber in self.subscribers: if subscriber in self.subscribers:
@@ -189,13 +207,13 @@ class CharacteristicProxy(AttributeProxy):
return await self.client.subscribe(self, subscriber, prefer_notify) return await self.client.subscribe(self, subscriber, prefer_notify)
async def unsubscribe(self, subscriber=None): async def unsubscribe(self, subscriber=None, force=False):
if subscriber in self.subscribers: if subscriber in self.subscribers:
subscriber = self.subscribers.pop(subscriber) subscriber = self.subscribers.pop(subscriber)
return await self.client.unsubscribe(self, subscriber) return await self.client.unsubscribe(self, subscriber, force)
def __str__(self): def __str__(self) -> str:
return ( return (
f'Characteristic(handle=0x{self.handle:04X}, ' f'Characteristic(handle=0x{self.handle:04X}, '
f'uuid={self.uuid}, ' f'uuid={self.uuid}, '
@@ -207,7 +225,7 @@ class DescriptorProxy(AttributeProxy):
def __init__(self, client, handle, descriptor_type): def __init__(self, client, handle, descriptor_type):
super().__init__(client, handle, 0, descriptor_type) super().__init__(client, handle, 0, descriptor_type)
def __str__(self): def __str__(self) -> str:
return f'Descriptor(handle=0x{self.handle:04X}, type={self.type})' return f'Descriptor(handle=0x{self.handle:04X}, type={self.type})'
@@ -216,8 +234,10 @@ class ProfileServiceProxy:
Base class for profile-specific service proxies Base class for profile-specific service proxies
''' '''
SERVICE_CLASS: Type[TemplateService]
@classmethod @classmethod
def from_client(cls, client): def from_client(cls, client: Client) -> ProfileServiceProxy:
return ServiceProxy.from_client(cls, client, cls.SERVICE_CLASS.UUID) return ServiceProxy.from_client(cls, client, cls.SERVICE_CLASS.UUID)
@@ -227,30 +247,36 @@ class ProfileServiceProxy:
class Client: 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[
int, Set[Union[CharacteristicProxy, Callable[[bytes], Any]]]
]
indication_subscribers: Dict[
int, Set[Union[CharacteristicProxy, Callable[[bytes], Any]]]
]
pending_response: Optional[asyncio.futures.Future[ATT_PDU]]
pending_request: Optional[ATT_PDU]
def __init__(self, connection): def __init__(self, connection: Connection) -> None:
self.connection = connection self.connection = connection
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
self.pending_response = None self.pending_response = None
self.notification_subscribers = ( self.notification_subscribers = {} # Subscriber set, by attribute handle
{} self.indication_subscribers = {} # Subscriber set, by attribute handle
) # Notification subscribers, by attribute handle
self.indication_subscribers = {} # Indication subscribers, by attribute handle
self.services = [] self.services = []
self.cached_values = {} self.cached_values = {}
def send_gatt_pdu(self, pdu): def send_gatt_pdu(self, pdu: bytes) -> None:
self.connection.send_l2cap_pdu(ATT_CID, pdu) self.connection.send_l2cap_pdu(ATT_CID, pdu)
async def send_command(self, command): async def send_command(self, command: ATT_PDU) -> None:
logger.debug( logger.debug(
f'GATT Command from client: [0x{self.connection.handle:04X}] {command}' f'GATT Command from client: [0x{self.connection.handle:04X}] {command}'
) )
self.send_gatt_pdu(command.to_bytes()) self.send_gatt_pdu(command.to_bytes())
async def send_request(self, request): async def send_request(self, request: ATT_PDU):
logger.debug( logger.debug(
f'GATT Request from client: [0x{self.connection.handle:04X}] {request}' f'GATT Request from client: [0x{self.connection.handle:04X}] {request}'
) )
@@ -279,14 +305,14 @@ class Client:
return response return response
def send_confirmation(self, confirmation): def send_confirmation(self, confirmation: ATT_Handle_Value_Confirmation) -> None:
logger.debug( logger.debug(
f'GATT Confirmation from client: [0x{self.connection.handle:04X}] ' f'GATT Confirmation from client: [0x{self.connection.handle:04X}] '
f'{confirmation}' f'{confirmation}'
) )
self.send_gatt_pdu(confirmation.to_bytes()) self.send_gatt_pdu(confirmation.to_bytes())
async def request_mtu(self, mtu): async def request_mtu(self, mtu: int) -> int:
# Check the range # Check the range
if mtu < ATT_DEFAULT_MTU: if mtu < ATT_DEFAULT_MTU:
raise ValueError(f'MTU must be >= {ATT_DEFAULT_MTU}') raise ValueError(f'MTU must be >= {ATT_DEFAULT_MTU}')
@@ -313,10 +339,12 @@ class Client:
return self.connection.att_mtu return self.connection.att_mtu
def get_services_by_uuid(self, uuid): 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(self, uuid, service=None): def get_characteristics_by_uuid(
self, uuid: UUID, service: Optional[ServiceProxy] = None
) -> List[CharacteristicProxy]:
services = [service] if service else self.services services = [service] if service else self.services
return [ return [
c c
@@ -363,7 +391,7 @@ class Client:
if not already_known: if not already_known:
self.services.append(service) self.services.append(service)
async def discover_services(self, uuids=None) -> List[ServiceProxy]: async def discover_services(self, uuids: Iterable[UUID] = []) -> List[ServiceProxy]:
''' '''
See Vol 3, Part G - 4.4.1 Discover All Primary Services See Vol 3, Part G - 4.4.1 Discover All Primary Services
''' '''
@@ -435,7 +463,7 @@ class Client:
return services return services
async def discover_service(self, uuid): async def discover_service(self, uuid: Union[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
''' '''
@@ -468,7 +496,7 @@ class Client:
f'{HCI_Constant.error_name(response.error_code)}' f'{HCI_Constant.error_name(response.error_code)}'
) )
# TODO raise appropriate exception # TODO raise appropriate exception
return return []
break break
for attribute_handle, end_group_handle in response.handles_information: for attribute_handle, end_group_handle in response.handles_information:
@@ -480,7 +508,7 @@ class Client:
logger.warning( logger.warning(
f'bogus handle values: {attribute_handle} {end_group_handle}' f'bogus handle values: {attribute_handle} {end_group_handle}'
) )
return return []
# Create a service proxy for this service # Create a service proxy for this service
service = ServiceProxy( service = ServiceProxy(
@@ -657,8 +685,8 @@ class Client:
async def discover_descriptors( async def discover_descriptors(
self, self,
characteristic: Optional[CharacteristicProxy] = None, characteristic: Optional[CharacteristicProxy] = None,
start_handle=None, start_handle: Optional[int] = None,
end_handle=None, end_handle: Optional[int] = 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
@@ -721,7 +749,7 @@ class Client:
return descriptors return descriptors
async def discover_attributes(self): async def discover_attributes(self) -> List[AttributeProxy]:
''' '''
Discover all attributes, regardless of type Discover all attributes, regardless of type
''' '''
@@ -764,7 +792,12 @@ class Client:
return attributes return attributes
async def subscribe(self, characteristic, subscriber=None, prefer_notify=True): async def subscribe(
self,
characteristic: CharacteristicProxy,
subscriber: Optional[Callable[[bytes], Any]] = None,
prefer_notify: bool = True,
) -> None:
# If we haven't already discovered the descriptors for this characteristic, # If we haven't already discovered the descriptors for this characteristic,
# do it now # do it now
if not characteristic.descriptors_discovered: if not characteristic.descriptors_discovered:
@@ -801,6 +834,7 @@ class Client:
subscriber_set = subscribers.setdefault(characteristic.handle, set()) subscriber_set = subscribers.setdefault(characteristic.handle, set())
if subscriber is not None: if subscriber is not None:
subscriber_set.add(subscriber) subscriber_set.add(subscriber)
# Add the characteristic as a subscriber, which will result in the # Add the characteristic as a subscriber, which will result in the
# characteristic emitting an 'update' event when a notification or indication # characteristic emitting an 'update' event when a notification or indication
# is received # is received
@@ -808,7 +842,18 @@ class Client:
await self.write_value(cccd, struct.pack('<H', bits), with_response=True) await self.write_value(cccd, struct.pack('<H', bits), with_response=True)
async def unsubscribe(self, characteristic, subscriber=None): async def unsubscribe(
self,
characteristic: CharacteristicProxy,
subscriber: Optional[Callable[[bytes], Any]] = None,
force: bool = False,
) -> None:
'''
Unsubscribe from a characteristic.
If `force` is True, this will write zeros to the CCCD when there are no
subscribers left, even if there were already no registered subscribers.
'''
# If we haven't already discovered the descriptors for this characteristic, # If we haven't already discovered the descriptors for this characteristic,
# do it now # do it now
if not characteristic.descriptors_discovered: if not characteristic.descriptors_discovered:
@@ -822,29 +867,45 @@ class Client:
logger.warning('unsubscribing from characteristic with no CCCD descriptor') logger.warning('unsubscribing from characteristic with no CCCD descriptor')
return return
# Check if the characteristic has subscribers
if not (
characteristic.handle in self.notification_subscribers
or characteristic.handle in self.indication_subscribers
):
if not force:
return
# Remove the subscriber(s)
if subscriber is not None: if subscriber is not None:
# Remove matching subscriber from subscriber sets # Remove matching subscriber from subscriber sets
for subscriber_set in ( for subscriber_set in (
self.notification_subscribers, self.notification_subscribers,
self.indication_subscribers, self.indication_subscribers,
): ):
subscribers = subscriber_set.get(characteristic.handle, []) if (
if subscriber in subscribers: subscribers := subscriber_set.get(characteristic.handle)
) and subscriber in subscribers:
subscribers.remove(subscriber) subscribers.remove(subscriber)
# Cleanup if we removed the last one # Cleanup if we removed the last one
if not subscribers: if not subscribers:
del subscriber_set[characteristic.handle] del subscriber_set[characteristic.handle]
else: else:
# Remove all subscribers for this attribute from the sets! # Remove all subscribers for this attribute from the sets
self.notification_subscribers.pop(characteristic.handle, None) self.notification_subscribers.pop(characteristic.handle, None)
self.indication_subscribers.pop(characteristic.handle, None) self.indication_subscribers.pop(characteristic.handle, None)
if not self.notification_subscribers and not self.indication_subscribers: # Update the CCCD
if not (
characteristic.handle in self.notification_subscribers
or characteristic.handle in self.indication_subscribers
):
# No more subscribers left # No more subscribers left
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(self, attribute, no_long_read=False): async def read_value(
self, attribute: Union[int, AttributeProxy], no_long_read: bool = False
) -> bytes:
''' '''
See Vol 3, Part G - 4.8.1 Read Characteristic Value See Vol 3, Part G - 4.8.1 Read Characteristic Value
@@ -905,7 +966,9 @@ class Client:
# Return the value as bytes # Return the value as bytes
return attribute_value return attribute_value
async def read_characteristics_by_uuid(self, uuid, service): async def read_characteristics_by_uuid(
self, uuid: UUID, service: Optional[ServiceProxy]
) -> 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
''' '''
@@ -960,7 +1023,12 @@ class Client:
return characteristics_values return characteristics_values
async def write_value(self, attribute, value, with_response=False): async def write_value(
self,
attribute: Union[int, AttributeProxy],
value: bytes,
with_response: bool = False,
) -> None:
''' '''
See Vol 3, Part G - 4.9.1 Write Without Response & 4.9.3 Write Characteristic See Vol 3, Part G - 4.9.1 Write Without Response & 4.9.3 Write Characteristic
Value Value
@@ -990,7 +1058,7 @@ class Client:
) )
) )
def on_gatt_pdu(self, att_pdu): def on_gatt_pdu(self, att_pdu: ATT_PDU) -> None:
logger.debug( logger.debug(
f'GATT Response to client: [0x{self.connection.handle:04X}] {att_pdu}' f'GATT Response to client: [0x{self.connection.handle:04X}] {att_pdu}'
) )
@@ -1013,6 +1081,7 @@ class Client:
return return
# Return the response to the coroutine that is waiting for it # Return the response to the coroutine that is waiting for it
assert self.pending_response is not None
self.pending_response.set_result(att_pdu) self.pending_response.set_result(att_pdu)
else: else:
handler_name = f'on_{att_pdu.name.lower()}' handler_name = f'on_{att_pdu.name.lower()}'
@@ -1032,7 +1101,7 @@ class Client:
def on_att_handle_value_notification(self, notification): def on_att_handle_value_notification(self, notification):
# Call all subscribers # Call all subscribers
subscribers = self.notification_subscribers.get( subscribers = self.notification_subscribers.get(
notification.attribute_handle, [] notification.attribute_handle, set()
) )
if not subscribers: if not subscribers:
logger.warning('!!! received notification with no subscriber') logger.warning('!!! received notification with no subscriber')
@@ -1046,7 +1115,9 @@ class Client:
def on_att_handle_value_indication(self, indication): def on_att_handle_value_indication(self, indication):
# Call all subscribers # Call all subscribers
subscribers = self.indication_subscribers.get(indication.attribute_handle, []) subscribers = self.indication_subscribers.get(
indication.attribute_handle, set()
)
if not subscribers: if not subscribers:
logger.warning('!!! received indication with no subscriber') logger.warning('!!! received indication with no subscriber')
@@ -1060,7 +1131,7 @@ class Client:
# Confirm that we received the indication # Confirm that we received the indication
self.send_confirmation(ATT_Handle_Value_Confirmation()) self.send_confirmation(ATT_Handle_Value_Confirmation())
def cache_value(self, attribute_handle: int, value: bytes): def cache_value(self, attribute_handle: int, value: bytes) -> None:
self.cached_values[attribute_handle] = ( self.cached_values[attribute_handle] = (
datetime.now(), datetime.now(),
value, value,

View File

@@ -23,11 +23,12 @@
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
# Imports # Imports
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
from __future__ import annotations
import asyncio import asyncio
import logging import logging
from collections import defaultdict from collections import defaultdict
import struct import struct
from typing import List, Tuple, Optional, TypeVar, Type from typing import List, Tuple, Optional, TypeVar, Type, Dict, Iterable, TYPE_CHECKING
from pyee import EventEmitter from pyee import EventEmitter
from .colors import color from .colors import color
@@ -42,6 +43,7 @@ from .att import (
ATT_INVALID_OFFSET_ERROR, ATT_INVALID_OFFSET_ERROR,
ATT_REQUEST_NOT_SUPPORTED_ERROR, ATT_REQUEST_NOT_SUPPORTED_ERROR,
ATT_REQUESTS, ATT_REQUESTS,
ATT_PDU,
ATT_UNLIKELY_ERROR_ERROR, ATT_UNLIKELY_ERROR_ERROR,
ATT_UNSUPPORTED_GROUP_TYPE_ERROR, ATT_UNSUPPORTED_GROUP_TYPE_ERROR,
ATT_Error, ATT_Error,
@@ -73,6 +75,8 @@ from .gatt import (
Service, Service,
) )
if TYPE_CHECKING:
from bumble.device import Device, Connection
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
# Logging # Logging
@@ -91,8 +95,13 @@ GATT_SERVER_DEFAULT_MAX_MTU = 517
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
class Server(EventEmitter): class Server(EventEmitter):
attributes: List[Attribute] attributes: List[Attribute]
services: List[Service]
attributes_by_handle: Dict[int, Attribute]
subscribers: Dict[int, Dict[int, bytes]]
indication_semaphores: defaultdict[int, asyncio.Semaphore]
pending_confirmations: defaultdict[int, Optional[asyncio.futures.Future]]
def __init__(self, device): def __init__(self, device: Device) -> None:
super().__init__() super().__init__()
self.device = device self.device = device
self.services = [] self.services = []
@@ -107,16 +116,16 @@ class Server(EventEmitter):
self.indication_semaphores = defaultdict(lambda: asyncio.Semaphore(1)) self.indication_semaphores = defaultdict(lambda: asyncio.Semaphore(1))
self.pending_confirmations = defaultdict(lambda: None) self.pending_confirmations = defaultdict(lambda: None)
def __str__(self): def __str__(self) -> str:
return "\n".join(map(str, self.attributes)) return "\n".join(map(str, self.attributes))
def send_gatt_pdu(self, connection_handle, pdu): def send_gatt_pdu(self, connection_handle: int, pdu: bytes) -> None:
self.device.send_l2cap_pdu(connection_handle, ATT_CID, pdu) self.device.send_l2cap_pdu(connection_handle, ATT_CID, pdu)
def next_handle(self): def next_handle(self) -> int:
return 1 + len(self.attributes) return 1 + len(self.attributes)
def get_advertising_service_data(self): def get_advertising_service_data(self) -> Dict[Attribute, bytes]:
return { return {
attribute: data attribute: data
for attribute in self.attributes for attribute in self.attributes
@@ -124,7 +133,7 @@ class Server(EventEmitter):
and (data := attribute.get_advertising_data()) and (data := attribute.get_advertising_data())
} }
def get_attribute(self, handle): def get_attribute(self, handle: int) -> Optional[Attribute]:
attribute = self.attributes_by_handle.get(handle) attribute = self.attributes_by_handle.get(handle)
if attribute: if attribute:
return attribute return attribute
@@ -173,12 +182,17 @@ class Server(EventEmitter):
return next( return next(
( (
(attribute, self.get_attribute(attribute.characteristic.handle)) (
attribute,
self.get_attribute(attribute.characteristic.handle),
) # type: ignore
for attribute in map( for attribute in map(
self.get_attribute, self.get_attribute,
range(service_handle.handle, service_handle.end_group_handle + 1), range(service_handle.handle, service_handle.end_group_handle + 1),
) )
if attribute.type == GATT_CHARACTERISTIC_ATTRIBUTE_TYPE if attribute is not None
and attribute.type == GATT_CHARACTERISTIC_ATTRIBUTE_TYPE
and isinstance(attribute, CharacteristicDeclaration)
and attribute.characteristic.uuid == characteristic_uuid and attribute.characteristic.uuid == characteristic_uuid
), ),
None, None,
@@ -197,7 +211,7 @@ class Server(EventEmitter):
return next( return next(
( (
attribute attribute # type: ignore
for attribute in map( for attribute in map(
self.get_attribute, self.get_attribute,
range( range(
@@ -205,12 +219,12 @@ class Server(EventEmitter):
characteristic_value.end_group_handle + 1, characteristic_value.end_group_handle + 1,
), ),
) )
if attribute.type == descriptor_uuid if attribute is not None and attribute.type == descriptor_uuid
), ),
None, None,
) )
def add_attribute(self, attribute): def add_attribute(self, attribute: Attribute) -> None:
# Assign a handle to this attribute # Assign a handle to this attribute
attribute.handle = self.next_handle() attribute.handle = self.next_handle()
attribute.end_group_handle = ( attribute.end_group_handle = (
@@ -220,7 +234,7 @@ class Server(EventEmitter):
# Add this attribute to the list # Add this attribute to the list
self.attributes.append(attribute) self.attributes.append(attribute)
def add_service(self, service: Service): def add_service(self, service: Service) -> None:
# Add the service attribute to the DB # Add the service attribute to the DB
self.add_attribute(service) self.add_attribute(service)
@@ -285,11 +299,13 @@ class Server(EventEmitter):
service.end_group_handle = self.attributes[-1].handle service.end_group_handle = self.attributes[-1].handle
self.services.append(service) self.services.append(service)
def add_services(self, services): def add_services(self, services: Iterable[Service]) -> None:
for service in services: for service in services:
self.add_service(service) self.add_service(service)
def read_cccd(self, connection, characteristic): def read_cccd(
self, connection: Optional[Connection], characteristic: Characteristic
) -> bytes:
if connection is None: if connection is None:
return bytes([0, 0]) return bytes([0, 0])
@@ -300,7 +316,12 @@ class Server(EventEmitter):
return cccd or bytes([0, 0]) return cccd or bytes([0, 0])
def write_cccd(self, connection, characteristic, value): def write_cccd(
self,
connection: Connection,
characteristic: Characteristic,
value: bytes,
) -> None:
logger.debug( logger.debug(
f'Subscription update for connection=0x{connection.handle:04X}, ' f'Subscription update for connection=0x{connection.handle:04X}, '
f'handle=0x{characteristic.handle:04X}: {value.hex()}' f'handle=0x{characteristic.handle:04X}: {value.hex()}'
@@ -327,13 +348,19 @@ class Server(EventEmitter):
indicate_enabled, indicate_enabled,
) )
def send_response(self, connection, response): def send_response(self, connection: Connection, response: ATT_PDU) -> None:
logger.debug( logger.debug(
f'GATT Response from server: [0x{connection.handle:04X}] {response}' f'GATT Response from server: [0x{connection.handle:04X}] {response}'
) )
self.send_gatt_pdu(connection.handle, response.to_bytes()) self.send_gatt_pdu(connection.handle, response.to_bytes())
async def notify_subscriber(self, connection, attribute, value=None, force=False): async def notify_subscriber(
self,
connection: Connection,
attribute: Attribute,
value: Optional[bytes] = None,
force: bool = False,
) -> None:
# Check if there's a subscriber # Check if there's a subscriber
if not force: if not force:
subscribers = self.subscribers.get(connection.handle) subscribers = self.subscribers.get(connection.handle)
@@ -370,7 +397,13 @@ class Server(EventEmitter):
) )
self.send_gatt_pdu(connection.handle, bytes(notification)) self.send_gatt_pdu(connection.handle, bytes(notification))
async def indicate_subscriber(self, connection, attribute, value=None, force=False): async def indicate_subscriber(
self,
connection: Connection,
attribute: Attribute,
value: Optional[bytes] = None,
force: bool = False,
) -> None:
# Check if there's a subscriber # Check if there's a subscriber
if not force: if not force:
subscribers = self.subscribers.get(connection.handle) subscribers = self.subscribers.get(connection.handle)
@@ -411,15 +444,13 @@ class Server(EventEmitter):
assert self.pending_confirmations[connection.handle] is None assert self.pending_confirmations[connection.handle] is None
# Create a future value to hold the eventual response # Create a future value to hold the eventual response
self.pending_confirmations[ pending_confirmation = self.pending_confirmations[
connection.handle connection.handle
] = asyncio.get_running_loop().create_future() ] = asyncio.get_running_loop().create_future()
try: try:
self.send_gatt_pdu(connection.handle, indication.to_bytes()) self.send_gatt_pdu(connection.handle, indication.to_bytes())
await asyncio.wait_for( await asyncio.wait_for(pending_confirmation, GATT_REQUEST_TIMEOUT)
self.pending_confirmations[connection.handle], GATT_REQUEST_TIMEOUT
)
except asyncio.TimeoutError as error: except asyncio.TimeoutError as error:
logger.warning(color('!!! GATT Indicate timeout', 'red')) logger.warning(color('!!! GATT Indicate timeout', 'red'))
raise TimeoutError(f'GATT timeout for {indication.name}') from error raise TimeoutError(f'GATT timeout for {indication.name}') from error
@@ -427,8 +458,12 @@ class Server(EventEmitter):
self.pending_confirmations[connection.handle] = None self.pending_confirmations[connection.handle] = None
async def notify_or_indicate_subscribers( async def notify_or_indicate_subscribers(
self, indicate, attribute, value=None, force=False self,
): indicate: bool,
attribute: Attribute,
value: Optional[bytes] = None,
force: bool = False,
) -> None:
# Get all the connections for which there's at least one subscription # Get all the connections for which there's at least one subscription
connections = [ connections = [
connection connection
@@ -450,13 +485,23 @@ class Server(EventEmitter):
] ]
) )
async def notify_subscribers(self, attribute, value=None, force=False): async def notify_subscribers(
self,
attribute: Attribute,
value: Optional[bytes] = None,
force: bool = False,
):
return await self.notify_or_indicate_subscribers(False, attribute, value, force) return await self.notify_or_indicate_subscribers(False, attribute, value, force)
async def indicate_subscribers(self, attribute, value=None, force=False): async def indicate_subscribers(
self,
attribute: Attribute,
value: Optional[bytes] = None,
force: bool = False,
):
return await self.notify_or_indicate_subscribers(True, attribute, value, force) return await self.notify_or_indicate_subscribers(True, attribute, value, force)
def on_disconnection(self, connection): def on_disconnection(self, connection: Connection) -> None:
if connection.handle in self.subscribers: if connection.handle in self.subscribers:
del self.subscribers[connection.handle] del self.subscribers[connection.handle]
if connection.handle in self.indication_semaphores: if connection.handle in self.indication_semaphores:
@@ -464,7 +509,7 @@ class Server(EventEmitter):
if connection.handle in self.pending_confirmations: if connection.handle in self.pending_confirmations:
del self.pending_confirmations[connection.handle] del self.pending_confirmations[connection.handle]
def on_gatt_pdu(self, connection, att_pdu): def on_gatt_pdu(self, connection: Connection, att_pdu: ATT_PDU) -> None:
logger.debug(f'GATT Request to server: [0x{connection.handle:04X}] {att_pdu}') logger.debug(f'GATT Request to server: [0x{connection.handle:04X}] {att_pdu}')
handler_name = f'on_{att_pdu.name.lower()}' handler_name = f'on_{att_pdu.name.lower()}'
handler = getattr(self, handler_name, None) handler = getattr(self, handler_name, None)
@@ -506,7 +551,7 @@ class Server(EventEmitter):
####################################################### #######################################################
# ATT handlers # ATT handlers
####################################################### #######################################################
def on_att_request(self, connection, pdu): def on_att_request(self, connection: Connection, pdu: ATT_PDU) -> None:
''' '''
Handler for requests without a more specific handler Handler for requests without a more specific handler
''' '''
@@ -679,7 +724,6 @@ class Server(EventEmitter):
and attribute.handle <= request.ending_handle and attribute.handle <= request.ending_handle
and pdu_space_available and pdu_space_available
): ):
try: try:
attribute_value = attribute.read_value(connection) attribute_value = attribute.read_value(connection)
except ATT_Error as error: except ATT_Error as error:

View File

@@ -17,10 +17,12 @@
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
from __future__ import annotations from __future__ import annotations
import collections import collections
import dataclasses
import enum
import functools import functools
import logging import logging
import struct import struct
from typing import Any, Dict, Callable, Optional, Type, Union from typing import Any, Dict, Callable, Optional, Type, Union, List
from .colors import color from .colors import color
from .core import ( from .core import (
@@ -121,6 +123,7 @@ HCI_VERSION_BLUETOOTH_CORE_5_0 = 9
HCI_VERSION_BLUETOOTH_CORE_5_1 = 10 HCI_VERSION_BLUETOOTH_CORE_5_1 = 10
HCI_VERSION_BLUETOOTH_CORE_5_2 = 11 HCI_VERSION_BLUETOOTH_CORE_5_2 = 11
HCI_VERSION_BLUETOOTH_CORE_5_3 = 12 HCI_VERSION_BLUETOOTH_CORE_5_3 = 12
HCI_VERSION_BLUETOOTH_CORE_5_4 = 13
HCI_VERSION_NAMES = { HCI_VERSION_NAMES = {
HCI_VERSION_BLUETOOTH_CORE_1_0B: 'HCI_VERSION_BLUETOOTH_CORE_1_0B', HCI_VERSION_BLUETOOTH_CORE_1_0B: 'HCI_VERSION_BLUETOOTH_CORE_1_0B',
@@ -135,7 +138,8 @@ HCI_VERSION_NAMES = {
HCI_VERSION_BLUETOOTH_CORE_5_0: 'HCI_VERSION_BLUETOOTH_CORE_5_0', HCI_VERSION_BLUETOOTH_CORE_5_0: 'HCI_VERSION_BLUETOOTH_CORE_5_0',
HCI_VERSION_BLUETOOTH_CORE_5_1: 'HCI_VERSION_BLUETOOTH_CORE_5_1', HCI_VERSION_BLUETOOTH_CORE_5_1: 'HCI_VERSION_BLUETOOTH_CORE_5_1',
HCI_VERSION_BLUETOOTH_CORE_5_2: 'HCI_VERSION_BLUETOOTH_CORE_5_2', HCI_VERSION_BLUETOOTH_CORE_5_2: 'HCI_VERSION_BLUETOOTH_CORE_5_2',
HCI_VERSION_BLUETOOTH_CORE_5_3: 'HCI_VERSION_BLUETOOTH_CORE_5_3' HCI_VERSION_BLUETOOTH_CORE_5_3: 'HCI_VERSION_BLUETOOTH_CORE_5_3',
HCI_VERSION_BLUETOOTH_CORE_5_4: 'HCI_VERSION_BLUETOOTH_CORE_5_4',
} }
# LMP Version # LMP Version
@@ -146,6 +150,7 @@ HCI_COMMAND_PACKET = 0x01
HCI_ACL_DATA_PACKET = 0x02 HCI_ACL_DATA_PACKET = 0x02
HCI_SYNCHRONOUS_DATA_PACKET = 0x03 HCI_SYNCHRONOUS_DATA_PACKET = 0x03
HCI_EVENT_PACKET = 0x04 HCI_EVENT_PACKET = 0x04
HCI_ISO_DATA_PACKET = 0x05
# HCI Event Codes # HCI Event Codes
HCI_INQUIRY_COMPLETE_EVENT = 0x01 HCI_INQUIRY_COMPLETE_EVENT = 0x01
@@ -556,6 +561,12 @@ HCI_LE_TRANSMITTER_TEST_V4_COMMAND = hci_c
HCI_LE_SET_DATA_RELATED_ADDRESS_CHANGES_COMMAND = hci_command_op_code(0x08, 0x007C) HCI_LE_SET_DATA_RELATED_ADDRESS_CHANGES_COMMAND = hci_command_op_code(0x08, 0x007C)
HCI_LE_SET_DEFAULT_SUBRATE_COMMAND = hci_command_op_code(0x08, 0x007D) HCI_LE_SET_DEFAULT_SUBRATE_COMMAND = hci_command_op_code(0x08, 0x007D)
HCI_LE_SUBRATE_REQUEST_COMMAND = hci_command_op_code(0x08, 0x007E) HCI_LE_SUBRATE_REQUEST_COMMAND = hci_command_op_code(0x08, 0x007E)
HCI_LE_SET_EXTENDED_ADVERTISING_PARAMETERS_V2_COMMAND = hci_command_op_code(0x08, 0x007F)
HCI_LE_SET_PERIODIC_ADVERTISING_SUBEVENT_DATA_COMMAND = hci_command_op_code(0x08, 0x0082)
HCI_LE_SET_PERIODIC_ADVERTISING_RESPONSE_DATA_COMMAND = hci_command_op_code(0x08, 0x0083)
HCI_LE_SET_PERIODIC_SYNC_SUBEVENT_COMMAND = hci_command_op_code(0x08, 0x0084)
HCI_LE_EXTENDED_CREATE_CONNECTION_V2_COMMAND = hci_command_op_code(0x08, 0x0085)
HCI_LE_SET_PERIODIC_ADVERTISING_PARAMETERS_V2_COMMAND = hci_command_op_code(0x08, 0x0086)
# HCI Error Codes # HCI Error Codes
@@ -1312,62 +1323,79 @@ HCI_SUPPORTED_COMMANDS_FLAGS = (
( (
HCI_LE_SET_DEFAULT_SUBRATE_COMMAND, HCI_LE_SET_DEFAULT_SUBRATE_COMMAND,
HCI_LE_SUBRATE_REQUEST_COMMAND, HCI_LE_SUBRATE_REQUEST_COMMAND,
HCI_LE_SET_EXTENDED_ADVERTISING_PARAMETERS_V2_COMMAND,
None,
None,
HCI_LE_SET_PERIODIC_ADVERTISING_SUBEVENT_DATA_COMMAND,
HCI_LE_SET_PERIODIC_ADVERTISING_RESPONSE_DATA_COMMAND,
HCI_LE_SET_PERIODIC_SYNC_SUBEVENT_COMMAND
),
# Octet 47
(
HCI_LE_EXTENDED_CREATE_CONNECTION_V2_COMMAND,
HCI_LE_SET_PERIODIC_ADVERTISING_PARAMETERS_V2_COMMAND,
None,
None, None,
None, None,
None, None,
None, None,
None, None,
None
) )
) )
# LE Supported Features # LE Supported Features
HCI_LE_ENCRYPTION_LE_SUPPORTED_FEATURE = 0 # See Bluetooth spec @ Vol 6, Part B, 4.6 FEATURE SUPPORT
HCI_CONNECTION_PARAMETERS_REQUEST_PROCEDURE_LE_SUPPORTED_FEATURE = 1 HCI_LE_ENCRYPTION_LE_SUPPORTED_FEATURE = 0
HCI_EXTENDED_REJECT_INDICATION_LE_SUPPORTED_FEATURE = 2 HCI_CONNECTION_PARAMETERS_REQUEST_PROCEDURE_LE_SUPPORTED_FEATURE = 1
HCI_PERIPHERAL_INITIATED_FEATURE_EXCHANGE_LE_SUPPORTED_FEATURE = 3 HCI_EXTENDED_REJECT_INDICATION_LE_SUPPORTED_FEATURE = 2
HCI_LE_PING_LE_SUPPORTED_FEATURE = 4 HCI_PERIPHERAL_INITIATED_FEATURE_EXCHANGE_LE_SUPPORTED_FEATURE = 3
HCI_LE_DATA_PACKET_LENGTH_EXTENSION_LE_SUPPORTED_FEATURE = 5 HCI_LE_PING_LE_SUPPORTED_FEATURE = 4
HCI_LL_PRIVACY_LE_SUPPORTED_FEATURE = 6 HCI_LE_DATA_PACKET_LENGTH_EXTENSION_LE_SUPPORTED_FEATURE = 5
HCI_EXTENDED_SCANNER_FILTER_POLICIES_LE_SUPPORTED_FEATURE = 7 HCI_LL_PRIVACY_LE_SUPPORTED_FEATURE = 6
HCI_LE_2M_PHY_LE_SUPPORTED_FEATURE = 8 HCI_EXTENDED_SCANNER_FILTER_POLICIES_LE_SUPPORTED_FEATURE = 7
HCI_STABLE_MODULATION_INDEX_TRANSMITTER_LE_SUPPORTED_FEATURE = 9 HCI_LE_2M_PHY_LE_SUPPORTED_FEATURE = 8
HCI_STABLE_MODULATION_INDEX_RECEIVER_LE_SUPPORTED_FEATURE = 10 HCI_STABLE_MODULATION_INDEX_TRANSMITTER_LE_SUPPORTED_FEATURE = 9
HCI_LE_CODED_PHY_LE_SUPPORTED_FEATURE = 11 HCI_STABLE_MODULATION_INDEX_RECEIVER_LE_SUPPORTED_FEATURE = 10
HCI_LE_EXTENDED_ADVERTISING_LE_SUPPORTED_FEATURE = 12 HCI_LE_CODED_PHY_LE_SUPPORTED_FEATURE = 11
HCI_LE_PERIODIC_ADVERTISING_LE_SUPPORTED_FEATURE = 13 HCI_LE_EXTENDED_ADVERTISING_LE_SUPPORTED_FEATURE = 12
HCI_CHANNEL_SELECTION_ALGORITHM_2_LE_SUPPORTED_FEATURE = 14 HCI_LE_PERIODIC_ADVERTISING_LE_SUPPORTED_FEATURE = 13
HCI_LE_POWER_CLASS_1_LE_SUPPORTED_FEATURE = 15 HCI_CHANNEL_SELECTION_ALGORITHM_2_LE_SUPPORTED_FEATURE = 14
HCI_MINIMUM_NUMBER_OF_USED_CHANNELS_PROCEDURE_LE_SUPPORTED_FEATURE = 16 HCI_LE_POWER_CLASS_1_LE_SUPPORTED_FEATURE = 15
HCI_CONNECTION_CTE_REQUEST_LE_SUPPORTED_FEATURE = 17 HCI_MINIMUM_NUMBER_OF_USED_CHANNELS_PROCEDURE_LE_SUPPORTED_FEATURE = 16
HCI_CONNECTION_CTE_RESPONSE_LE_SUPPORTED_FEATURE = 18 HCI_CONNECTION_CTE_REQUEST_LE_SUPPORTED_FEATURE = 17
HCI_CONNECTIONLESS_CTE_TRANSMITTER_LE_SUPPORTED_FEATURE = 19 HCI_CONNECTION_CTE_RESPONSE_LE_SUPPORTED_FEATURE = 18
HCI_CONNECTIONLESS_CTR_RECEIVER_LE_SUPPORTED_FEATURE = 20 HCI_CONNECTIONLESS_CTE_TRANSMITTER_LE_SUPPORTED_FEATURE = 19
HCI_ANTENNA_SWITCHING_DURING_CTE_TRANSMISSION_LE_SUPPORTED_FEATURE = 21 HCI_CONNECTIONLESS_CTR_RECEIVER_LE_SUPPORTED_FEATURE = 20
HCI_ANTENNA_SWITCHING_DURING_CTE_RECEPTION_LE_SUPPORTED_FEATURE = 22 HCI_ANTENNA_SWITCHING_DURING_CTE_TRANSMISSION_LE_SUPPORTED_FEATURE = 21
HCI_RECEIVING_CONSTANT_TONE_EXTENSIONS_LE_SUPPORTED_FEATURE = 23 HCI_ANTENNA_SWITCHING_DURING_CTE_RECEPTION_LE_SUPPORTED_FEATURE = 22
HCI_PERIODIC_ADVERTISING_SYNC_TRANSFER_SENDER_LE_SUPPORTED_FEATURE = 24 HCI_RECEIVING_CONSTANT_TONE_EXTENSIONS_LE_SUPPORTED_FEATURE = 23
HCI_PERIODIC_ADVERTISING_SYNC_TRANSFER_RECIPIENT_LE_SUPPORTED_FEATURE = 25 HCI_PERIODIC_ADVERTISING_SYNC_TRANSFER_SENDER_LE_SUPPORTED_FEATURE = 24
HCI_SLEEP_CLOCK_ACCURACY_UPDATES_LE_SUPPORTED_FEATURE = 26 HCI_PERIODIC_ADVERTISING_SYNC_TRANSFER_RECIPIENT_LE_SUPPORTED_FEATURE = 25
HCI_REMOTE_PUBLIC_KEY_VALIDATION_LE_SUPPORTED_FEATURE = 27 HCI_SLEEP_CLOCK_ACCURACY_UPDATES_LE_SUPPORTED_FEATURE = 26
HCI_CONNECTED_ISOCHRONOUS_STREAM_CENTRAL_LE_SUPPORTED_FEATURE = 28 HCI_REMOTE_PUBLIC_KEY_VALIDATION_LE_SUPPORTED_FEATURE = 27
HCI_CONNECTED_ISOCHRONOUS_STREAM_PERIPHERAL_LE_SUPPORTED_FEATURE = 29 HCI_CONNECTED_ISOCHRONOUS_STREAM_CENTRAL_LE_SUPPORTED_FEATURE = 28
HCI_ISOCHRONOUS_BROADCASTER_LE_SUPPORTED_FEATURE = 30 HCI_CONNECTED_ISOCHRONOUS_STREAM_PERIPHERAL_LE_SUPPORTED_FEATURE = 29
HCI_SYNCHRONIZED_RECEIVER_LE_SUPPORTED_FEATURE = 31 HCI_ISOCHRONOUS_BROADCASTER_LE_SUPPORTED_FEATURE = 30
HCI_CONNECTED_ISOCHRONOUS_STREAM_LE_SUPPORTED_FEATURE = 32 HCI_SYNCHRONIZED_RECEIVER_LE_SUPPORTED_FEATURE = 31
HCI_LE_POWER_CONTROL_REQUEST_LE_SUPPORTED_FEATURE = 33 HCI_CONNECTED_ISOCHRONOUS_STREAM_LE_SUPPORTED_FEATURE = 32
HCI_LE_POWER_CONTROL_REQUEST_DUP_LE_SUPPORTED_FEATURE = 34 HCI_LE_POWER_CONTROL_REQUEST_LE_SUPPORTED_FEATURE = 33
HCI_LE_PATH_LOSS_MONITORING_LE_SUPPORTED_FEATURE = 35 HCI_LE_POWER_CONTROL_REQUEST_DUP_LE_SUPPORTED_FEATURE = 34
HCI_PERIODIC_ADVERTISING_ADI_SUPPORT_LE_SUPPORTED_FEATURE = 36 HCI_LE_PATH_LOSS_MONITORING_LE_SUPPORTED_FEATURE = 35
HCI_CONNECTION_SUBRATING_LE_SUPPORTED_FEATURE = 37 HCI_PERIODIC_ADVERTISING_ADI_SUPPORT_LE_SUPPORTED_FEATURE = 36
HCI_CONNECTION_SUBRATING_HOST_SUPPORT_LE_SUPPORTED_FEATURE = 38 HCI_CONNECTION_SUBRATING_LE_SUPPORTED_FEATURE = 37
HCI_CHANNEL_CLASSIFICATION_LE_SUPPORTED_FEATURE = 39 HCI_CONNECTION_SUBRATING_HOST_SUPPORT_LE_SUPPORTED_FEATURE = 38
HCI_CHANNEL_CLASSIFICATION_LE_SUPPORTED_FEATURE = 39
HCI_ADVERTISING_CODING_SELECTION_LE_SUPPORTED_FEATURE = 40
HCI_ADVERTISING_CODING_SELECTION_HOST_SUPPORT_LE_SUPPORTED_FEATURE = 41
HCI_PERIODIC_ADVERTISING_WITH_RESPONSES_ADVERTISER_LE_SUPPORTED_FEATURE = 43
HCI_PERIODIC_ADVERTISING_WITH_RESPONSES_SCANNER_LE_SUPPORTED_FEATURE = 44
HCI_LE_SUPPORTED_FEATURES_NAMES = { HCI_LE_SUPPORTED_FEATURES_NAMES = {
flag: feature_name for (feature_name, flag) in globals().items() flag: feature_name for (feature_name, flag) in globals().items()
if feature_name.startswith('HCI_') and feature_name.endswith('_LE_SUPPORTED_FEATURE') if feature_name.startswith('HCI_') and feature_name.endswith('_LE_SUPPORTED_FEATURE')
} }
# fmt: on # fmt: on
# pylint: enable=line-too-long # pylint: enable=line-too-long
# pylint: disable=invalid-name # pylint: disable=invalid-name
@@ -1377,6 +1405,45 @@ HCI_LE_SUPPORTED_FEATURES_NAMES = {
STATUS_SPEC = {'size': 1, 'mapper': lambda x: HCI_Constant.status_name(x)} STATUS_SPEC = {'size': 1, 'mapper': lambda x: HCI_Constant.status_name(x)}
class CodecID(enum.IntEnum):
# fmt: off
U_LOG = 0x00
A_LOG = 0x01
CVSD = 0x02
TRANSPARENT = 0x03
LINEAR_PCM = 0x04
MSBC = 0x05
LC3 = 0x06
G729A = 0x07
VENDOR_SPECIFIC = 0xFF
@dataclasses.dataclass(frozen=True)
class CodingFormat:
codec_id: CodecID
company_id: int = 0
vendor_specific_codec_id: int = 0
@classmethod
def parse_from_bytes(cls, data: bytes, offset: int):
(codec_id, company_id, vendor_specific_codec_id) = struct.unpack_from(
'<BHH', data, offset
)
return offset + 5, cls(
codec_id=CodecID(codec_id),
company_id=company_id,
vendor_specific_codec_id=vendor_specific_codec_id,
)
def to_bytes(self) -> bytes:
return struct.pack(
'<BHH', self.codec_id, self.company_id, self.vendor_specific_codec_id
)
def __bytes__(self) -> bytes:
return self.to_bytes()
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
class HCI_Constant: class HCI_Constant:
@staticmethod @staticmethod
@@ -1472,6 +1539,12 @@ class HCI_Object:
# The rest of the bytes # The rest of the bytes
field_value = data[offset:] field_value = data[offset:]
return (field_value, len(field_value)) return (field_value, len(field_value))
if field_type == 'v':
# Variable-length bytes field, with 1-byte length at the beginning
field_length = data[offset]
offset += 1
field_value = data[offset : offset + field_length]
return (field_value, field_length + 1)
if field_type == 1: if field_type == 1:
# 8-bit unsigned # 8-bit unsigned
return (data[offset], 1) return (data[offset], 1)
@@ -1576,6 +1649,11 @@ class HCI_Object:
raise ValueError('value too large for *-typed field') raise ValueError('value too large for *-typed field')
else: else:
field_bytes = bytes(field_value) field_bytes = bytes(field_value)
elif field_type == 'v':
# Variable-length bytes field, with 1-byte length at the beginning
field_bytes = bytes(field_value)
field_length = len(field_bytes)
field_bytes = bytes([field_length]) + field_bytes
elif isinstance(field_value, (bytes, bytearray)) or hasattr( elif isinstance(field_value, (bytes, bytearray)) or hasattr(
field_value, 'to_bytes' field_value, 'to_bytes'
): ):
@@ -1883,6 +1961,7 @@ Address.NIL = Address(b"\xff\xff\xff\xff\xff\xff", Address.PUBLIC_DEVICE_ADDRESS
Address.ANY = Address(b"\x00\x00\x00\x00\x00\x00", Address.PUBLIC_DEVICE_ADDRESS) Address.ANY = Address(b"\x00\x00\x00\x00\x00\x00", Address.PUBLIC_DEVICE_ADDRESS)
Address.ANY_RANDOM = Address(b"\x00\x00\x00\x00\x00\x00", Address.RANDOM_DEVICE_ADDRESS) Address.ANY_RANDOM = Address(b"\x00\x00\x00\x00\x00\x00", Address.RANDOM_DEVICE_ADDRESS)
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
class OwnAddressType: class OwnAddressType:
PUBLIC = 0 PUBLIC = 0
@@ -1923,9 +2002,15 @@ class HCI_Packet:
if packet_type == HCI_ACL_DATA_PACKET: if packet_type == HCI_ACL_DATA_PACKET:
return HCI_AclDataPacket.from_bytes(packet) return HCI_AclDataPacket.from_bytes(packet)
if packet_type == HCI_SYNCHRONOUS_DATA_PACKET:
return HCI_SynchronousDataPacket.from_bytes(packet)
if packet_type == HCI_EVENT_PACKET: if packet_type == HCI_EVENT_PACKET:
return HCI_Event.from_bytes(packet) return HCI_Event.from_bytes(packet)
if packet_type == HCI_ISO_DATA_PACKET:
return HCI_IsoDataPacket.from_bytes(packet)
return HCI_CustomPacket(packet) return HCI_CustomPacket(packet)
def __init__(self, name): def __init__(self, name):
@@ -1958,6 +2043,7 @@ class HCI_Command(HCI_Packet):
hci_packet_type = HCI_COMMAND_PACKET hci_packet_type = HCI_COMMAND_PACKET
command_names: Dict[int, str] = {} command_names: Dict[int, str] = {}
command_classes: Dict[int, Type[HCI_Command]] = {} command_classes: Dict[int, Type[HCI_Command]] = {}
op_code: int
@staticmethod @staticmethod
def command(fields=(), return_parameters_fields=()): def command(fields=(), return_parameters_fields=()):
@@ -2043,7 +2129,11 @@ class HCI_Command(HCI_Packet):
return_parameters.fields = cls.return_parameters_fields return_parameters.fields = cls.return_parameters_fields
return return_parameters return return_parameters
def __init__(self, op_code, parameters=None, **kwargs): def __init__(self, op_code=-1, parameters=None, **kwargs):
# Since the legacy implementation relies on an __init__ injector, typing always
# complains that positional argument op_code is not passed, so here sets a
# default value to allow building derived HCI_Command without op_code.
assert op_code != -1
super().__init__(HCI_Command.command_name(op_code)) super().__init__(HCI_Command.command_name(op_code))
if (fields := getattr(self, 'fields', None)) and kwargs: if (fields := getattr(self, 'fields', None)) and kwargs:
HCI_Object.init_from_fields(self, fields, kwargs) HCI_Object.init_from_fields(self, fields, kwargs)
@@ -2291,6 +2381,19 @@ class HCI_Read_Clock_Offset_Command(HCI_Command):
''' '''
# -----------------------------------------------------------------------------
@HCI_Command.command(
fields=[
('bd_addr', Address.parse_address),
('reason', {'size': 1, 'mapper': HCI_Constant.error_name}),
],
)
class HCI_Reject_Synchronous_Connection_Request_Command(HCI_Command):
'''
See Bluetooth spec @ 7.1.28 Reject Synchronous Connection Request Command
'''
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
@HCI_Command.command( @HCI_Command.command(
fields=[ fields=[
@@ -2424,14 +2527,14 @@ class HCI_IO_Capability_Request_Negative_Reply_Command(HCI_Command):
('connection_handle', 2), ('connection_handle', 2),
('transmit_bandwidth', 4), ('transmit_bandwidth', 4),
('receive_bandwidth', 4), ('receive_bandwidth', 4),
('transmit_coding_format', 5), ('transmit_coding_format', CodingFormat.parse_from_bytes),
('receive_coding_format', 5), ('receive_coding_format', CodingFormat.parse_from_bytes),
('transmit_codec_frame_size', 2), ('transmit_codec_frame_size', 2),
('receive_codec_frame_size', 2), ('receive_codec_frame_size', 2),
('input_bandwidth', 4), ('input_bandwidth', 4),
('output_bandwidth', 4), ('output_bandwidth', 4),
('input_coding_format', 5), ('input_coding_format', CodingFormat.parse_from_bytes),
('output_coding_format', 5), ('output_coding_format', CodingFormat.parse_from_bytes),
('input_coded_data_size', 2), ('input_coded_data_size', 2),
('output_coded_data_size', 2), ('output_coded_data_size', 2),
('input_pcm_data_format', 1), ('input_pcm_data_format', 1),
@@ -2452,6 +2555,35 @@ class HCI_Enhanced_Setup_Synchronous_Connection_Command(HCI_Command):
See Bluetooth spec @ 7.1.45 Enhanced Setup Synchronous Connection Command See Bluetooth spec @ 7.1.45 Enhanced Setup Synchronous Connection Command
''' '''
class PcmDataFormat(enum.IntEnum):
NA = 0x00
ONES_COMPLEMENT = 0x01
TWOS_COMPLEMENT = 0x02
SIGN_MAGNITUDE = 0x03
UNSIGNED = 0x04
class DataPath(enum.IntEnum):
HCI = 0x00
PCM = 0x01
class RetransmissionEffort(enum.IntEnum):
NO_RETRANSMISSION = 0x00
OPTIMIZE_FOR_POWER = 0x01
OPTIMIZE_FOR_QUALITY = 0x02
DONT_CARE = 0xFF
class PacketType(enum.IntFlag):
HV1 = 0x0001
HV2 = 0x0002
HV3 = 0x0004
EV3 = 0x0008
EV4 = 0x0010
EV5 = 0x0020
NO_2_EV3 = 0x0040
NO_3_EV3 = 0x0080
NO_2_EV5 = 0x0100
NO_3_EV5 = 0x0200
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
@HCI_Command.command( @HCI_Command.command(
@@ -2459,14 +2591,14 @@ class HCI_Enhanced_Setup_Synchronous_Connection_Command(HCI_Command):
('bd_addr', Address.parse_address), ('bd_addr', Address.parse_address),
('transmit_bandwidth', 4), ('transmit_bandwidth', 4),
('receive_bandwidth', 4), ('receive_bandwidth', 4),
('transmit_coding_format', 5), ('transmit_coding_format', CodingFormat.parse_from_bytes),
('receive_coding_format', 5), ('receive_coding_format', CodingFormat.parse_from_bytes),
('transmit_codec_frame_size', 2), ('transmit_codec_frame_size', 2),
('receive_codec_frame_size', 2), ('receive_codec_frame_size', 2),
('input_bandwidth', 4), ('input_bandwidth', 4),
('output_bandwidth', 4), ('output_bandwidth', 4),
('input_coding_format', 5), ('input_coding_format', CodingFormat.parse_from_bytes),
('output_coding_format', 5), ('output_coding_format', CodingFormat.parse_from_bytes),
('input_coded_data_size', 2), ('input_coded_data_size', 2),
('output_coded_data_size', 2), ('output_coded_data_size', 2),
('input_pcm_data_format', 1), ('input_pcm_data_format', 1),
@@ -3763,8 +3895,10 @@ class HCI_LE_Set_Advertising_Set_Random_Address_Command(HCI_Command):
'advertising_event_properties', 'advertising_event_properties',
{ {
'size': 2, 'size': 2,
'mapper': lambda x: HCI_LE_Set_Extended_Advertising_Parameters_Command.advertising_properties_string( 'mapper': lambda x: str(
x HCI_LE_Set_Extended_Advertising_Parameters_Command.AdvertisingProperties(
x
)
), ),
}, },
), ),
@@ -3774,8 +3908,8 @@ class HCI_LE_Set_Advertising_Set_Random_Address_Command(HCI_Command):
'primary_advertising_channel_map', 'primary_advertising_channel_map',
{ {
'size': 1, 'size': 1,
'mapper': lambda x: HCI_LE_Set_Extended_Advertising_Parameters_Command.channel_map_string( 'mapper': lambda x: str(
x HCI_LE_Set_Extended_Advertising_Parameters_Command.ChannelMap(x)
), ),
}, },
), ),
@@ -3797,38 +3931,33 @@ class HCI_LE_Set_Extended_Advertising_Parameters_Command(HCI_Command):
See Bluetooth spec @ 7.8.53 LE Set Extended Advertising Parameters Command See Bluetooth spec @ 7.8.53 LE Set Extended Advertising Parameters Command
''' '''
CONNECTABLE_ADVERTISING = 0 class AdvertisingProperties(enum.IntFlag):
SCANNABLE_ADVERTISING = 1 CONNECTABLE_ADVERTISING = 1 << 0
DIRECTED_ADVERTISING = 2 SCANNABLE_ADVERTISING = 1 << 1
HIGH_DUTY_CYCLE_DIRECTED_CONNECTABLE_ADVERTISING = 3 DIRECTED_ADVERTISING = 1 << 2
USE_LEGACY_ADVERTISING_PDUS = 4 HIGH_DUTY_CYCLE_DIRECTED_CONNECTABLE_ADVERTISING = 1 << 3
ANONYMOUS_ADVERTISING = 5 USE_LEGACY_ADVERTISING_PDUS = 1 << 4
INCLUDE_TX_POWER = 6 ANONYMOUS_ADVERTISING = 1 << 5
INCLUDE_TX_POWER = 1 << 6
ADVERTISING_PROPERTIES_NAMES = ( def __str__(self) -> str:
'CONNECTABLE_ADVERTISING', return '|'.join(
'SCANNABLE_ADVERTISING', flag.name
'DIRECTED_ADVERTISING', for flag in HCI_LE_Set_Extended_Advertising_Parameters_Command.AdvertisingProperties
'HIGH_DUTY_CYCLE_DIRECTED_CONNECTABLE_ADVERTISING', if self.value & flag.value and flag.name is not None
'USE_LEGACY_ADVERTISING_PDUS', )
'ANONYMOUS_ADVERTISING',
'INCLUDE_TX_POWER',
)
CHANNEL_37 = 0 class ChannelMap(enum.IntFlag):
CHANNEL_38 = 1 CHANNEL_37 = 1 << 0
CHANNEL_39 = 2 CHANNEL_38 = 1 << 1
CHANNEL_39 = 1 << 2
CHANNEL_NAMES = ('37', '38', '39') def __str__(self) -> str:
return '|'.join(
@classmethod flag.name
def advertising_properties_string(cls, properties): for flag in HCI_LE_Set_Extended_Advertising_Parameters_Command.ChannelMap
# pylint: disable=line-too-long if self.value & flag.value and flag.name is not None
return f'[{",".join(bit_flags_to_strings(properties, cls.ADVERTISING_PROPERTIES_NAMES))}]' )
@classmethod
def channel_map_string(cls, channel_map):
return f'[{",".join(bit_flags_to_strings(channel_map, cls.CHANNEL_NAMES))}]'
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
@@ -3840,9 +3969,9 @@ class HCI_LE_Set_Extended_Advertising_Parameters_Command(HCI_Command):
'operation', 'operation',
{ {
'size': 1, 'size': 1,
'mapper': lambda x: HCI_LE_Set_Extended_Advertising_Data_Command.operation_name( 'mapper': lambda x: HCI_LE_Set_Extended_Advertising_Data_Command.Operation(
x x
), ).name,
}, },
), ),
('fragment_preference', 1), ('fragment_preference', 1),
@@ -3860,23 +3989,12 @@ class HCI_LE_Set_Extended_Advertising_Data_Command(HCI_Command):
See Bluetooth spec @ 7.8.54 LE Set Extended Advertising Data Command See Bluetooth spec @ 7.8.54 LE Set Extended Advertising Data Command
''' '''
INTERMEDIATE_FRAGMENT = 0x00 class Operation(enum.IntEnum):
FIRST_FRAGMENT = 0x01 INTERMEDIATE_FRAGMENT = 0x00
LAST_FRAGMENT = 0x02 FIRST_FRAGMENT = 0x01
COMPLETE_DATA = 0x03 LAST_FRAGMENT = 0x02
UNCHANGED_DATA = 0x04 COMPLETE_DATA = 0x03
UNCHANGED_DATA = 0x04
OPERATION_NAMES = {
INTERMEDIATE_FRAGMENT: 'INTERMEDIATE_FRAGMENT',
FIRST_FRAGMENT: 'FIRST_FRAGMENT',
LAST_FRAGMENT: 'LAST_FRAGMENT',
COMPLETE_DATA: 'COMPLETE_DATA',
UNCHANGED_DATA: 'UNCHANGED_DATA',
}
@classmethod
def operation_name(cls, operation):
return name_or_number(cls.OPERATION_NAMES, operation)
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
@@ -3888,9 +4006,9 @@ class HCI_LE_Set_Extended_Advertising_Data_Command(HCI_Command):
'operation', 'operation',
{ {
'size': 1, 'size': 1,
'mapper': lambda x: HCI_LE_Set_Extended_Advertising_Data_Command.operation_name( 'mapper': lambda x: HCI_LE_Set_Extended_Advertising_Data_Command.Operation(
x x
), ).name,
}, },
), ),
('fragment_preference', 1), ('fragment_preference', 1),
@@ -3908,22 +4026,6 @@ class HCI_LE_Set_Extended_Scan_Response_Data_Command(HCI_Command):
See Bluetooth spec @ 7.8.55 LE Set Extended Scan Response Data Command See Bluetooth spec @ 7.8.55 LE Set Extended Scan Response Data Command
''' '''
INTERMEDIATE_FRAGMENT = 0x00
FIRST_FRAGMENT = 0x01
LAST_FRAGMENT = 0x02
COMPLETE_DATA = 0x03
OPERATION_NAMES = {
INTERMEDIATE_FRAGMENT: 'INTERMEDIATE_FRAGMENT',
FIRST_FRAGMENT: 'FIRST_FRAGMENT',
LAST_FRAGMENT: 'LAST_FRAGMENT',
COMPLETE_DATA: 'COMPLETE_DATA',
}
@classmethod
def operation_name(cls, operation):
return name_or_number(cls.OPERATION_NAMES, operation)
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
@HCI_Command.command( @HCI_Command.command(
@@ -4321,6 +4423,162 @@ class HCI_LE_Set_Host_Feature_Command(HCI_Command):
''' '''
# -----------------------------------------------------------------------------
@HCI_Command.command(
fields=[
('cig_id', 1),
('sdu_interval_c_to_p', 3),
('sdu_interval_p_to_c', 3),
('worst_case_sca', 1),
('packing', 1),
('framing', 1),
('max_transport_latency_c_to_p', 2),
('max_transport_latency_p_to_c', 2),
[
('cis_id', 1),
('max_sdu_c_to_p', 2),
('max_sdu_p_to_c', 2),
('phy_c_to_p', 1),
('phy_p_to_c', 1),
('rtn_c_to_p', 1),
('rtn_p_to_c', 1),
],
],
return_parameters_fields=[
('status', STATUS_SPEC),
('cig_id', 1),
[('connection_handle', 2)],
],
)
class HCI_LE_Set_CIG_Parameters_Command(HCI_Command):
'''
See Bluetooth spec @ 7.8.97 LE Set CIG Parameters Command
'''
cig_id: int
sdu_interval_c_to_p: int
sdu_interval_p_to_c: int
worst_case_sca: int
packing: int
framing: int
max_transport_latency_c_to_p: int
max_transport_latency_p_to_c: int
cis_id: List[int]
max_sdu_c_to_p: List[int]
max_sdu_p_to_c: List[int]
phy_c_to_p: List[int]
phy_p_to_c: List[int]
rtn_c_to_p: List[int]
rtn_p_to_c: List[int]
# -----------------------------------------------------------------------------
@HCI_Command.command(
fields=[
[
('cis_connection_handle', 2),
('acl_connection_handle', 2),
],
],
)
class HCI_LE_Create_CIS_Command(HCI_Command):
'''
See Bluetooth spec @ 7.8.99 LE Create CIS command
'''
cis_connection_handle: List[int]
acl_connection_handle: List[int]
# -----------------------------------------------------------------------------
@HCI_Command.command(
fields=[('cig_id', 1)],
return_parameters_fields=[('status', STATUS_SPEC), ('cig_id', 1)],
)
class HCI_LE_Remove_CIG_Command(HCI_Command):
'''
See Bluetooth spec @ 7.8.100 LE Remove CIG command
'''
cig_id: int
# -----------------------------------------------------------------------------
@HCI_Command.command(
fields=[('connection_handle', 2)],
)
class HCI_LE_Accept_CIS_Request_Command(HCI_Command):
'''
See Bluetooth spec @ 7.8.101 LE Accept CIS Request command
'''
connection_handle: int
# -----------------------------------------------------------------------------
@HCI_Command.command(
fields=[
('connection_handle', 2),
('reason', {'size': 1, 'mapper': HCI_Constant.error_name}),
],
)
class HCI_LE_Reject_CIS_Request_Command(HCI_Command):
'''
See Bluetooth spec @ 7.8.102 LE Reject CIS Request command
'''
connection_handle: int
reason: int
# -----------------------------------------------------------------------------
@HCI_Command.command(
fields=[
('connection_handle', 2),
('data_path_direction', 1),
('data_path_id', 1),
('codec_id', CodingFormat.parse_from_bytes),
('controller_delay', 3),
('codec_configuration', 'v'),
],
return_parameters_fields=[
('status', STATUS_SPEC),
('connection_handle', 2),
],
)
class HCI_LE_Setup_ISO_Data_Path_Command(HCI_Command):
'''
See Bluetooth spec @ 7.8.109 LE Setup ISO Data Path command
'''
connection_handle: int
data_path_direction: int
data_path_id: int
codec_id: CodingFormat
controller_delay: int
codec_configuration: bytes
# -----------------------------------------------------------------------------
@HCI_Command.command(
fields=[
('connection_handle', 2),
('data_path_direction', 1),
],
return_parameters_fields=[
('status', STATUS_SPEC),
('connection_handle', 2),
],
)
class HCI_LE_Remove_ISO_Data_Path_Command(HCI_Command):
'''
See Bluetooth spec @ 7.8.110 LE Remove ISO Data Path command
'''
connection_handle: int
data_path_direction: int
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
# HCI Events # HCI Events
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
@@ -4397,7 +4655,7 @@ class HCI_Event(HCI_Packet):
if len(parameters) != length: if len(parameters) != length:
raise ValueError('invalid packet length') raise ValueError('invalid packet length')
cls: Type[HCI_Event | HCI_LE_Meta_Event] | None cls: Any
if event_code == HCI_LE_META_EVENT: if event_code == HCI_LE_META_EVENT:
# We do this dispatch here and not in the subclass in order to avoid call # We do this dispatch here and not in the subclass in order to avoid call
# loops # loops
@@ -4940,6 +5198,48 @@ class HCI_LE_Channel_Selection_Algorithm_Event(HCI_LE_Meta_Event):
''' '''
# -----------------------------------------------------------------------------
@HCI_LE_Meta_Event.event(
[
('status', STATUS_SPEC),
('connection_handle', 2),
('cig_sync_delay', 3),
('cis_sync_delay', 3),
('transport_latency_c_to_p', 3),
('transport_latency_p_to_c', 3),
('phy_c_to_p', 1),
('phy_p_to_c', 1),
('nse', 1),
('bn_c_to_p', 1),
('bn_p_to_c', 1),
('ft_c_to_p', 1),
('ft_p_to_c', 1),
('max_pdu_c_to_p', 2),
('max_pdu_p_to_c', 2),
('iso_interval', 2),
]
)
class HCI_LE_CIS_Established_Event(HCI_LE_Meta_Event):
'''
See Bluetooth spec @ 7.7.65.25 LE CIS Established Event
'''
# -----------------------------------------------------------------------------
@HCI_LE_Meta_Event.event(
[
('acl_connection_handle', 2),
('cis_connection_handle', 2),
('cig_id', 1),
('cis_id', 1),
]
)
class HCI_LE_CIS_Request_Event(HCI_LE_Meta_Event):
'''
See Bluetooth spec @ 7.7.65.26 LE CIS Request Event
'''
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
@HCI_Event.event([('status', STATUS_SPEC)]) @HCI_Event.event([('status', STATUS_SPEC)])
class HCI_Inquiry_Complete_Event(HCI_Event): class HCI_Inquiry_Complete_Event(HCI_Event):
@@ -5066,6 +5366,10 @@ class HCI_Disconnection_Complete_Event(HCI_Event):
See Bluetooth spec @ 7.7.5 Disconnection Complete Event See Bluetooth spec @ 7.7.5 Disconnection Complete Event
''' '''
status: int
connection_handle: int
reason: int
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
@HCI_Event.event([('status', STATUS_SPEC), ('connection_handle', 2)]) @HCI_Event.event([('status', STATUS_SPEC), ('connection_handle', 2)])
@@ -5736,6 +6040,168 @@ class HCI_AclDataPacket(HCI_Packet):
) )
# -----------------------------------------------------------------------------
class HCI_SynchronousDataPacket(HCI_Packet):
'''
See Bluetooth spec @ 5.4.3 HCI SCO Data Packets
'''
hci_packet_type = HCI_SYNCHRONOUS_DATA_PACKET
@staticmethod
def from_bytes(packet: bytes) -> HCI_SynchronousDataPacket:
# Read the header
h, data_total_length = struct.unpack_from('<HB', packet, 1)
connection_handle = h & 0xFFF
packet_status = (h >> 12) & 0b11
data = packet[4:]
if len(data) != data_total_length:
raise ValueError(
f'invalid packet length {len(data)} != {data_total_length}'
)
return HCI_SynchronousDataPacket(
connection_handle, packet_status, data_total_length, data
)
def to_bytes(self) -> bytes:
h = (self.packet_status << 12) | self.connection_handle
return (
struct.pack('<BHB', HCI_SYNCHRONOUS_DATA_PACKET, h, self.data_total_length)
+ self.data
)
def __init__(
self,
connection_handle: int,
packet_status: int,
data_total_length: int,
data: bytes,
) -> None:
self.connection_handle = connection_handle
self.packet_status = packet_status
self.data_total_length = data_total_length
self.data = data
def __bytes__(self) -> bytes:
return self.to_bytes()
def __str__(self) -> str:
return (
f'{color("SCO", "blue")}: '
f'handle=0x{self.connection_handle:04x}, '
f'ps={self.packet_status}, '
f'data_total_length={self.data_total_length}, '
f'data={self.data.hex()}'
)
# -----------------------------------------------------------------------------
class HCI_IsoDataPacket(HCI_Packet):
'''
See Bluetooth spec @ 5.4.5 HCI ISO Data Packets
'''
hci_packet_type = HCI_ISO_DATA_PACKET
@staticmethod
def from_bytes(packet: bytes) -> HCI_IsoDataPacket:
time_stamp: Optional[int] = None
packet_sequence_number: Optional[int] = None
iso_sdu_length: Optional[int] = None
packet_status_flag: Optional[int] = None
pos = 1
pdu_info, data_total_length = struct.unpack_from('<HH', packet, pos)
connection_handle = pdu_info & 0xFFF
pb_flag = (pdu_info >> 12) & 0b11
ts_flag = (pdu_info >> 14) & 0b01
pos += 4
# pb_flag in (0b00, 0b10) but faster
should_include_sdu_info = not (pb_flag & 0b01)
if ts_flag:
if not should_include_sdu_info:
logger.warn(f'Timestamp included when pb_flag={bin(pb_flag)}')
time_stamp, *_ = struct.unpack_from('<I', packet, pos)
pos += 4
if should_include_sdu_info:
packet_sequence_number, sdu_info = struct.unpack_from('<HH', packet, pos)
iso_sdu_length = sdu_info & 0xFFF
packet_status_flag = sdu_info >> 14
pos += 4
iso_sdu_fragment = packet[pos:]
return HCI_IsoDataPacket(
connection_handle=connection_handle,
pb_flag=pb_flag,
ts_flag=ts_flag,
data_total_length=data_total_length,
time_stamp=time_stamp,
packet_sequence_number=packet_sequence_number,
iso_sdu_length=iso_sdu_length,
packet_status_flag=packet_status_flag,
iso_sdu_fragment=iso_sdu_fragment,
)
def __init__(
self,
connection_handle: int,
pb_flag: int,
ts_flag: int,
data_total_length: int,
time_stamp: Optional[int],
packet_sequence_number: Optional[int],
iso_sdu_length: Optional[int],
packet_status_flag: Optional[int],
iso_sdu_fragment: bytes,
) -> None:
self.connection_handle = connection_handle
self.pb_flag = pb_flag
self.ts_flag = ts_flag
self.data_total_length = data_total_length
self.time_stamp = time_stamp
self.packet_sequence_number = packet_sequence_number
self.iso_sdu_length = iso_sdu_length
self.packet_status_flag = packet_status_flag
self.iso_sdu_fragment = iso_sdu_fragment
def __bytes__(self) -> bytes:
return self.to_bytes()
def to_bytes(self) -> bytes:
fmt = '<BHH'
args = [
HCI_ISO_DATA_PACKET,
self.ts_flag << 14 | self.pb_flag << 12 | self.connection_handle,
self.data_total_length,
]
if self.time_stamp is not None:
fmt += 'I'
args.append(self.time_stamp)
if (
self.packet_sequence_number is not None
and self.iso_sdu_length is not None
and self.packet_status_flag is not None
):
fmt += 'HH'
args += [
self.packet_sequence_number,
self.iso_sdu_length | self.packet_status_flag << 14,
]
return struct.pack(fmt, *args) + self.iso_sdu_fragment
def __str__(self) -> str:
return (
f'{color("ISO", "blue")}: '
f'handle=0x{self.connection_handle:04x}, '
f'ps={self.packet_status_flag}, '
f'data_total_length={self.data_total_length}, '
f'sdu={self.iso_sdu_fragment.hex()}'
)
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
class HCI_AclDataPacketAssembler: class HCI_AclDataPacketAssembler:
current_data: Optional[bytes] current_data: Optional[bytes]

View File

@@ -15,30 +15,39 @@
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
# Imports # Imports
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
from __future__ import annotations
from collections.abc import Callable, MutableMapping
from typing import cast, Any
import logging import logging
from .colors import color from bumble import avdtp
from .att import ATT_CID, ATT_PDU from bumble.colors import color
from .smp import SMP_CID, SMP_Command from bumble.att import ATT_CID, ATT_PDU
from .core import name_or_number from bumble.smp import SMP_CID, SMP_Command
from .l2cap import ( from bumble.core import name_or_number
from bumble.l2cap import (
L2CAP_PDU, L2CAP_PDU,
L2CAP_CONNECTION_REQUEST, L2CAP_CONNECTION_REQUEST,
L2CAP_CONNECTION_RESPONSE, L2CAP_CONNECTION_RESPONSE,
L2CAP_SIGNALING_CID, L2CAP_SIGNALING_CID,
L2CAP_LE_SIGNALING_CID, L2CAP_LE_SIGNALING_CID,
L2CAP_Control_Frame, L2CAP_Control_Frame,
L2CAP_Connection_Request,
L2CAP_Connection_Response, L2CAP_Connection_Response,
) )
from .hci import ( from bumble.hci import (
HCI_EVENT_PACKET, HCI_EVENT_PACKET,
HCI_ACL_DATA_PACKET, HCI_ACL_DATA_PACKET,
HCI_DISCONNECTION_COMPLETE_EVENT, HCI_DISCONNECTION_COMPLETE_EVENT,
HCI_AclDataPacketAssembler, HCI_AclDataPacketAssembler,
HCI_Packet,
HCI_Event,
HCI_AclDataPacket,
HCI_Disconnection_Complete_Event,
) )
from .rfcomm import RFCOMM_Frame, RFCOMM_PSM from bumble.rfcomm import RFCOMM_Frame, RFCOMM_PSM
from .sdp import SDP_PDU, SDP_PSM from bumble.sdp import SDP_PDU, SDP_PSM
from .avdtp import MessageAssembler as AVDTP_MessageAssembler, AVDTP_PSM
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
# Logging # Logging
@@ -50,23 +59,25 @@ logger = logging.getLogger(__name__)
PSM_NAMES = { PSM_NAMES = {
RFCOMM_PSM: 'RFCOMM', RFCOMM_PSM: 'RFCOMM',
SDP_PSM: 'SDP', SDP_PSM: 'SDP',
AVDTP_PSM: 'AVDTP' avdtp.AVDTP_PSM: 'AVDTP',
# TODO: add more PSM values
} }
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
class PacketTracer: class PacketTracer:
class AclStream: class AclStream:
def __init__(self, analyzer): psms: MutableMapping[int, int]
peer: PacketTracer.AclStream
avdtp_assemblers: MutableMapping[int, avdtp.MessageAssembler]
def __init__(self, analyzer: PacketTracer.Analyzer) -> None:
self.analyzer = analyzer self.analyzer = analyzer
self.packet_assembler = HCI_AclDataPacketAssembler(self.on_acl_pdu) self.packet_assembler = HCI_AclDataPacketAssembler(self.on_acl_pdu)
self.avdtp_assemblers = {} # AVDTP assemblers, by source_cid self.avdtp_assemblers = {} # AVDTP assemblers, by source_cid
self.psms = {} # PSM, by source_cid self.psms = {} # PSM, by source_cid
self.peer = None # ACL stream in the other direction
# pylint: disable=too-many-nested-blocks # pylint: disable=too-many-nested-blocks
def on_acl_pdu(self, pdu): def on_acl_pdu(self, pdu: bytes) -> None:
l2cap_pdu = L2CAP_PDU.from_bytes(pdu) l2cap_pdu = L2CAP_PDU.from_bytes(pdu)
if l2cap_pdu.cid == ATT_CID: if l2cap_pdu.cid == ATT_CID:
@@ -81,26 +92,30 @@ class PacketTracer:
# Check if this signals a new channel # Check if this signals a new channel
if control_frame.code == L2CAP_CONNECTION_REQUEST: if control_frame.code == L2CAP_CONNECTION_REQUEST:
self.psms[control_frame.source_cid] = control_frame.psm connection_request = cast(L2CAP_Connection_Request, control_frame)
self.psms[connection_request.source_cid] = connection_request.psm
elif control_frame.code == L2CAP_CONNECTION_RESPONSE: elif control_frame.code == L2CAP_CONNECTION_RESPONSE:
connection_response = cast(L2CAP_Connection_Response, control_frame)
if ( if (
control_frame.result connection_response.result
== L2CAP_Connection_Response.CONNECTION_SUCCESSFUL == L2CAP_Connection_Response.CONNECTION_SUCCESSFUL
): ):
if self.peer: if self.peer:
if psm := self.peer.psms.get(control_frame.source_cid): if psm := self.peer.psms.get(
connection_response.source_cid
):
# Found a pending connection # Found a pending connection
self.psms[control_frame.destination_cid] = psm self.psms[connection_response.destination_cid] = psm
# For AVDTP connections, create a packet assembler for # For AVDTP connections, create a packet assembler for
# each direction # each direction
if psm == AVDTP_PSM: if psm == avdtp.AVDTP_PSM:
self.avdtp_assemblers[ self.avdtp_assemblers[
control_frame.source_cid connection_response.source_cid
] = AVDTP_MessageAssembler(self.on_avdtp_message) ] = avdtp.MessageAssembler(self.on_avdtp_message)
self.peer.avdtp_assemblers[ self.peer.avdtp_assemblers[
control_frame.destination_cid connection_response.destination_cid
] = AVDTP_MessageAssembler( ] = avdtp.MessageAssembler(
self.peer.on_avdtp_message self.peer.on_avdtp_message
) )
@@ -113,7 +128,7 @@ class PacketTracer:
elif psm == RFCOMM_PSM: elif psm == RFCOMM_PSM:
rfcomm_frame = RFCOMM_Frame.from_bytes(l2cap_pdu.payload) rfcomm_frame = RFCOMM_Frame.from_bytes(l2cap_pdu.payload)
self.analyzer.emit(rfcomm_frame) self.analyzer.emit(rfcomm_frame)
elif psm == AVDTP_PSM: elif psm == avdtp.AVDTP_PSM:
self.analyzer.emit( self.analyzer.emit(
f'{color("L2CAP", "green")} [CID={l2cap_pdu.cid}, ' f'{color("L2CAP", "green")} [CID={l2cap_pdu.cid}, '
f'PSM=AVDTP]: {l2cap_pdu.payload.hex()}' f'PSM=AVDTP]: {l2cap_pdu.payload.hex()}'
@@ -130,22 +145,26 @@ class PacketTracer:
else: else:
self.analyzer.emit(l2cap_pdu) self.analyzer.emit(l2cap_pdu)
def on_avdtp_message(self, transaction_label, message): def on_avdtp_message(
self, transaction_label: int, message: avdtp.Message
) -> None:
self.analyzer.emit( self.analyzer.emit(
f'{color("AVDTP", "green")} [{transaction_label}] {message}' f'{color("AVDTP", "green")} [{transaction_label}] {message}'
) )
def feed_packet(self, packet): def feed_packet(self, packet: HCI_AclDataPacket) -> None:
self.packet_assembler.feed_packet(packet) self.packet_assembler.feed_packet(packet)
class Analyzer: class Analyzer:
def __init__(self, label, emit_message): acl_streams: MutableMapping[int, PacketTracer.AclStream]
peer: PacketTracer.Analyzer
def __init__(self, label: str, emit_message: Callable[..., None]) -> None:
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.peer = None # Analyzer in the other direction
def start_acl_stream(self, connection_handle): def start_acl_stream(self, connection_handle: int) -> PacketTracer.AclStream:
logger.info( logger.info(
f'[{self.label}] +++ Creating ACL stream for connection ' f'[{self.label}] +++ Creating ACL stream for connection '
f'0x{connection_handle:04X}' f'0x{connection_handle:04X}'
@@ -160,7 +179,7 @@ class PacketTracer:
return stream return stream
def end_acl_stream(self, connection_handle): def end_acl_stream(self, connection_handle: int) -> None:
if connection_handle in self.acl_streams: if connection_handle in self.acl_streams:
logger.info( logger.info(
f'[{self.label}] --- Removing ACL stream for connection ' f'[{self.label}] --- Removing ACL stream for connection '
@@ -171,23 +190,29 @@ class PacketTracer:
# Let the other forwarder know so it can cleanup its stream as well # Let the other forwarder know so it can cleanup its stream as well
self.peer.end_acl_stream(connection_handle) self.peer.end_acl_stream(connection_handle)
def on_packet(self, packet): def on_packet(self, packet: HCI_Packet) -> None:
self.emit(packet) self.emit(packet)
if packet.hci_packet_type == HCI_ACL_DATA_PACKET: if packet.hci_packet_type == HCI_ACL_DATA_PACKET:
acl_packet = cast(HCI_AclDataPacket, packet)
# Look for an existing stream for this handle, create one if it is the # Look for an existing stream for this handle, create one if it is the
# first ACL packet for that connection handle # first ACL packet for that connection handle
if (stream := self.acl_streams.get(packet.connection_handle)) is None: if (
stream = self.start_acl_stream(packet.connection_handle) stream := self.acl_streams.get(acl_packet.connection_handle)
stream.feed_packet(packet) ) is None:
stream = self.start_acl_stream(acl_packet.connection_handle)
stream.feed_packet(acl_packet)
elif packet.hci_packet_type == HCI_EVENT_PACKET: elif packet.hci_packet_type == HCI_EVENT_PACKET:
if packet.event_code == HCI_DISCONNECTION_COMPLETE_EVENT: event_packet = cast(HCI_Event, packet)
self.end_acl_stream(packet.connection_handle) if event_packet.event_code == HCI_DISCONNECTION_COMPLETE_EVENT:
self.end_acl_stream(
cast(HCI_Disconnection_Complete_Event, packet).connection_handle
)
def emit(self, message): def emit(self, message: Any) -> None:
self.emit_message(f'[{self.label}] {message}') self.emit_message(f'[{self.label}] {message}')
def trace(self, packet, direction=0): def trace(self, packet: HCI_Packet, direction: int = 0) -> None:
if direction == 0: if direction == 0:
self.host_to_controller_analyzer.on_packet(packet) self.host_to_controller_analyzer.on_packet(packet)
else: else:
@@ -195,10 +220,10 @@ class PacketTracer:
def __init__( def __init__(
self, self,
host_to_controller_label=color('HOST->CONTROLLER', 'blue'), host_to_controller_label: str = color('HOST->CONTROLLER', 'blue'),
controller_to_host_label=color('CONTROLLER->HOST', 'cyan'), controller_to_host_label: str = color('CONTROLLER->HOST', 'cyan'),
emit_message=logger.info, emit_message: Callable[..., None] = logger.info,
): ) -> None:
self.host_to_controller_analyzer = PacketTracer.Analyzer( self.host_to_controller_analyzer = PacketTracer.Analyzer(
host_to_controller_label, emit_message host_to_controller_label, emit_message
) )

View File

@@ -22,7 +22,7 @@ import dataclasses
import enum import enum
import traceback import traceback
import warnings import warnings
from typing import Dict, List, Union, Set, TYPE_CHECKING from typing import Dict, List, Union, Set, Any, TYPE_CHECKING
from . import at from . import at
from . import rfcomm from . import rfcomm
@@ -35,6 +35,11 @@ from bumble.core import (
BT_L2CAP_PROTOCOL_ID, BT_L2CAP_PROTOCOL_ID,
BT_RFCOMM_PROTOCOL_ID, BT_RFCOMM_PROTOCOL_ID,
) )
from bumble.hci import (
HCI_Enhanced_Setup_Synchronous_Connection_Command,
CodingFormat,
CodecID,
)
from bumble.sdp import ( from bumble.sdp import (
DataElement, DataElement,
ServiceAttribute, ServiceAttribute,
@@ -65,6 +70,7 @@ class HfpProtocolError(ProtocolError):
# Protocol Support # Protocol Support
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
class HfpProtocol: class HfpProtocol:
dlc: rfcomm.DLC dlc: rfcomm.DLC
@@ -819,3 +825,175 @@ def sdp_records(
DataElement.unsigned_integer_16(hf_supported_features), DataElement.unsigned_integer_16(hf_supported_features),
), ),
] ]
# -----------------------------------------------------------------------------
# ESCO Codec Default Parameters
# -----------------------------------------------------------------------------
# Hands-Free Profile v1.8, 5.7 Codec Interoperability Requirements
class DefaultCodecParameters(enum.IntEnum):
SCO_CVSD_D0 = enum.auto()
SCO_CVSD_D1 = enum.auto()
ESCO_CVSD_S1 = enum.auto()
ESCO_CVSD_S2 = enum.auto()
ESCO_CVSD_S3 = enum.auto()
ESCO_CVSD_S4 = enum.auto()
ESCO_MSBC_T1 = enum.auto()
ESCO_MSBC_T2 = enum.auto()
@dataclasses.dataclass
class EscoParameters:
# Codec specific
transmit_coding_format: CodingFormat
receive_coding_format: CodingFormat
packet_type: HCI_Enhanced_Setup_Synchronous_Connection_Command.PacketType
retransmission_effort: HCI_Enhanced_Setup_Synchronous_Connection_Command.RetransmissionEffort
max_latency: int
# Common
input_coding_format: CodingFormat = CodingFormat(CodecID.LINEAR_PCM)
output_coding_format: CodingFormat = CodingFormat(CodecID.LINEAR_PCM)
input_coded_data_size: int = 16
output_coded_data_size: int = 16
input_pcm_data_format: HCI_Enhanced_Setup_Synchronous_Connection_Command.PcmDataFormat = (
HCI_Enhanced_Setup_Synchronous_Connection_Command.PcmDataFormat.TWOS_COMPLEMENT
)
output_pcm_data_format: HCI_Enhanced_Setup_Synchronous_Connection_Command.PcmDataFormat = (
HCI_Enhanced_Setup_Synchronous_Connection_Command.PcmDataFormat.TWOS_COMPLEMENT
)
input_pcm_sample_payload_msb_position: int = 0
output_pcm_sample_payload_msb_position: int = 0
input_data_path: HCI_Enhanced_Setup_Synchronous_Connection_Command.DataPath = (
HCI_Enhanced_Setup_Synchronous_Connection_Command.DataPath.HCI
)
output_data_path: HCI_Enhanced_Setup_Synchronous_Connection_Command.DataPath = (
HCI_Enhanced_Setup_Synchronous_Connection_Command.DataPath.HCI
)
input_transport_unit_size: int = 0
output_transport_unit_size: int = 0
input_bandwidth: int = 16000
output_bandwidth: int = 16000
transmit_bandwidth: int = 8000
receive_bandwidth: int = 8000
transmit_codec_frame_size: int = 60
receive_codec_frame_size: int = 60
def asdict(self) -> Dict[str, Any]:
# dataclasses.asdict() will recursively deep-copy the entire object,
# which is expensive and breaks CodingFormat object, so let it simply copy here.
return self.__dict__
_ESCO_PARAMETERS_CVSD_D0 = EscoParameters(
transmit_coding_format=CodingFormat(CodecID.CVSD),
receive_coding_format=CodingFormat(CodecID.CVSD),
max_latency=0xFFFF,
packet_type=HCI_Enhanced_Setup_Synchronous_Connection_Command.PacketType.HV1,
retransmission_effort=HCI_Enhanced_Setup_Synchronous_Connection_Command.RetransmissionEffort.NO_RETRANSMISSION,
)
_ESCO_PARAMETERS_CVSD_D1 = EscoParameters(
transmit_coding_format=CodingFormat(CodecID.CVSD),
receive_coding_format=CodingFormat(CodecID.CVSD),
max_latency=0xFFFF,
packet_type=HCI_Enhanced_Setup_Synchronous_Connection_Command.PacketType.HV3,
retransmission_effort=HCI_Enhanced_Setup_Synchronous_Connection_Command.RetransmissionEffort.NO_RETRANSMISSION,
)
_ESCO_PARAMETERS_CVSD_S1 = EscoParameters(
transmit_coding_format=CodingFormat(CodecID.CVSD),
receive_coding_format=CodingFormat(CodecID.CVSD),
max_latency=0x0007,
packet_type=(
HCI_Enhanced_Setup_Synchronous_Connection_Command.PacketType.EV3
| HCI_Enhanced_Setup_Synchronous_Connection_Command.PacketType.NO_2_EV3
| HCI_Enhanced_Setup_Synchronous_Connection_Command.PacketType.NO_3_EV3
| HCI_Enhanced_Setup_Synchronous_Connection_Command.PacketType.NO_2_EV5
| HCI_Enhanced_Setup_Synchronous_Connection_Command.PacketType.NO_3_EV5
),
retransmission_effort=HCI_Enhanced_Setup_Synchronous_Connection_Command.RetransmissionEffort.OPTIMIZE_FOR_POWER,
)
_ESCO_PARAMETERS_CVSD_S2 = EscoParameters(
transmit_coding_format=CodingFormat(CodecID.CVSD),
receive_coding_format=CodingFormat(CodecID.CVSD),
max_latency=0x0007,
packet_type=(
HCI_Enhanced_Setup_Synchronous_Connection_Command.PacketType.EV3
| HCI_Enhanced_Setup_Synchronous_Connection_Command.PacketType.NO_3_EV3
| HCI_Enhanced_Setup_Synchronous_Connection_Command.PacketType.NO_2_EV5
| HCI_Enhanced_Setup_Synchronous_Connection_Command.PacketType.NO_3_EV5
),
retransmission_effort=HCI_Enhanced_Setup_Synchronous_Connection_Command.RetransmissionEffort.OPTIMIZE_FOR_POWER,
)
_ESCO_PARAMETERS_CVSD_S3 = EscoParameters(
transmit_coding_format=CodingFormat(CodecID.CVSD),
receive_coding_format=CodingFormat(CodecID.CVSD),
max_latency=0x000A,
packet_type=(
HCI_Enhanced_Setup_Synchronous_Connection_Command.PacketType.EV3
| HCI_Enhanced_Setup_Synchronous_Connection_Command.PacketType.NO_3_EV3
| HCI_Enhanced_Setup_Synchronous_Connection_Command.PacketType.NO_2_EV5
| HCI_Enhanced_Setup_Synchronous_Connection_Command.PacketType.NO_3_EV5
),
retransmission_effort=HCI_Enhanced_Setup_Synchronous_Connection_Command.RetransmissionEffort.OPTIMIZE_FOR_POWER,
)
_ESCO_PARAMETERS_CVSD_S4 = EscoParameters(
transmit_coding_format=CodingFormat(CodecID.CVSD),
receive_coding_format=CodingFormat(CodecID.CVSD),
max_latency=0x000C,
packet_type=(
HCI_Enhanced_Setup_Synchronous_Connection_Command.PacketType.EV3
| HCI_Enhanced_Setup_Synchronous_Connection_Command.PacketType.NO_3_EV3
| HCI_Enhanced_Setup_Synchronous_Connection_Command.PacketType.NO_2_EV5
| HCI_Enhanced_Setup_Synchronous_Connection_Command.PacketType.NO_3_EV5
),
retransmission_effort=HCI_Enhanced_Setup_Synchronous_Connection_Command.RetransmissionEffort.OPTIMIZE_FOR_QUALITY,
)
_ESCO_PARAMETERS_MSBC_T1 = EscoParameters(
transmit_coding_format=CodingFormat(CodecID.MSBC),
receive_coding_format=CodingFormat(CodecID.MSBC),
max_latency=0x0008,
packet_type=(
HCI_Enhanced_Setup_Synchronous_Connection_Command.PacketType.EV3
| HCI_Enhanced_Setup_Synchronous_Connection_Command.PacketType.NO_3_EV3
| HCI_Enhanced_Setup_Synchronous_Connection_Command.PacketType.NO_2_EV5
| HCI_Enhanced_Setup_Synchronous_Connection_Command.PacketType.NO_3_EV5
),
input_bandwidth=32000,
output_bandwidth=32000,
retransmission_effort=HCI_Enhanced_Setup_Synchronous_Connection_Command.RetransmissionEffort.OPTIMIZE_FOR_QUALITY,
)
_ESCO_PARAMETERS_MSBC_T2 = EscoParameters(
transmit_coding_format=CodingFormat(CodecID.MSBC),
receive_coding_format=CodingFormat(CodecID.MSBC),
max_latency=0x000D,
packet_type=(
HCI_Enhanced_Setup_Synchronous_Connection_Command.PacketType.EV3
| HCI_Enhanced_Setup_Synchronous_Connection_Command.PacketType.NO_2_EV3
| HCI_Enhanced_Setup_Synchronous_Connection_Command.PacketType.NO_3_EV3
| HCI_Enhanced_Setup_Synchronous_Connection_Command.PacketType.NO_2_EV5
| HCI_Enhanced_Setup_Synchronous_Connection_Command.PacketType.NO_3_EV5
),
input_bandwidth=32000,
output_bandwidth=32000,
retransmission_effort=HCI_Enhanced_Setup_Synchronous_Connection_Command.RetransmissionEffort.OPTIMIZE_FOR_QUALITY,
)
ESCO_PARAMETERS = {
DefaultCodecParameters.SCO_CVSD_D0: _ESCO_PARAMETERS_CVSD_D0,
DefaultCodecParameters.SCO_CVSD_D1: _ESCO_PARAMETERS_CVSD_D1,
DefaultCodecParameters.ESCO_CVSD_S1: _ESCO_PARAMETERS_CVSD_S1,
DefaultCodecParameters.ESCO_CVSD_S2: _ESCO_PARAMETERS_CVSD_S2,
DefaultCodecParameters.ESCO_CVSD_S3: _ESCO_PARAMETERS_CVSD_S3,
DefaultCodecParameters.ESCO_CVSD_S4: _ESCO_PARAMETERS_CVSD_S4,
DefaultCodecParameters.ESCO_MSBC_T1: _ESCO_PARAMETERS_MSBC_T1,
DefaultCodecParameters.ESCO_MSBC_T2: _ESCO_PARAMETERS_MSBC_T2,
}

333
bumble/hid.py Normal file
View File

@@ -0,0 +1,333 @@
# 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
# -----------------------------------------------------------------------------
from __future__ import annotations
from dataclasses import dataclass
import logging
import enum
from pyee import EventEmitter
from typing import Optional, TYPE_CHECKING
from bumble import l2cap
from bumble.colors import color
from bumble.core import InvalidStateError, ProtocolError
if TYPE_CHECKING:
from bumble.device import Device, Connection
# -----------------------------------------------------------------------------
# Logging
# -----------------------------------------------------------------------------
logger = logging.getLogger(__name__)
# -----------------------------------------------------------------------------
# Constants
# -----------------------------------------------------------------------------
# fmt: on
HID_CONTROL_PSM = 0x0011
HID_INTERRUPT_PSM = 0x0013
class Message:
message_type: MessageType
# Report types
class ReportType(enum.IntEnum):
OTHER_REPORT = 0x00
INPUT_REPORT = 0x01
OUTPUT_REPORT = 0x02
FEATURE_REPORT = 0x03
# Handshake parameters
class Handshake(enum.IntEnum):
SUCCESSFUL = 0x00
NOT_READY = 0x01
ERR_INVALID_REPORT_ID = 0x02
ERR_UNSUPPORTED_REQUEST = 0x03
ERR_UNKNOWN = 0x0E
ERR_FATAL = 0x0F
# Message Type
class MessageType(enum.IntEnum):
HANDSHAKE = 0x00
CONTROL = 0x01
GET_REPORT = 0x04
SET_REPORT = 0x05
GET_PROTOCOL = 0x06
SET_PROTOCOL = 0x07
DATA = 0x0A
# Protocol modes
class ProtocolMode(enum.IntEnum):
BOOT_PROTOCOL = 0x00
REPORT_PROTOCOL = 0x01
# Control Operations
class ControlCommand(enum.IntEnum):
SUSPEND = 0x03
EXIT_SUSPEND = 0x04
VIRTUAL_CABLE_UNPLUG = 0x05
# Class Method to derive header
@classmethod
def header(cls, lower_bits: int = 0x00) -> bytes:
return bytes([(cls.message_type << 4) | lower_bits])
# HIDP messages
@dataclass
class GetReportMessage(Message):
report_type: int
report_id: int
buffer_size: int
message_type = Message.MessageType.GET_REPORT
def __bytes__(self) -> bytes:
packet_bytes = bytearray()
packet_bytes.append(self.report_id)
packet_bytes.extend(
[(self.buffer_size & 0xFF), ((self.buffer_size >> 8) & 0xFF)]
)
if self.report_type == Message.ReportType.OTHER_REPORT:
return self.header(self.report_type) + packet_bytes
else:
return self.header(0x08 | self.report_type) + packet_bytes
@dataclass
class SetReportMessage(Message):
report_type: int
data: bytes
message_type = Message.MessageType.SET_REPORT
def __bytes__(self) -> bytes:
return self.header(self.report_type) + self.data
@dataclass
class GetProtocolMessage(Message):
message_type = Message.MessageType.GET_PROTOCOL
def __bytes__(self) -> bytes:
return self.header()
@dataclass
class SetProtocolMessage(Message):
protocol_mode: int
message_type = Message.MessageType.SET_PROTOCOL
def __bytes__(self) -> bytes:
return self.header(self.protocol_mode)
@dataclass
class Suspend(Message):
message_type = Message.MessageType.CONTROL
def __bytes__(self) -> bytes:
return self.header(Message.ControlCommand.SUSPEND)
@dataclass
class ExitSuspend(Message):
message_type = Message.MessageType.CONTROL
def __bytes__(self) -> bytes:
return self.header(Message.ControlCommand.EXIT_SUSPEND)
@dataclass
class VirtualCableUnplug(Message):
message_type = Message.MessageType.CONTROL
def __bytes__(self) -> bytes:
return self.header(Message.ControlCommand.VIRTUAL_CABLE_UNPLUG)
@dataclass
class SendData(Message):
data: bytes
message_type = Message.MessageType.DATA
def __bytes__(self) -> bytes:
return self.header(Message.ReportType.OUTPUT_REPORT) + self.data
# -----------------------------------------------------------------------------
class Host(EventEmitter):
l2cap_ctrl_channel: Optional[l2cap.ClassicChannel]
l2cap_intr_channel: Optional[l2cap.ClassicChannel]
def __init__(self, device: Device, connection: Connection) -> None:
super().__init__()
self.device = device
self.connection = connection
self.l2cap_ctrl_channel = None
self.l2cap_intr_channel = None
# Register ourselves with the L2CAP channel manager
device.register_l2cap_server(HID_CONTROL_PSM, self.on_connection)
device.register_l2cap_server(HID_INTERRUPT_PSM, self.on_connection)
async def connect_control_channel(self) -> None:
# Create a new L2CAP connection - control channel
try:
self.l2cap_ctrl_channel = await self.device.l2cap_channel_manager.connect(
self.connection, HID_CONTROL_PSM
)
except ProtocolError:
logging.exception(f'L2CAP connection failed.')
raise
assert self.l2cap_ctrl_channel is not None
# Become a sink for the L2CAP channel
self.l2cap_ctrl_channel.sink = self.on_ctrl_pdu
async def connect_interrupt_channel(self) -> None:
# Create a new L2CAP connection - interrupt channel
try:
self.l2cap_intr_channel = await self.device.l2cap_channel_manager.connect(
self.connection, HID_INTERRUPT_PSM
)
except ProtocolError:
logging.exception(f'L2CAP connection failed.')
raise
assert self.l2cap_intr_channel is not None
# Become a sink for the L2CAP channel
self.l2cap_intr_channel.sink = self.on_intr_pdu
async def disconnect_interrupt_channel(self) -> None:
if self.l2cap_intr_channel is None:
raise InvalidStateError('invalid state')
channel = self.l2cap_intr_channel
self.l2cap_intr_channel = None
await channel.disconnect()
async def disconnect_control_channel(self) -> None:
if self.l2cap_ctrl_channel is None:
raise InvalidStateError('invalid state')
channel = self.l2cap_ctrl_channel
self.l2cap_ctrl_channel = None
await channel.disconnect()
def on_connection(self, l2cap_channel: l2cap.ClassicChannel) -> None:
logger.debug(f'+++ New L2CAP connection: {l2cap_channel}')
l2cap_channel.on('open', lambda: self.on_l2cap_channel_open(l2cap_channel))
def on_l2cap_channel_open(self, l2cap_channel: l2cap.ClassicChannel) -> None:
if l2cap_channel.psm == HID_CONTROL_PSM:
self.l2cap_ctrl_channel = l2cap_channel
self.l2cap_ctrl_channel.sink = self.on_ctrl_pdu
else:
self.l2cap_intr_channel = l2cap_channel
self.l2cap_intr_channel.sink = self.on_intr_pdu
logger.debug(f'$$$ L2CAP channel open: {l2cap_channel}')
def on_ctrl_pdu(self, pdu: bytes) -> None:
logger.debug(f'<<< HID CONTROL PDU: {pdu.hex()}')
# Here we will receive all kinds of packets, parse and then call respective callbacks
message_type = pdu[0] >> 4
param = pdu[0] & 0x0F
if message_type == Message.MessageType.HANDSHAKE:
logger.debug(f'<<< HID HANDSHAKE: {Message.Handshake(param).name}')
self.emit('handshake', Message.Handshake(param))
elif message_type == Message.MessageType.DATA:
logger.debug('<<< HID CONTROL DATA')
self.emit('data', pdu)
elif message_type == Message.MessageType.CONTROL:
if param == Message.ControlCommand.SUSPEND:
logger.debug('<<< HID SUSPEND')
self.emit('suspend', pdu)
elif param == Message.ControlCommand.EXIT_SUSPEND:
logger.debug('<<< HID EXIT SUSPEND')
self.emit('exit_suspend', pdu)
elif param == Message.ControlCommand.VIRTUAL_CABLE_UNPLUG:
logger.debug('<<< HID VIRTUAL CABLE UNPLUG')
self.emit('virtual_cable_unplug')
else:
logger.debug('<<< HID CONTROL OPERATION UNSUPPORTED')
else:
logger.debug('<<< HID CONTROL DATA')
self.emit('data', pdu)
def on_intr_pdu(self, pdu: bytes) -> None:
logger.debug(f'<<< HID INTERRUPT PDU: {pdu.hex()}')
self.emit("data", pdu)
def get_report(self, report_type: int, report_id: int, buffer_size: int) -> None:
msg = GetReportMessage(
report_type=report_type, report_id=report_id, buffer_size=buffer_size
)
hid_message = bytes(msg)
logger.debug(f'>>> HID CONTROL GET REPORT, PDU: {hid_message.hex()}')
self.send_pdu_on_ctrl(hid_message)
def set_report(self, report_type: int, data: bytes):
msg = SetReportMessage(report_type=report_type, data=data)
hid_message = bytes(msg)
logger.debug(f'>>> HID CONTROL SET REPORT, PDU:{hid_message.hex()}')
self.send_pdu_on_ctrl(hid_message)
def get_protocol(self):
msg = GetProtocolMessage()
hid_message = bytes(msg)
logger.debug(f'>>> HID CONTROL GET PROTOCOL, PDU: {hid_message.hex()}')
self.send_pdu_on_ctrl(hid_message)
def set_protocol(self, protocol_mode: int):
msg = SetProtocolMessage(protocol_mode=protocol_mode)
hid_message = bytes(msg)
logger.debug(f'>>> HID CONTROL SET PROTOCOL, PDU: {hid_message.hex()}')
self.send_pdu_on_ctrl(hid_message)
def send_pdu_on_ctrl(self, msg: bytes) -> None:
assert self.l2cap_ctrl_channel
self.l2cap_ctrl_channel.send_pdu(msg)
def send_pdu_on_intr(self, msg: bytes) -> None:
assert self.l2cap_intr_channel
self.l2cap_intr_channel.send_pdu(msg)
def send_data(self, data):
msg = SendData(data)
hid_message = bytes(msg)
logger.debug(f'>>> HID INTERRUPT SEND DATA, PDU: {hid_message.hex()}')
self.send_pdu_on_intr(hid_message)
def suspend(self):
msg = Suspend()
hid_message = bytes(msg)
logger.debug(f'>>> HID CONTROL SUSPEND, PDU:{hid_message.hex()}')
self.send_pdu_on_ctrl(msg)
def exit_suspend(self):
msg = ExitSuspend()
hid_message = bytes(msg)
logger.debug(f'>>> HID CONTROL EXIT SUSPEND, PDU:{hid_message.hex()}')
self.send_pdu_on_ctrl(msg)
def virtual_cable_unplug(self):
msg = VirtualCableUnplug()
hid_message = bytes(msg)
logger.debug(f'>>> HID CONTROL VIRTUAL CABLE UNPLUG, PDU: {hid_message.hex()}')
self.send_pdu_on_ctrl(msg)

View File

@@ -21,7 +21,7 @@ import collections
import logging import logging
import struct import struct
from typing import Optional, TYPE_CHECKING, Dict, Callable, Awaitable from typing import Optional, TYPE_CHECKING, Dict, Callable, Awaitable, cast
from bumble.colors import color from bumble.colors import color
from bumble.l2cap import L2CAP_PDU from bumble.l2cap import L2CAP_PDU
@@ -32,8 +32,8 @@ from .hci import (
Address, Address,
HCI_ACL_DATA_PACKET, HCI_ACL_DATA_PACKET,
HCI_COMMAND_PACKET, HCI_COMMAND_PACKET,
HCI_COMMAND_COMPLETE_EVENT,
HCI_EVENT_PACKET, HCI_EVENT_PACKET,
HCI_ISO_DATA_PACKET,
HCI_LE_READ_BUFFER_SIZE_COMMAND, HCI_LE_READ_BUFFER_SIZE_COMMAND,
HCI_LE_READ_LOCAL_SUPPORTED_FEATURES_COMMAND, HCI_LE_READ_LOCAL_SUPPORTED_FEATURES_COMMAND,
HCI_LE_READ_SUGGESTED_DEFAULT_DATA_LENGTH_COMMAND, HCI_LE_READ_SUGGESTED_DEFAULT_DATA_LENGTH_COMMAND,
@@ -43,6 +43,7 @@ from .hci import (
HCI_RESET_COMMAND, HCI_RESET_COMMAND,
HCI_SUCCESS, HCI_SUCCESS,
HCI_SUPPORTED_COMMANDS_FLAGS, HCI_SUPPORTED_COMMANDS_FLAGS,
HCI_SYNCHRONOUS_DATA_PACKET,
HCI_VERSION_BLUETOOTH_CORE_4_0, HCI_VERSION_BLUETOOTH_CORE_4_0,
HCI_AclDataPacket, HCI_AclDataPacket,
HCI_AclDataPacketAssembler, HCI_AclDataPacketAssembler,
@@ -51,6 +52,7 @@ from .hci import (
HCI_Constant, HCI_Constant,
HCI_Error, HCI_Error,
HCI_Event, HCI_Event,
HCI_IsoDataPacket,
HCI_LE_Long_Term_Key_Request_Negative_Reply_Command, HCI_LE_Long_Term_Key_Request_Negative_Reply_Command,
HCI_LE_Long_Term_Key_Request_Reply_Command, HCI_LE_Long_Term_Key_Request_Reply_Command,
HCI_LE_Read_Buffer_Size_Command, HCI_LE_Read_Buffer_Size_Command,
@@ -67,13 +69,13 @@ from .hci import (
HCI_Read_Local_Version_Information_Command, HCI_Read_Local_Version_Information_Command,
HCI_Reset_Command, HCI_Reset_Command,
HCI_Set_Event_Mask_Command, HCI_Set_Event_Mask_Command,
HCI_SynchronousDataPacket,
) )
from .core import ( from .core import (
BT_BR_EDR_TRANSPORT, BT_BR_EDR_TRANSPORT,
BT_LE_TRANSPORT, BT_LE_TRANSPORT,
ConnectionPHY, ConnectionPHY,
ConnectionParameters, ConnectionParameters,
InvalidStateError,
) )
from .utils import AbortableEventEmitter from .utils import AbortableEventEmitter
from .transport.common import TransportLostError from .transport.common import TransportLostError
@@ -241,7 +243,7 @@ class Host(AbortableEventEmitter):
# understand # understand
le_event_mask = bytes.fromhex('1F00000000000000') le_event_mask = bytes.fromhex('1F00000000000000')
else: else:
le_event_mask = bytes.fromhex('FFFFF00000000000') le_event_mask = bytes.fromhex('FFFFFFFF00000000')
await self.send_command( await self.send_command(
HCI_LE_Set_Event_Mask_Command(le_event_mask=le_event_mask) HCI_LE_Set_Event_Mask_Command(le_event_mask=le_event_mask)
@@ -485,12 +487,16 @@ class Host(AbortableEventEmitter):
self.snooper.snoop(bytes(packet), Snooper.Direction.CONTROLLER_TO_HOST) self.snooper.snoop(bytes(packet), Snooper.Direction.CONTROLLER_TO_HOST)
# If the packet is a command, invoke the handler for this packet # If the packet is a command, invoke the handler for this packet
if isinstance(packet, HCI_Command): if packet.hci_packet_type == HCI_COMMAND_PACKET:
self.on_hci_command_packet(packet) self.on_hci_command_packet(cast(HCI_Command, packet))
elif isinstance(packet, HCI_Event): elif packet.hci_packet_type == HCI_EVENT_PACKET:
self.on_hci_event_packet(packet) self.on_hci_event_packet(cast(HCI_Event, packet))
elif isinstance(packet, HCI_AclDataPacket): elif packet.hci_packet_type == HCI_ACL_DATA_PACKET:
self.on_hci_acl_data_packet(packet) self.on_hci_acl_data_packet(cast(HCI_AclDataPacket, packet))
elif packet.hci_packet_type == HCI_SYNCHRONOUS_DATA_PACKET:
self.on_hci_sco_data_packet(cast(HCI_SynchronousDataPacket, packet))
elif packet.hci_packet_type == HCI_ISO_DATA_PACKET:
self.on_hci_iso_data_packet(cast(HCI_IsoDataPacket, packet))
else: else:
logger.warning(f'!!! unknown packet type {packet.hci_packet_type}') logger.warning(f'!!! unknown packet type {packet.hci_packet_type}')
@@ -507,6 +513,14 @@ class Host(AbortableEventEmitter):
if connection := self.connections.get(packet.connection_handle): if connection := self.connections.get(packet.connection_handle):
connection.on_hci_acl_data_packet(packet) connection.on_hci_acl_data_packet(packet)
def on_hci_sco_data_packet(self, packet: HCI_SynchronousDataPacket) -> None:
# Experimental
self.emit('sco_packet', packet.connection_handle, packet)
def on_hci_iso_data_packet(self, packet: HCI_IsoDataPacket) -> None:
# Experimental
self.emit('iso_packet', packet.connection_handle, packet)
def on_l2cap_pdu(self, connection: Connection, cid: int, pdu: bytes) -> None: def on_l2cap_pdu(self, connection: Connection, cid: int, pdu: bytes) -> None:
self.emit('l2cap_pdu', connection.handle, cid, pdu) self.emit('l2cap_pdu', connection.handle, cid, pdu)
@@ -707,6 +721,24 @@ class Host(AbortableEventEmitter):
def on_hci_le_extended_advertising_report_event(self, event): def on_hci_le_extended_advertising_report_event(self, event):
self.on_hci_le_advertising_report_event(event) self.on_hci_le_advertising_report_event(event)
def on_hci_le_cis_request_event(self, event):
self.emit(
'cis_request',
event.acl_connection_handle,
event.cis_connection_handle,
event.cig_id,
event.cis_id,
)
def on_hci_le_cis_established_event(self, event):
# The remaining parameters are unused for now.
if event.status == HCI_SUCCESS:
self.emit('cis_establishment', event.connection_handle)
else:
self.emit(
'cis_establishment_failure', event.connection_handle, event.status
)
def on_hci_le_remote_connection_parameter_request_event(self, event): def on_hci_le_remote_connection_parameter_request_event(self, event):
if event.connection_handle not in self.connections: if event.connection_handle not in self.connections:
logger.warning('!!! REMOTE CONNECTION PARAMETER REQUEST: unknown handle') logger.warning('!!! REMOTE CONNECTION PARAMETER REQUEST: unknown handle')
@@ -760,7 +792,25 @@ class Host(AbortableEventEmitter):
asyncio.create_task(send_long_term_key()) asyncio.create_task(send_long_term_key())
def on_hci_synchronous_connection_complete_event(self, event): def on_hci_synchronous_connection_complete_event(self, event):
pass if event.status == HCI_SUCCESS:
# Create/update the connection
logger.debug(
f'### SCO CONNECTION: [0x{event.connection_handle:04X}] '
f'{event.bd_addr}'
)
# Notify the client
self.emit(
'sco_connection',
event.bd_addr,
event.connection_handle,
event.link_type,
)
else:
logger.debug(f'### SCO CONNECTION FAILED: {event.status}')
# Notify the client
self.emit('sco_connection_failure', event.bd_addr, event.status)
def on_hci_synchronous_connection_changed_event(self, event): def on_hci_synchronous_connection_changed_event(self, event):
pass pass

View File

@@ -17,6 +17,8 @@
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
from __future__ import annotations from __future__ import annotations
import asyncio import asyncio
import dataclasses
import enum
import logging import logging
import struct import struct
@@ -37,6 +39,7 @@ from typing import (
TYPE_CHECKING, TYPE_CHECKING,
) )
from .utils import deprecated
from .colors import color from .colors import color
from .core import BT_CENTRAL_ROLE, InvalidStateError, ProtocolError from .core import BT_CENTRAL_ROLE, InvalidStateError, ProtocolError
from .hci import ( from .hci import (
@@ -166,6 +169,34 @@ L2CAP_MTU_CONFIGURATION_PARAMETER_TYPE = 0x01
# pylint: disable=invalid-name # pylint: disable=invalid-name
@dataclasses.dataclass
class ClassicChannelSpec:
psm: Optional[int] = None
mtu: int = L2CAP_MIN_BR_EDR_MTU
@dataclasses.dataclass
class LeCreditBasedChannelSpec:
psm: Optional[int] = None
mtu: int = L2CAP_LE_CREDIT_BASED_CONNECTION_DEFAULT_MTU
mps: int = L2CAP_LE_CREDIT_BASED_CONNECTION_DEFAULT_MPS
max_credits: int = L2CAP_LE_CREDIT_BASED_CONNECTION_DEFAULT_INITIAL_CREDITS
def __post_init__(self):
if (
self.max_credits < 1
or self.max_credits > L2CAP_LE_CREDIT_BASED_CONNECTION_MAX_CREDITS
):
raise ValueError('max credits out of range')
if self.mtu < L2CAP_LE_CREDIT_BASED_CONNECTION_MIN_MTU:
raise ValueError('MTU too small')
if (
self.mps < L2CAP_LE_CREDIT_BASED_CONNECTION_MIN_MPS
or self.mps > L2CAP_LE_CREDIT_BASED_CONNECTION_MAX_MPS
):
raise ValueError('MPS out of range')
class L2CAP_PDU: class L2CAP_PDU:
''' '''
See Bluetooth spec @ Vol 3, Part A - 3 DATA PACKET FORMAT See Bluetooth spec @ Vol 3, Part A - 3 DATA PACKET FORMAT
@@ -360,6 +391,9 @@ class L2CAP_Connection_Request(L2CAP_Control_Frame):
See Bluetooth spec @ Vol 3, Part A - 4.2 CONNECTION REQUEST See Bluetooth spec @ Vol 3, Part A - 4.2 CONNECTION REQUEST
''' '''
psm: int
source_cid: int
@staticmethod @staticmethod
def parse_psm(data: bytes, offset: int = 0) -> Tuple[int, int]: def parse_psm(data: bytes, offset: int = 0) -> Tuple[int, int]:
psm_length = 2 psm_length = 2
@@ -401,6 +435,11 @@ class L2CAP_Connection_Response(L2CAP_Control_Frame):
See Bluetooth spec @ Vol 3, Part A - 4.3 CONNECTION RESPONSE See Bluetooth spec @ Vol 3, Part A - 4.3 CONNECTION RESPONSE
''' '''
source_cid: int
destination_cid: int
status: int
result: int
CONNECTION_SUCCESSFUL = 0x0000 CONNECTION_SUCCESSFUL = 0x0000
CONNECTION_PENDING = 0x0001 CONNECTION_PENDING = 0x0001
CONNECTION_REFUSED_PSM_NOT_SUPPORTED = 0x0002 CONNECTION_REFUSED_PSM_NOT_SUPPORTED = 0x0002
@@ -675,57 +714,36 @@ class L2CAP_LE_Flow_Control_Credit(L2CAP_Control_Frame):
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
class Channel(EventEmitter): class ClassicChannel(EventEmitter):
# States class State(enum.IntEnum):
CLOSED = 0x00 # States
WAIT_CONNECT = 0x01 CLOSED = 0x00
WAIT_CONNECT_RSP = 0x02 WAIT_CONNECT = 0x01
OPEN = 0x03 WAIT_CONNECT_RSP = 0x02
WAIT_DISCONNECT = 0x04 OPEN = 0x03
WAIT_CREATE = 0x05 WAIT_DISCONNECT = 0x04
WAIT_CREATE_RSP = 0x06 WAIT_CREATE = 0x05
WAIT_MOVE = 0x07 WAIT_CREATE_RSP = 0x06
WAIT_MOVE_RSP = 0x08 WAIT_MOVE = 0x07
WAIT_MOVE_CONFIRM = 0x09 WAIT_MOVE_RSP = 0x08
WAIT_CONFIRM_RSP = 0x0A WAIT_MOVE_CONFIRM = 0x09
WAIT_CONFIRM_RSP = 0x0A
# CONFIG substates # CONFIG substates
WAIT_CONFIG = 0x10 WAIT_CONFIG = 0x10
WAIT_SEND_CONFIG = 0x11 WAIT_SEND_CONFIG = 0x11
WAIT_CONFIG_REQ_RSP = 0x12 WAIT_CONFIG_REQ_RSP = 0x12
WAIT_CONFIG_RSP = 0x13 WAIT_CONFIG_RSP = 0x13
WAIT_CONFIG_REQ = 0x14 WAIT_CONFIG_REQ = 0x14
WAIT_IND_FINAL_RSP = 0x15 WAIT_IND_FINAL_RSP = 0x15
WAIT_FINAL_RSP = 0x16 WAIT_FINAL_RSP = 0x16
WAIT_CONTROL_IND = 0x17 WAIT_CONTROL_IND = 0x17
STATE_NAMES = {
CLOSED: 'CLOSED',
WAIT_CONNECT: 'WAIT_CONNECT',
WAIT_CONNECT_RSP: 'WAIT_CONNECT_RSP',
OPEN: 'OPEN',
WAIT_DISCONNECT: 'WAIT_DISCONNECT',
WAIT_CREATE: 'WAIT_CREATE',
WAIT_CREATE_RSP: 'WAIT_CREATE_RSP',
WAIT_MOVE: 'WAIT_MOVE',
WAIT_MOVE_RSP: 'WAIT_MOVE_RSP',
WAIT_MOVE_CONFIRM: 'WAIT_MOVE_CONFIRM',
WAIT_CONFIRM_RSP: 'WAIT_CONFIRM_RSP',
WAIT_CONFIG: 'WAIT_CONFIG',
WAIT_SEND_CONFIG: 'WAIT_SEND_CONFIG',
WAIT_CONFIG_REQ_RSP: 'WAIT_CONFIG_REQ_RSP',
WAIT_CONFIG_RSP: 'WAIT_CONFIG_RSP',
WAIT_CONFIG_REQ: 'WAIT_CONFIG_REQ',
WAIT_IND_FINAL_RSP: 'WAIT_IND_FINAL_RSP',
WAIT_FINAL_RSP: 'WAIT_FINAL_RSP',
WAIT_CONTROL_IND: 'WAIT_CONTROL_IND',
}
connection_result: Optional[asyncio.Future[None]] connection_result: Optional[asyncio.Future[None]]
disconnection_result: Optional[asyncio.Future[None]] disconnection_result: Optional[asyncio.Future[None]]
response: Optional[asyncio.Future[bytes]] response: Optional[asyncio.Future[bytes]]
sink: Optional[Callable[[bytes], Any]] sink: Optional[Callable[[bytes], Any]]
state: int state: State
connection: Connection connection: Connection
def __init__( def __init__(
@@ -741,7 +759,7 @@ class Channel(EventEmitter):
self.manager = manager self.manager = manager
self.connection = connection self.connection = connection
self.signaling_cid = signaling_cid self.signaling_cid = signaling_cid
self.state = Channel.CLOSED self.state = self.State.CLOSED
self.mtu = mtu self.mtu = mtu
self.psm = psm self.psm = psm
self.source_cid = source_cid self.source_cid = source_cid
@@ -751,13 +769,11 @@ class Channel(EventEmitter):
self.disconnection_result = None self.disconnection_result = None
self.sink = None self.sink = None
def change_state(self, new_state: int) -> None: def _change_state(self, new_state: State) -> None:
logger.debug( logger.debug(f'{self} state change -> {color(new_state.name, "cyan")}')
f'{self} state change -> {color(Channel.STATE_NAMES[new_state], "cyan")}'
)
self.state = new_state self.state = new_state
def send_pdu(self, pdu: SupportsBytes | bytes) -> None: def send_pdu(self, pdu: Union[SupportsBytes, bytes]) -> None:
self.manager.send_pdu(self.connection, self.destination_cid, pdu) self.manager.send_pdu(self.connection, self.destination_cid, pdu)
def send_control_frame(self, frame: L2CAP_Control_Frame) -> None: def send_control_frame(self, frame: L2CAP_Control_Frame) -> None:
@@ -767,7 +783,7 @@ class Channel(EventEmitter):
# Check that there isn't already a request pending # Check that there isn't already a request pending
if self.response: if self.response:
raise InvalidStateError('request already pending') raise InvalidStateError('request already pending')
if self.state != Channel.OPEN: if self.state != self.State.OPEN:
raise InvalidStateError('channel not open') raise InvalidStateError('channel not open')
self.response = asyncio.get_running_loop().create_future() self.response = asyncio.get_running_loop().create_future()
@@ -787,14 +803,14 @@ class Channel(EventEmitter):
) )
async def connect(self) -> None: async def connect(self) -> None:
if self.state != Channel.CLOSED: if self.state != self.State.CLOSED:
raise InvalidStateError('invalid state') raise InvalidStateError('invalid state')
# Check that we can start a new connection # Check that we can start a new connection
if self.connection_result: if self.connection_result:
raise RuntimeError('connection already pending') raise RuntimeError('connection already pending')
self.change_state(Channel.WAIT_CONNECT_RSP) self._change_state(self.State.WAIT_CONNECT_RSP)
self.send_control_frame( self.send_control_frame(
L2CAP_Connection_Request( L2CAP_Connection_Request(
identifier=self.manager.next_identifier(self.connection), identifier=self.manager.next_identifier(self.connection),
@@ -814,10 +830,10 @@ class Channel(EventEmitter):
self.connection_result = None self.connection_result = None
async def disconnect(self) -> None: async def disconnect(self) -> None:
if self.state != Channel.OPEN: if self.state != self.State.OPEN:
raise InvalidStateError('invalid state') raise InvalidStateError('invalid state')
self.change_state(Channel.WAIT_DISCONNECT) self._change_state(self.State.WAIT_DISCONNECT)
self.send_control_frame( self.send_control_frame(
L2CAP_Disconnection_Request( L2CAP_Disconnection_Request(
identifier=self.manager.next_identifier(self.connection), identifier=self.manager.next_identifier(self.connection),
@@ -832,8 +848,8 @@ class Channel(EventEmitter):
return await self.disconnection_result return await self.disconnection_result
def abort(self) -> None: def abort(self) -> None:
if self.state == self.OPEN: if self.state == self.State.OPEN:
self.change_state(self.CLOSED) self._change_state(self.State.CLOSED)
self.emit('close') self.emit('close')
def send_configure_request(self) -> None: def send_configure_request(self) -> None:
@@ -856,7 +872,7 @@ class Channel(EventEmitter):
def on_connection_request(self, request) -> None: def on_connection_request(self, request) -> None:
self.destination_cid = request.source_cid self.destination_cid = request.source_cid
self.change_state(Channel.WAIT_CONNECT) self._change_state(self.State.WAIT_CONNECT)
self.send_control_frame( self.send_control_frame(
L2CAP_Connection_Response( L2CAP_Connection_Response(
identifier=request.identifier, identifier=request.identifier,
@@ -866,24 +882,24 @@ class Channel(EventEmitter):
status=0x0000, status=0x0000,
) )
) )
self.change_state(Channel.WAIT_CONFIG) self._change_state(self.State.WAIT_CONFIG)
self.send_configure_request() self.send_configure_request()
self.change_state(Channel.WAIT_CONFIG_REQ_RSP) self._change_state(self.State.WAIT_CONFIG_REQ_RSP)
def on_connection_response(self, response): def on_connection_response(self, response):
if self.state != Channel.WAIT_CONNECT_RSP: if self.state != self.State.WAIT_CONNECT_RSP:
logger.warning(color('invalid state', 'red')) logger.warning(color('invalid state', 'red'))
return return
if response.result == L2CAP_Connection_Response.CONNECTION_SUCCESSFUL: if response.result == L2CAP_Connection_Response.CONNECTION_SUCCESSFUL:
self.destination_cid = response.destination_cid self.destination_cid = response.destination_cid
self.change_state(Channel.WAIT_CONFIG) self._change_state(self.State.WAIT_CONFIG)
self.send_configure_request() self.send_configure_request()
self.change_state(Channel.WAIT_CONFIG_REQ_RSP) self._change_state(self.State.WAIT_CONFIG_REQ_RSP)
elif response.result == L2CAP_Connection_Response.CONNECTION_PENDING: elif response.result == L2CAP_Connection_Response.CONNECTION_PENDING:
pass pass
else: else:
self.change_state(Channel.CLOSED) self._change_state(self.State.CLOSED)
self.connection_result.set_exception( self.connection_result.set_exception(
ProtocolError( ProtocolError(
response.result, response.result,
@@ -895,9 +911,9 @@ class Channel(EventEmitter):
def on_configure_request(self, request) -> None: def on_configure_request(self, request) -> None:
if self.state not in ( if self.state not in (
Channel.WAIT_CONFIG, self.State.WAIT_CONFIG,
Channel.WAIT_CONFIG_REQ, self.State.WAIT_CONFIG_REQ,
Channel.WAIT_CONFIG_REQ_RSP, self.State.WAIT_CONFIG_REQ_RSP,
): ):
logger.warning(color('invalid state', 'red')) logger.warning(color('invalid state', 'red'))
return return
@@ -918,25 +934,28 @@ class Channel(EventEmitter):
options=request.options, # TODO: don't accept everything blindly options=request.options, # TODO: don't accept everything blindly
) )
) )
if self.state == Channel.WAIT_CONFIG: if self.state == self.State.WAIT_CONFIG:
self.change_state(Channel.WAIT_SEND_CONFIG) self._change_state(self.State.WAIT_SEND_CONFIG)
self.send_configure_request() self.send_configure_request()
self.change_state(Channel.WAIT_CONFIG_RSP) self._change_state(self.State.WAIT_CONFIG_RSP)
elif self.state == Channel.WAIT_CONFIG_REQ: elif self.state == self.State.WAIT_CONFIG_REQ:
self.change_state(Channel.OPEN) self._change_state(self.State.OPEN)
if self.connection_result: if self.connection_result:
self.connection_result.set_result(None) self.connection_result.set_result(None)
self.connection_result = None self.connection_result = None
self.emit('open') self.emit('open')
elif self.state == Channel.WAIT_CONFIG_REQ_RSP: elif self.state == self.State.WAIT_CONFIG_REQ_RSP:
self.change_state(Channel.WAIT_CONFIG_RSP) self._change_state(self.State.WAIT_CONFIG_RSP)
def on_configure_response(self, response) -> None: def on_configure_response(self, response) -> None:
if response.result == L2CAP_Configure_Response.SUCCESS: if response.result == L2CAP_Configure_Response.SUCCESS:
if self.state == Channel.WAIT_CONFIG_REQ_RSP: if self.state == self.State.WAIT_CONFIG_REQ_RSP:
self.change_state(Channel.WAIT_CONFIG_REQ) self._change_state(self.State.WAIT_CONFIG_REQ)
elif self.state in (Channel.WAIT_CONFIG_RSP, Channel.WAIT_CONTROL_IND): elif self.state in (
self.change_state(Channel.OPEN) self.State.WAIT_CONFIG_RSP,
self.State.WAIT_CONTROL_IND,
):
self._change_state(self.State.OPEN)
if self.connection_result: if self.connection_result:
self.connection_result.set_result(None) self.connection_result.set_result(None)
self.connection_result = None self.connection_result = None
@@ -966,7 +985,7 @@ class Channel(EventEmitter):
# TODO: decide how to fail gracefully # TODO: decide how to fail gracefully
def on_disconnection_request(self, request) -> None: def on_disconnection_request(self, request) -> None:
if self.state in (Channel.OPEN, Channel.WAIT_DISCONNECT): if self.state in (self.State.OPEN, self.State.WAIT_DISCONNECT):
self.send_control_frame( self.send_control_frame(
L2CAP_Disconnection_Response( L2CAP_Disconnection_Response(
identifier=request.identifier, identifier=request.identifier,
@@ -974,14 +993,14 @@ class Channel(EventEmitter):
source_cid=request.source_cid, source_cid=request.source_cid,
) )
) )
self.change_state(Channel.CLOSED) self._change_state(self.State.CLOSED)
self.emit('close') self.emit('close')
self.manager.on_channel_closed(self) self.manager.on_channel_closed(self)
else: else:
logger.warning(color('invalid state', 'red')) logger.warning(color('invalid state', 'red'))
def on_disconnection_response(self, response) -> None: def on_disconnection_response(self, response) -> None:
if self.state != Channel.WAIT_DISCONNECT: if self.state != self.State.WAIT_DISCONNECT:
logger.warning(color('invalid state', 'red')) logger.warning(color('invalid state', 'red'))
return return
@@ -992,7 +1011,7 @@ class Channel(EventEmitter):
logger.warning('unexpected source or destination CID') logger.warning('unexpected source or destination CID')
return return
self.change_state(Channel.CLOSED) self._change_state(self.State.CLOSED)
if self.disconnection_result: if self.disconnection_result:
self.disconnection_result.set_result(None) self.disconnection_result.set_result(None)
self.disconnection_result = None self.disconnection_result = None
@@ -1004,42 +1023,32 @@ class Channel(EventEmitter):
f'Channel({self.source_cid}->{self.destination_cid}, ' f'Channel({self.source_cid}->{self.destination_cid}, '
f'PSM={self.psm}, ' f'PSM={self.psm}, '
f'MTU={self.mtu}, ' f'MTU={self.mtu}, '
f'state={Channel.STATE_NAMES[self.state]})' f'state={self.state.name})'
) )
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
class LeConnectionOrientedChannel(EventEmitter): class LeCreditBasedChannel(EventEmitter):
""" """
LE Credit-based Connection Oriented Channel LE Credit-based Connection Oriented Channel
""" """
INIT = 0 class State(enum.IntEnum):
CONNECTED = 1 INIT = 0
CONNECTING = 2 CONNECTED = 1
DISCONNECTING = 3 CONNECTING = 2
DISCONNECTED = 4 DISCONNECTING = 3
CONNECTION_ERROR = 5 DISCONNECTED = 4
CONNECTION_ERROR = 5
STATE_NAMES = {
INIT: 'INIT',
CONNECTED: 'CONNECTED',
CONNECTING: 'CONNECTING',
DISCONNECTING: 'DISCONNECTING',
DISCONNECTED: 'DISCONNECTED',
CONNECTION_ERROR: 'CONNECTION_ERROR',
}
out_queue: Deque[bytes] out_queue: Deque[bytes]
connection_result: Optional[asyncio.Future[LeConnectionOrientedChannel]] connection_result: Optional[asyncio.Future[LeCreditBasedChannel]]
disconnection_result: Optional[asyncio.Future[None]] disconnection_result: Optional[asyncio.Future[None]]
in_sdu: Optional[bytes]
out_sdu: Optional[bytes] out_sdu: Optional[bytes]
state: int state: State
connection: Connection connection: Connection
sink: Optional[Callable[[bytes], Any]]
@staticmethod
def state_name(state: int) -> str:
return name_or_number(LeConnectionOrientedChannel.STATE_NAMES, state)
def __init__( def __init__(
self, self,
@@ -1083,30 +1092,28 @@ class LeConnectionOrientedChannel(EventEmitter):
self.drained.set() self.drained.set()
if connected: if connected:
self.state = LeConnectionOrientedChannel.CONNECTED self.state = self.State.CONNECTED
else: else:
self.state = LeConnectionOrientedChannel.INIT self.state = self.State.INIT
def change_state(self, new_state: int) -> None: def _change_state(self, new_state: State) -> None:
logger.debug( logger.debug(f'{self} state change -> {color(new_state.name, "cyan")}')
f'{self} state change -> {color(self.state_name(new_state), "cyan")}'
)
self.state = new_state self.state = new_state
if new_state == self.CONNECTED: if new_state == self.State.CONNECTED:
self.emit('open') self.emit('open')
elif new_state == self.DISCONNECTED: elif new_state == self.State.DISCONNECTED:
self.emit('close') self.emit('close')
def send_pdu(self, pdu: SupportsBytes | bytes) -> None: def send_pdu(self, pdu: Union[SupportsBytes, bytes]) -> None:
self.manager.send_pdu(self.connection, self.destination_cid, pdu) self.manager.send_pdu(self.connection, self.destination_cid, pdu)
def send_control_frame(self, frame: L2CAP_Control_Frame) -> None: def send_control_frame(self, frame: L2CAP_Control_Frame) -> None:
self.manager.send_control_frame(self.connection, L2CAP_LE_SIGNALING_CID, frame) self.manager.send_control_frame(self.connection, L2CAP_LE_SIGNALING_CID, frame)
async def connect(self) -> LeConnectionOrientedChannel: async def connect(self) -> LeCreditBasedChannel:
# Check that we're in the right state # Check that we're in the right state
if self.state != self.INIT: if self.state != self.State.INIT:
raise InvalidStateError('not in a connectable state') raise InvalidStateError('not in a connectable state')
# Check that we can start a new connection # Check that we can start a new connection
@@ -1114,7 +1121,7 @@ class LeConnectionOrientedChannel(EventEmitter):
if identifier in self.manager.le_coc_requests: if identifier in self.manager.le_coc_requests:
raise RuntimeError('too many concurrent connection requests') raise RuntimeError('too many concurrent connection requests')
self.change_state(self.CONNECTING) self._change_state(self.State.CONNECTING)
request = L2CAP_LE_Credit_Based_Connection_Request( request = L2CAP_LE_Credit_Based_Connection_Request(
identifier=identifier, identifier=identifier,
le_psm=self.le_psm, le_psm=self.le_psm,
@@ -1134,10 +1141,10 @@ class LeConnectionOrientedChannel(EventEmitter):
async def disconnect(self) -> None: async def disconnect(self) -> None:
# Check that we're connected # Check that we're connected
if self.state != self.CONNECTED: if self.state != self.State.CONNECTED:
raise InvalidStateError('not connected') raise InvalidStateError('not connected')
self.change_state(self.DISCONNECTING) self._change_state(self.State.DISCONNECTING)
self.flush_output() self.flush_output()
self.send_control_frame( self.send_control_frame(
L2CAP_Disconnection_Request( L2CAP_Disconnection_Request(
@@ -1153,15 +1160,15 @@ class LeConnectionOrientedChannel(EventEmitter):
return await self.disconnection_result return await self.disconnection_result
def abort(self) -> None: def abort(self) -> None:
if self.state == self.CONNECTED: if self.state == self.State.CONNECTED:
self.change_state(self.DISCONNECTED) self._change_state(self.State.DISCONNECTED)
def on_pdu(self, pdu: bytes) -> None: def on_pdu(self, pdu: bytes) -> None:
if self.sink is None: if self.sink is None:
logger.warning('received pdu without a sink') logger.warning('received pdu without a sink')
return return
if self.state != self.CONNECTED: if self.state != self.State.CONNECTED:
logger.warning('received PDU while not connected, dropping') logger.warning('received PDU while not connected, dropping')
# Manage the peer credits # Manage the peer credits
@@ -1240,7 +1247,7 @@ class LeConnectionOrientedChannel(EventEmitter):
self.credits = response.initial_credits self.credits = response.initial_credits
self.connected = True self.connected = True
self.connection_result.set_result(self) self.connection_result.set_result(self)
self.change_state(self.CONNECTED) self._change_state(self.State.CONNECTED)
else: else:
self.connection_result.set_exception( self.connection_result.set_exception(
ProtocolError( ProtocolError(
@@ -1251,7 +1258,7 @@ class LeConnectionOrientedChannel(EventEmitter):
), ),
) )
) )
self.change_state(self.CONNECTION_ERROR) self._change_state(self.State.CONNECTION_ERROR)
# Cleanup # Cleanup
self.connection_result = None self.connection_result = None
@@ -1271,11 +1278,11 @@ class LeConnectionOrientedChannel(EventEmitter):
source_cid=request.source_cid, source_cid=request.source_cid,
) )
) )
self.change_state(self.DISCONNECTED) self._change_state(self.State.DISCONNECTED)
self.flush_output() self.flush_output()
def on_disconnection_response(self, response) -> None: def on_disconnection_response(self, response) -> None:
if self.state != self.DISCONNECTING: if self.state != self.State.DISCONNECTING:
logger.warning(color('invalid state', 'red')) logger.warning(color('invalid state', 'red'))
return return
@@ -1286,7 +1293,7 @@ class LeConnectionOrientedChannel(EventEmitter):
logger.warning('unexpected source or destination CID') logger.warning('unexpected source or destination CID')
return return
self.change_state(self.DISCONNECTED) self._change_state(self.State.DISCONNECTED)
if self.disconnection_result: if self.disconnection_result:
self.disconnection_result.set_result(None) self.disconnection_result.set_result(None)
self.disconnection_result = None self.disconnection_result = None
@@ -1339,7 +1346,7 @@ class LeConnectionOrientedChannel(EventEmitter):
return return
def write(self, data: bytes) -> None: def write(self, data: bytes) -> None:
if self.state != self.CONNECTED: if self.state != self.State.CONNECTED:
logger.warning('not connected, dropping data') logger.warning('not connected, dropping data')
return return
@@ -1367,7 +1374,7 @@ class LeConnectionOrientedChannel(EventEmitter):
def __str__(self) -> str: def __str__(self) -> str:
return ( return (
f'CoC({self.source_cid}->{self.destination_cid}, ' f'CoC({self.source_cid}->{self.destination_cid}, '
f'State={self.state_name(self.state)}, ' f'State={self.state.name}, '
f'PSM={self.le_psm}, ' f'PSM={self.le_psm}, '
f'MTU={self.mtu}/{self.peer_mtu}, ' f'MTU={self.mtu}/{self.peer_mtu}, '
f'MPS={self.mps}/{self.peer_mps}, ' f'MPS={self.mps}/{self.peer_mps}, '
@@ -1375,15 +1382,67 @@ class LeConnectionOrientedChannel(EventEmitter):
) )
# -----------------------------------------------------------------------------
class ClassicChannelServer(EventEmitter):
def __init__(
self,
manager: ChannelManager,
psm: int,
handler: Optional[Callable[[ClassicChannel], Any]],
mtu: int,
) -> None:
super().__init__()
self.manager = manager
self.handler = handler
self.psm = psm
self.mtu = mtu
def on_connection(self, channel: ClassicChannel) -> None:
self.emit('connection', channel)
if self.handler:
self.handler(channel)
def close(self) -> None:
if self.psm in self.manager.servers:
del self.manager.servers[self.psm]
# -----------------------------------------------------------------------------
class LeCreditBasedChannelServer(EventEmitter):
def __init__(
self,
manager: ChannelManager,
psm: int,
handler: Optional[Callable[[LeCreditBasedChannel], Any]],
max_credits: int,
mtu: int,
mps: int,
) -> None:
super().__init__()
self.manager = manager
self.handler = handler
self.psm = psm
self.max_credits = max_credits
self.mtu = mtu
self.mps = mps
def on_connection(self, channel: LeCreditBasedChannel) -> None:
self.emit('connection', channel)
if self.handler:
self.handler(channel)
def close(self) -> None:
if self.psm in self.manager.le_coc_servers:
del self.manager.le_coc_servers[self.psm]
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
class ChannelManager: class ChannelManager:
identifiers: Dict[int, int] identifiers: Dict[int, int]
channels: Dict[int, Dict[int, Union[Channel, LeConnectionOrientedChannel]]] channels: Dict[int, Dict[int, Union[ClassicChannel, LeCreditBasedChannel]]]
servers: Dict[int, Callable[[Channel], Any]] servers: Dict[int, ClassicChannelServer]
le_coc_channels: Dict[int, Dict[int, LeConnectionOrientedChannel]] le_coc_channels: Dict[int, Dict[int, LeCreditBasedChannel]]
le_coc_servers: Dict[ le_coc_servers: Dict[int, LeCreditBasedChannelServer]
int, Tuple[Callable[[LeConnectionOrientedChannel], Any], int, int, int]
]
le_coc_requests: Dict[int, L2CAP_LE_Credit_Based_Connection_Request] le_coc_requests: Dict[int, L2CAP_LE_Credit_Based_Connection_Request]
fixed_channels: Dict[int, Optional[Callable[[int, bytes], Any]]] fixed_channels: Dict[int, Optional[Callable[[int, bytes], Any]]]
_host: Optional[Host] _host: Optional[Host]
@@ -1462,21 +1521,6 @@ class ChannelManager:
raise RuntimeError('no free CID') raise RuntimeError('no free CID')
@staticmethod
def check_le_coc_parameters(max_credits: int, mtu: int, mps: int) -> None:
if (
max_credits < 1
or max_credits > L2CAP_LE_CREDIT_BASED_CONNECTION_MAX_CREDITS
):
raise ValueError('max credits out of range')
if mtu < L2CAP_LE_CREDIT_BASED_CONNECTION_MIN_MTU:
raise ValueError('MTU too small')
if (
mps < L2CAP_LE_CREDIT_BASED_CONNECTION_MIN_MPS
or mps > L2CAP_LE_CREDIT_BASED_CONNECTION_MAX_MPS
):
raise ValueError('MPS out of range')
def next_identifier(self, connection: Connection) -> int: def next_identifier(self, connection: Connection) -> int:
identifier = (self.identifiers.setdefault(connection.handle, 0) + 1) % 256 identifier = (self.identifiers.setdefault(connection.handle, 0) + 1) % 256
self.identifiers[connection.handle] = identifier self.identifiers[connection.handle] = identifier
@@ -1491,8 +1535,22 @@ class ChannelManager:
if cid in self.fixed_channels: if cid in self.fixed_channels:
del self.fixed_channels[cid] del self.fixed_channels[cid]
def register_server(self, psm: int, server: Callable[[Channel], Any]) -> int: @deprecated("Please use create_classic_server")
if psm == 0: def register_server(
self,
psm: int,
server: Callable[[ClassicChannel], Any],
) -> int:
return self.create_classic_server(
handler=server, spec=ClassicChannelSpec(psm=psm)
).psm
def create_classic_server(
self,
spec: ClassicChannelSpec,
handler: Optional[Callable[[ClassicChannel], Any]] = None,
) -> ClassicChannelServer:
if not spec.psm:
# Find a free PSM # Find a free PSM
for candidate in range( for candidate in range(
L2CAP_PSM_DYNAMIC_RANGE_START, L2CAP_PSM_DYNAMIC_RANGE_END + 1, 2 L2CAP_PSM_DYNAMIC_RANGE_START, L2CAP_PSM_DYNAMIC_RANGE_END + 1, 2
@@ -1501,62 +1559,75 @@ class ChannelManager:
continue continue
if candidate in self.servers: if candidate in self.servers:
continue continue
psm = candidate spec.psm = candidate
break break
else: else:
raise InvalidStateError('no free PSM') raise InvalidStateError('no free PSM')
else: else:
# Check that the PSM isn't already in use # Check that the PSM isn't already in use
if psm in self.servers: if spec.psm in self.servers:
raise ValueError('PSM already in use') raise ValueError('PSM already in use')
# Check that the PSM is valid # Check that the PSM is valid
if psm % 2 == 0: if spec.psm % 2 == 0:
raise ValueError('invalid PSM (not odd)') raise ValueError('invalid PSM (not odd)')
check = psm >> 8 check = spec.psm >> 8
while check: while check:
if check % 2 != 0: if check % 2 != 0:
raise ValueError('invalid PSM') raise ValueError('invalid PSM')
check >>= 8 check >>= 8
self.servers[psm] = server self.servers[spec.psm] = ClassicChannelServer(self, spec.psm, handler, spec.mtu)
return psm return self.servers[spec.psm]
@deprecated("Please use create_le_credit_based_server()")
def register_le_coc_server( def register_le_coc_server(
self, self,
psm: int, psm: int,
server: Callable[[LeConnectionOrientedChannel], Any], server: Callable[[LeCreditBasedChannel], Any],
max_credits: int = L2CAP_LE_CREDIT_BASED_CONNECTION_DEFAULT_INITIAL_CREDITS, max_credits: int,
mtu: int = L2CAP_LE_CREDIT_BASED_CONNECTION_DEFAULT_MTU, mtu: int,
mps: int = L2CAP_LE_CREDIT_BASED_CONNECTION_DEFAULT_MPS, mps: int,
) -> int: ) -> int:
self.check_le_coc_parameters(max_credits, mtu, mps) return self.create_le_credit_based_server(
spec=LeCreditBasedChannelSpec(
psm=None if psm == 0 else psm, mtu=mtu, mps=mps, max_credits=max_credits
),
handler=server,
).psm
if psm == 0: def create_le_credit_based_server(
self,
spec: LeCreditBasedChannelSpec,
handler: Optional[Callable[[LeCreditBasedChannel], Any]] = None,
) -> LeCreditBasedChannelServer:
if not spec.psm:
# Find a free PSM # Find a free PSM
for candidate in range( for candidate in range(
L2CAP_LE_PSM_DYNAMIC_RANGE_START, L2CAP_LE_PSM_DYNAMIC_RANGE_END + 1 L2CAP_LE_PSM_DYNAMIC_RANGE_START, L2CAP_LE_PSM_DYNAMIC_RANGE_END + 1
): ):
if candidate in self.le_coc_servers: if candidate in self.le_coc_servers:
continue continue
psm = candidate spec.psm = candidate
break break
else: else:
raise InvalidStateError('no free PSM') raise InvalidStateError('no free PSM')
else: else:
# Check that the PSM isn't already in use # Check that the PSM isn't already in use
if psm in self.le_coc_servers: if spec.psm in self.le_coc_servers:
raise ValueError('PSM already in use') raise ValueError('PSM already in use')
self.le_coc_servers[psm] = ( self.le_coc_servers[spec.psm] = LeCreditBasedChannelServer(
server, self,
max_credits or L2CAP_LE_CREDIT_BASED_CONNECTION_DEFAULT_INITIAL_CREDITS, spec.psm,
mtu or L2CAP_LE_CREDIT_BASED_CONNECTION_DEFAULT_MTU, handler,
mps or L2CAP_LE_CREDIT_BASED_CONNECTION_DEFAULT_MPS, max_credits=spec.max_credits,
mtu=spec.mtu,
mps=spec.mps,
) )
return psm return self.le_coc_servers[spec.psm]
def on_disconnection(self, connection_handle: int, _reason: int) -> None: def on_disconnection(self, connection_handle: int, _reason: int) -> None:
logger.debug(f'disconnection from {connection_handle}, cleaning up channels') logger.debug(f'disconnection from {connection_handle}, cleaning up channels')
@@ -1571,7 +1642,7 @@ class ChannelManager:
if connection_handle in self.identifiers: if connection_handle in self.identifiers:
del self.identifiers[connection_handle] del self.identifiers[connection_handle]
def send_pdu(self, connection, cid: int, pdu: SupportsBytes | bytes) -> None: def send_pdu(self, connection, cid: int, pdu: Union[SupportsBytes, bytes]) -> None:
pdu_str = pdu.hex() if isinstance(pdu, bytes) else str(pdu) pdu_str = pdu.hex() if isinstance(pdu, bytes) else str(pdu)
logger.debug( logger.debug(
f'{color(">>> Sending L2CAP PDU", "blue")} ' f'{color(">>> Sending L2CAP PDU", "blue")} '
@@ -1683,13 +1754,13 @@ class ChannelManager:
logger.debug( logger.debug(
f'creating server channel with cid={source_cid} for psm {request.psm}' f'creating server channel with cid={source_cid} for psm {request.psm}'
) )
channel = Channel( channel = ClassicChannel(
self, connection, cid, request.psm, source_cid, L2CAP_MIN_BR_EDR_MTU self, connection, cid, request.psm, source_cid, server.mtu
) )
connection_channels[source_cid] = channel connection_channels[source_cid] = channel
# Notify # Notify
server(channel) server.on_connection(channel)
channel.on_connection_request(request) channel.on_connection_request(request)
else: else:
logger.warning( logger.warning(
@@ -1855,7 +1926,7 @@ class ChannelManager:
supervision_timeout=request.timeout, supervision_timeout=request.timeout,
min_ce_length=0, min_ce_length=0,
max_ce_length=0, max_ce_length=0,
) # type: ignore[call-arg] )
) )
else: else:
self.send_control_frame( self.send_control_frame(
@@ -1911,7 +1982,7 @@ class ChannelManager:
self, connection: Connection, cid: int, request self, connection: Connection, cid: int, request
) -> None: ) -> None:
if request.le_psm in self.le_coc_servers: if request.le_psm in self.le_coc_servers:
(server, max_credits, mtu, mps) = self.le_coc_servers[request.le_psm] server = self.le_coc_servers[request.le_psm]
# Check that the CID isn't already used # Check that the CID isn't already used
le_connection_channels = self.le_coc_channels.setdefault( le_connection_channels = self.le_coc_channels.setdefault(
@@ -1925,8 +1996,8 @@ class ChannelManager:
L2CAP_LE_Credit_Based_Connection_Response( L2CAP_LE_Credit_Based_Connection_Response(
identifier=request.identifier, identifier=request.identifier,
destination_cid=0, destination_cid=0,
mtu=mtu, mtu=server.mtu,
mps=mps, mps=server.mps,
initial_credits=0, initial_credits=0,
# pylint: disable=line-too-long # pylint: disable=line-too-long
result=L2CAP_LE_Credit_Based_Connection_Response.CONNECTION_REFUSED_SOURCE_CID_ALREADY_ALLOCATED, result=L2CAP_LE_Credit_Based_Connection_Response.CONNECTION_REFUSED_SOURCE_CID_ALREADY_ALLOCATED,
@@ -1944,8 +2015,8 @@ class ChannelManager:
L2CAP_LE_Credit_Based_Connection_Response( L2CAP_LE_Credit_Based_Connection_Response(
identifier=request.identifier, identifier=request.identifier,
destination_cid=0, destination_cid=0,
mtu=mtu, mtu=server.mtu,
mps=mps, mps=server.mps,
initial_credits=0, initial_credits=0,
# pylint: disable=line-too-long # pylint: disable=line-too-long
result=L2CAP_LE_Credit_Based_Connection_Response.CONNECTION_REFUSED_NO_RESOURCES_AVAILABLE, result=L2CAP_LE_Credit_Based_Connection_Response.CONNECTION_REFUSED_NO_RESOURCES_AVAILABLE,
@@ -1958,18 +2029,18 @@ class ChannelManager:
f'creating LE CoC server channel with cid={source_cid} for psm ' f'creating LE CoC server channel with cid={source_cid} for psm '
f'{request.le_psm}' f'{request.le_psm}'
) )
channel = LeConnectionOrientedChannel( channel = LeCreditBasedChannel(
self, self,
connection, connection,
request.le_psm, request.le_psm,
source_cid, source_cid,
request.source_cid, request.source_cid,
mtu, server.mtu,
mps, server.mps,
request.initial_credits, request.initial_credits,
request.mtu, request.mtu,
request.mps, request.mps,
max_credits, server.max_credits,
True, True,
) )
connection_channels[source_cid] = channel connection_channels[source_cid] = channel
@@ -1982,16 +2053,16 @@ class ChannelManager:
L2CAP_LE_Credit_Based_Connection_Response( L2CAP_LE_Credit_Based_Connection_Response(
identifier=request.identifier, identifier=request.identifier,
destination_cid=source_cid, destination_cid=source_cid,
mtu=mtu, mtu=server.mtu,
mps=mps, mps=server.mps,
initial_credits=max_credits, initial_credits=server.max_credits,
# pylint: disable=line-too-long # pylint: disable=line-too-long
result=L2CAP_LE_Credit_Based_Connection_Response.CONNECTION_SUCCESSFUL, result=L2CAP_LE_Credit_Based_Connection_Response.CONNECTION_SUCCESSFUL,
), ),
) )
# Notify # Notify
server(channel) server.on_connection(channel)
else: else:
logger.info( logger.info(
f'No LE server for connection 0x{connection.handle:04X} ' f'No LE server for connection 0x{connection.handle:04X} '
@@ -2046,37 +2117,51 @@ class ChannelManager:
channel.on_credits(credit.credits) channel.on_credits(credit.credits)
def on_channel_closed(self, channel: Channel) -> None: def on_channel_closed(self, channel: ClassicChannel) -> None:
connection_channels = self.channels.get(channel.connection.handle) connection_channels = self.channels.get(channel.connection.handle)
if connection_channels: if connection_channels:
if channel.source_cid in connection_channels: if channel.source_cid in connection_channels:
del connection_channels[channel.source_cid] del connection_channels[channel.source_cid]
@deprecated("Please use create_le_credit_based_channel()")
async def open_le_coc( async def open_le_coc(
self, connection: Connection, psm: int, max_credits: int, mtu: int, mps: int self, connection: Connection, psm: int, max_credits: int, mtu: int, mps: int
) -> LeConnectionOrientedChannel: ) -> LeCreditBasedChannel:
self.check_le_coc_parameters(max_credits, mtu, mps) return await self.create_le_credit_based_channel(
connection=connection,
spec=LeCreditBasedChannelSpec(
psm=psm, max_credits=max_credits, mtu=mtu, mps=mps
),
)
async def create_le_credit_based_channel(
self,
connection: Connection,
spec: LeCreditBasedChannelSpec,
) -> LeCreditBasedChannel:
# Find a free CID for the new channel # Find a free CID for the new channel
connection_channels = self.channels.setdefault(connection.handle, {}) connection_channels = self.channels.setdefault(connection.handle, {})
source_cid = self.find_free_le_cid(connection_channels) source_cid = self.find_free_le_cid(connection_channels)
if source_cid is None: # Should never happen! if source_cid is None: # Should never happen!
raise RuntimeError('all CIDs already in use') raise RuntimeError('all CIDs already in use')
if spec.psm is None:
raise ValueError('PSM cannot be None')
# Create the channel # Create the channel
logger.debug(f'creating coc channel with cid={source_cid} for psm {psm}') logger.debug(f'creating coc channel with cid={source_cid} for psm {spec.psm}')
channel = LeConnectionOrientedChannel( channel = LeCreditBasedChannel(
manager=self, manager=self,
connection=connection, connection=connection,
le_psm=psm, le_psm=spec.psm,
source_cid=source_cid, source_cid=source_cid,
destination_cid=0, destination_cid=0,
mtu=mtu, mtu=spec.mtu,
mps=mps, mps=spec.mps,
credits=0, credits=0,
peer_mtu=0, peer_mtu=0,
peer_mps=0, peer_mps=0,
peer_credits=max_credits, peer_credits=spec.max_credits,
connected=False, connected=False,
) )
connection_channels[source_cid] = channel connection_channels[source_cid] = channel
@@ -2095,7 +2180,15 @@ class ChannelManager:
return channel return channel
async def connect(self, connection: Connection, psm: int) -> Channel: @deprecated("Please use create_classic_channel()")
async def connect(self, connection: Connection, psm: int) -> ClassicChannel:
return await self.create_classic_channel(
connection=connection, spec=ClassicChannelSpec(psm=psm)
)
async def create_classic_channel(
self, connection: Connection, spec: ClassicChannelSpec
) -> ClassicChannel:
# NOTE: this implementation hard-codes BR/EDR # NOTE: this implementation hard-codes BR/EDR
# Find a free CID for a new channel # Find a free CID for a new channel
@@ -2104,10 +2197,20 @@ class ChannelManager:
if source_cid is None: # Should never happen! if source_cid is None: # Should never happen!
raise RuntimeError('all CIDs already in use') raise RuntimeError('all CIDs already in use')
if spec.psm is None:
raise ValueError('PSM cannot be None')
# Create the channel # Create the channel
logger.debug(f'creating client channel with cid={source_cid} for psm {psm}') logger.debug(
channel = Channel( f'creating client channel with cid={source_cid} for psm {spec.psm}'
self, connection, L2CAP_SIGNALING_CID, psm, source_cid, L2CAP_MIN_BR_EDR_MTU )
channel = ClassicChannel(
self,
connection,
L2CAP_SIGNALING_CID,
spec.psm,
source_cid,
spec.mtu,
) )
connection_channels[source_cid] = channel connection_channels[source_cid] = channel
@@ -2119,3 +2222,20 @@ class ChannelManager:
raise e raise e
return channel return channel
# -----------------------------------------------------------------------------
# Deprecated Classes
# -----------------------------------------------------------------------------
class Channel(ClassicChannel):
@deprecated("Please use ClassicChannel")
def __init__(self, *args, **kwargs) -> None:
super().__init__(*args, **kwargs)
class LeConnectionOrientedChannel(LeCreditBasedChannel):
@deprecated("Please use LeCreditBasedChannel")
def __init__(self, *args, **kwargs) -> None:
super().__init__(*args, **kwargs)

View File

@@ -15,7 +15,9 @@
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
# Imports # Imports
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
from __future__ import annotations
import enum import enum
from dataclasses import dataclass
from typing import Optional, Tuple from typing import Optional, Tuple
from .hci import ( from .hci import (
@@ -35,7 +37,60 @@ from .smp import (
SMP_ID_KEY_DISTRIBUTION_FLAG, SMP_ID_KEY_DISTRIBUTION_FLAG,
SMP_SIGN_KEY_DISTRIBUTION_FLAG, SMP_SIGN_KEY_DISTRIBUTION_FLAG,
SMP_LINK_KEY_DISTRIBUTION_FLAG, SMP_LINK_KEY_DISTRIBUTION_FLAG,
OobContext,
OobLegacyContext,
OobSharedData,
) )
from .core import AdvertisingData, LeRole
# -----------------------------------------------------------------------------
@dataclass
class OobData:
"""OOB data that can be sent from one device to another."""
address: Optional[Address] = None
role: Optional[LeRole] = None
shared_data: Optional[OobSharedData] = None
legacy_context: Optional[OobLegacyContext] = None
@classmethod
def from_ad(cls, ad: AdvertisingData) -> OobData:
instance = cls()
shared_data_c: Optional[bytes] = None
shared_data_r: Optional[bytes] = None
for ad_type, ad_data in ad.ad_structures:
if ad_type == AdvertisingData.LE_BLUETOOTH_DEVICE_ADDRESS:
instance.address = Address(ad_data)
elif ad_type == AdvertisingData.LE_ROLE:
instance.role = LeRole(ad_data[0])
elif ad_type == AdvertisingData.LE_SECURE_CONNECTIONS_CONFIRMATION_VALUE:
shared_data_c = ad_data
elif ad_type == AdvertisingData.LE_SECURE_CONNECTIONS_RANDOM_VALUE:
shared_data_r = ad_data
elif ad_type == AdvertisingData.SECURITY_MANAGER_TK_VALUE:
instance.legacy_context = OobLegacyContext(tk=ad_data)
if shared_data_c and shared_data_r:
instance.shared_data = OobSharedData(c=shared_data_c, r=shared_data_r)
return instance
def to_ad(self) -> AdvertisingData:
ad_structures = []
if self.address is not None:
ad_structures.append(
(AdvertisingData.LE_BLUETOOTH_DEVICE_ADDRESS, bytes(self.address))
)
if self.role is not None:
ad_structures.append((AdvertisingData.LE_ROLE, bytes([self.role])))
if self.shared_data is not None:
ad_structures.extend(self.shared_data.to_ad().ad_structures)
if self.legacy_context is not None:
ad_structures.append(
(AdvertisingData.SECURITY_MANAGER_TK_VALUE, self.legacy_context.tk)
)
return AdvertisingData(ad_structures)
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
@@ -173,6 +228,14 @@ class PairingConfig:
PUBLIC = Address.PUBLIC_DEVICE_ADDRESS PUBLIC = Address.PUBLIC_DEVICE_ADDRESS
RANDOM = Address.RANDOM_DEVICE_ADDRESS RANDOM = Address.RANDOM_DEVICE_ADDRESS
@dataclass
class OobConfig:
"""Config for OOB pairing."""
our_context: Optional[OobContext]
peer_data: Optional[OobSharedData]
legacy_context: Optional[OobLegacyContext]
def __init__( def __init__(
self, self,
sc: bool = True, sc: bool = True,
@@ -180,17 +243,20 @@ class PairingConfig:
bonding: bool = True, bonding: bool = True,
delegate: Optional[PairingDelegate] = None, delegate: Optional[PairingDelegate] = None,
identity_address_type: Optional[AddressType] = None, identity_address_type: Optional[AddressType] = None,
oob: Optional[OobConfig] = None,
) -> None: ) -> None:
self.sc = sc self.sc = sc
self.mitm = mitm self.mitm = mitm
self.bonding = bonding self.bonding = bonding
self.delegate = delegate or PairingDelegate() self.delegate = delegate or PairingDelegate()
self.identity_address_type = identity_address_type self.identity_address_type = identity_address_type
self.oob = oob
def __str__(self) -> str: def __str__(self) -> str:
return ( return (
f'PairingConfig(sc={self.sc}, ' f'PairingConfig(sc={self.sc}, '
f'mitm={self.mitm}, bonding={self.bonding}, ' f'mitm={self.mitm}, bonding={self.bonding}, '
f'identity_address_type={self.identity_address_type}, ' f'identity_address_type={self.identity_address_type}, '
f'delegate[{self.delegate.io_capability}])' f'delegate[{self.delegate.io_capability}]), '
f'oob[{self.oob}])'
) )

View File

@@ -12,6 +12,7 @@
# 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 bumble.pairing import PairingConfig, PairingDelegate from bumble.pairing import PairingConfig, PairingDelegate
from dataclasses import dataclass from dataclasses import dataclass
from typing import Any, Dict from typing import Any, Dict

View File

@@ -14,6 +14,7 @@
"""Generic & dependency free Bumble (reference) device.""" """Generic & dependency free Bumble (reference) device."""
from __future__ import annotations
from bumble import transport from bumble import transport
from bumble.core import ( from bumble.core import (
BT_GENERIC_AUDIO_SERVICE, BT_GENERIC_AUDIO_SERVICE,

View File

@@ -12,6 +12,7 @@
# See the License for the specific language governing permissions and # See the License for the specific language governing permissions and
# limitations under the License. # limitations under the License.
from __future__ import annotations
import asyncio import asyncio
import bumble.device import bumble.device
import grpc import grpc

View File

@@ -12,7 +12,9 @@
# See the License for the specific language governing permissions and # See the License for the specific language governing permissions and
# limitations under the License. # limitations under the License.
from __future__ import annotations
import asyncio import asyncio
import contextlib
import grpc import grpc
import logging import logging
@@ -27,8 +29,8 @@ from bumble.core import (
) )
from bumble.device import Connection as BumbleConnection, Device from bumble.device import Connection as BumbleConnection, Device
from bumble.hci import HCI_Error from bumble.hci import HCI_Error
from bumble.utils import EventWatcher
from bumble.pairing import PairingConfig, PairingDelegate as BasePairingDelegate from bumble.pairing import PairingConfig, PairingDelegate as BasePairingDelegate
from contextlib import suppress
from google.protobuf import any_pb2 # pytype: disable=pyi-error from google.protobuf import any_pb2 # pytype: disable=pyi-error
from google.protobuf import empty_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 google.protobuf import wrappers_pb2 # pytype: disable=pyi-error
@@ -232,7 +234,11 @@ class SecurityService(SecurityServicer):
sc=config.pairing_sc_enable, sc=config.pairing_sc_enable,
mitm=config.pairing_mitm_enable, mitm=config.pairing_mitm_enable,
bonding=config.pairing_bonding_enable, bonding=config.pairing_bonding_enable,
identity_address_type=config.identity_address_type, identity_address_type=(
PairingConfig.AddressType.PUBLIC
if connection.self_address.is_public
else config.identity_address_type
),
delegate=PairingDelegate( delegate=PairingDelegate(
connection, connection,
self, self,
@@ -294,23 +300,35 @@ class SecurityService(SecurityServicer):
try: try:
self.log.debug('Pair...') self.log.debug('Pair...')
if ( security_result = asyncio.get_running_loop().create_future()
connection.transport == BT_LE_TRANSPORT
and connection.role == BT_PERIPHERAL_ROLE
):
wait_for_security: asyncio.Future[
bool
] = asyncio.get_running_loop().create_future()
connection.on("pairing", lambda *_: wait_for_security.set_result(True)) # type: ignore
connection.on("pairing_failure", wait_for_security.set_exception)
connection.request_pairing() with contextlib.closing(EventWatcher()) as watcher:
await wait_for_security @watcher.on(connection, 'pairing')
else: def on_pairing(*_: Any) -> None:
await connection.pair() security_result.set_result('success')
self.log.debug('Paired') @watcher.on(connection, 'pairing_failure')
def on_pairing_failure(*_: Any) -> None:
security_result.set_result('pairing_failure')
@watcher.on(connection, 'disconnection')
def on_disconnection(*_: Any) -> None:
security_result.set_result('connection_died')
if (
connection.transport == BT_LE_TRANSPORT
and connection.role == BT_PERIPHERAL_ROLE
):
connection.request_pairing()
else:
await connection.pair()
result = await security_result
self.log.debug(f'Pairing session complete, status={result}')
if result != 'success':
return SecureResponse(**{result: empty_pb2.Empty()})
except asyncio.CancelledError: except asyncio.CancelledError:
self.log.warning("Connection died during encryption") self.log.warning("Connection died during encryption")
return SecureResponse(connection_died=empty_pb2.Empty()) return SecureResponse(connection_died=empty_pb2.Empty())
@@ -369,6 +387,7 @@ class SecurityService(SecurityServicer):
str str
] = asyncio.get_running_loop().create_future() ] = asyncio.get_running_loop().create_future()
authenticate_task: Optional[asyncio.Future[None]] = None authenticate_task: Optional[asyncio.Future[None]] = None
pair_task: Optional[asyncio.Future[None]] = None
async def authenticate() -> None: async def authenticate() -> None:
assert connection assert connection
@@ -415,6 +434,10 @@ class SecurityService(SecurityServicer):
if authenticate_task is None: if authenticate_task is None:
authenticate_task = asyncio.create_task(authenticate()) authenticate_task = asyncio.create_task(authenticate())
def pair(*_: Any) -> None:
if self.need_pairing(connection, level):
pair_task = asyncio.create_task(connection.pair())
listeners: Dict[str, Callable[..., None]] = { listeners: Dict[str, Callable[..., None]] = {
'disconnection': set_failure('connection_died'), 'disconnection': set_failure('connection_died'),
'pairing_failure': set_failure('pairing_failure'), 'pairing_failure': set_failure('pairing_failure'),
@@ -425,23 +448,21 @@ class SecurityService(SecurityServicer):
'connection_encryption_change': on_encryption_change, 'connection_encryption_change': on_encryption_change,
'classic_pairing': try_set_success, 'classic_pairing': try_set_success,
'classic_pairing_failure': set_failure('pairing_failure'), 'classic_pairing_failure': set_failure('pairing_failure'),
'security_request': pair,
} }
# register event handlers with contextlib.closing(EventWatcher()) as watcher:
for event, listener in listeners.items(): # register event handlers
connection.on(event, listener) for event, listener in listeners.items():
watcher.on(connection, event, listener)
# security level already reached # security level already reached
if self.reached_security_level(connection, level): if self.reached_security_level(connection, level):
return WaitSecurityResponse(success=empty_pb2.Empty()) return WaitSecurityResponse(success=empty_pb2.Empty())
self.log.debug('Wait for security...') self.log.debug('Wait for security...')
kwargs = {} kwargs = {}
kwargs[await wait_for_security] = empty_pb2.Empty() kwargs[await wait_for_security] = empty_pb2.Empty()
# remove event handlers
for event, listener in listeners.items():
connection.remove_listener(event, listener) # type: ignore
# wait for `authenticate` to finish if any # wait for `authenticate` to finish if any
if authenticate_task is not None: if authenticate_task is not None:
@@ -452,6 +473,15 @@ class SecurityService(SecurityServicer):
pass pass
self.log.debug('Authenticated') self.log.debug('Authenticated')
# wait for `pair` to finish if any
if pair_task is not None:
self.log.debug('Wait for authentication...')
try:
await pair_task # type: ignore
except:
pass
self.log.debug('paired')
return WaitSecurityResponse(**kwargs) return WaitSecurityResponse(**kwargs)
def reached_security_level( def reached_security_level(
@@ -523,7 +553,7 @@ class SecurityStorageService(SecurityStorageServicer):
self.log.debug(f"DeleteBond: {address}") self.log.debug(f"DeleteBond: {address}")
if self.device.keystore is not None: if self.device.keystore is not None:
with suppress(KeyError): with contextlib.suppress(KeyError):
await self.device.keystore.delete(str(address)) await self.device.keystore.delete(str(address))
return empty_pb2.Empty() return empty_pb2.Empty()

View File

@@ -12,6 +12,7 @@
# 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 contextlib import contextlib
import functools import functools
import grpc import grpc

View File

@@ -19,6 +19,8 @@
import struct import struct
import logging import logging
from typing import List from typing import List
from bumble import l2cap
from ..core import AdvertisingData from ..core import AdvertisingData
from ..device import Device, Connection from ..device import Device, Connection
from ..gatt import ( from ..gatt import (
@@ -149,7 +151,10 @@ class AshaService(TemplateService):
channel.sink = on_data channel.sink = on_data
# let the server find a free PSM # let the server find a free PSM
self.psm = self.device.register_l2cap_channel_server(self.psm, on_coc, 8) self.psm = device.create_l2cap_server(
spec=l2cap.LeCreditBasedChannelSpec(psm=self.psm, max_credits=8),
handler=on_coc,
).psm
self.le_psm_out_characteristic = Characteristic( self.le_psm_out_characteristic = Characteristic(
GATT_ASHA_LE_PSM_OUT_CHARACTERISTIC, GATT_ASHA_LE_PSM_OUT_CHARACTERISTIC,
Characteristic.Properties.READ, Characteristic.Properties.READ,

496
bumble/profiles/bap.py Normal file
View File

@@ -0,0 +1,496 @@
# Copyright 2021-2023 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
from collections.abc import Sequence
import dataclasses
import enum
import struct
import functools
from typing import Optional, List, Union
from bumble import hci
from bumble import gatt
from bumble import gatt_client
# -----------------------------------------------------------------------------
# Constants
# -----------------------------------------------------------------------------
class AudioLocation(enum.IntFlag):
'''Bluetooth Assigned Numbers, Section 6.12.1 - Audio Location'''
# fmt: off
NOT_ALLOWED = 0x00000000
FRONT_LEFT = 0x00000001
FRONT_RIGHT = 0x00000002
FRONT_CENTER = 0x00000004
LOW_FREQUENCY_EFFECTS_1 = 0x00000008
BACK_LEFT = 0x00000010
BACK_RIGHT = 0x00000020
FRONT_LEFT_OF_CENTER = 0x00000040
FRONT_RIGHT_OF_CENTER = 0x00000080
BACK_CENTER = 0x00000100
LOW_FREQUENCY_EFFECTS_2 = 0x00000200
SIDE_LEFT = 0x00000400
SIDE_RIGHT = 0x00000800
TOP_FRONT_LEFT = 0x00001000
TOP_FRONT_RIGHT = 0x00002000
TOP_FRONT_CENTER = 0x00004000
TOP_CENTER = 0x00008000
TOP_BACK_LEFT = 0x00010000
TOP_BACK_RIGHT = 0x00020000
TOP_SIDE_LEFT = 0x00040000
TOP_SIDE_RIGHT = 0x00080000
TOP_BACK_CENTER = 0x00100000
BOTTOM_FRONT_CENTER = 0x00200000
BOTTOM_FRONT_LEFT = 0x00400000
BOTTOM_FRONT_RIGHT = 0x00800000
FRONT_LEFT_WIDE = 0x01000000
FRONT_RIGHT_WIDE = 0x02000000
LEFT_SURROUND = 0x04000000
RIGHT_SURROUND = 0x08000000
class AudioInputType(enum.IntEnum):
'''Bluetooth Assigned Numbers, Section 6.12.2 - Audio Input Type'''
# fmt: off
UNSPECIFIED = 0x00
BLUETOOTH = 0x01
MICROPHONE = 0x02
ANALOG = 0x03
DIGITAL = 0x04
RADIO = 0x05
STREAMING = 0x06
AMBIENT = 0x07
class ContextType(enum.IntFlag):
'''Bluetooth Assigned Numbers, Section 6.12.3 - Context Type'''
# fmt: off
PROHIBITED = 0x0000
CONVERSATIONAL = 0x0002
MEDIA = 0x0004
GAME = 0x0008
INSTRUCTIONAL = 0x0010
VOICE_ASSISTANTS = 0x0020
LIVE = 0x0040
SOUND_EFFECTS = 0x0080
NOTIFICATIONS = 0x0100
RINGTONE = 0x0200
ALERTS = 0x0400
EMERGENCY_ALARM = 0x0800
class SamplingFrequency(enum.IntEnum):
'''Bluetooth Assigned Numbers, Section 6.12.5.1 - Sampling Frequency'''
# fmt: off
FREQ_8000 = 0x01
FREQ_11025 = 0x02
FREQ_16000 = 0x03
FREQ_22050 = 0x04
FREQ_24000 = 0x05
FREQ_32000 = 0x06
FREQ_44100 = 0x07
FREQ_48000 = 0x08
FREQ_88200 = 0x09
FREQ_96000 = 0x0A
FREQ_176400 = 0x0B
FREQ_192000 = 0x0C
FREQ_384000 = 0x0D
# fmt: on
@classmethod
def from_hz(cls, frequency: int) -> SamplingFrequency:
return {
8000: SamplingFrequency.FREQ_8000,
11025: SamplingFrequency.FREQ_11025,
16000: SamplingFrequency.FREQ_16000,
22050: SamplingFrequency.FREQ_22050,
24000: SamplingFrequency.FREQ_24000,
32000: SamplingFrequency.FREQ_32000,
44100: SamplingFrequency.FREQ_44100,
48000: SamplingFrequency.FREQ_48000,
88200: SamplingFrequency.FREQ_88200,
96000: SamplingFrequency.FREQ_96000,
176400: SamplingFrequency.FREQ_176400,
192000: SamplingFrequency.FREQ_192000,
384000: SamplingFrequency.FREQ_384000,
}[frequency]
@property
def hz(self) -> int:
return {
SamplingFrequency.FREQ_8000: 8000,
SamplingFrequency.FREQ_11025: 11025,
SamplingFrequency.FREQ_16000: 16000,
SamplingFrequency.FREQ_22050: 22050,
SamplingFrequency.FREQ_24000: 24000,
SamplingFrequency.FREQ_32000: 32000,
SamplingFrequency.FREQ_44100: 44100,
SamplingFrequency.FREQ_48000: 48000,
SamplingFrequency.FREQ_88200: 88200,
SamplingFrequency.FREQ_96000: 96000,
SamplingFrequency.FREQ_176400: 176400,
SamplingFrequency.FREQ_192000: 192000,
SamplingFrequency.FREQ_384000: 384000,
}[self]
class SupportedSamplingFrequency(enum.IntFlag):
'''Bluetooth Assigned Numbers, Section 6.12.4.1 - Sample Frequency'''
# fmt: off
FREQ_8000 = 1 << (SamplingFrequency.FREQ_8000 - 1)
FREQ_11025 = 1 << (SamplingFrequency.FREQ_11025 - 1)
FREQ_16000 = 1 << (SamplingFrequency.FREQ_16000 - 1)
FREQ_22050 = 1 << (SamplingFrequency.FREQ_22050 - 1)
FREQ_24000 = 1 << (SamplingFrequency.FREQ_24000 - 1)
FREQ_32000 = 1 << (SamplingFrequency.FREQ_32000 - 1)
FREQ_44100 = 1 << (SamplingFrequency.FREQ_44100 - 1)
FREQ_48000 = 1 << (SamplingFrequency.FREQ_48000 - 1)
FREQ_88200 = 1 << (SamplingFrequency.FREQ_88200 - 1)
FREQ_96000 = 1 << (SamplingFrequency.FREQ_96000 - 1)
FREQ_176400 = 1 << (SamplingFrequency.FREQ_176400 - 1)
FREQ_192000 = 1 << (SamplingFrequency.FREQ_192000 - 1)
FREQ_384000 = 1 << (SamplingFrequency.FREQ_384000 - 1)
# fmt: on
@classmethod
def from_hz(cls, frequencies: Sequence[int]) -> SupportedSamplingFrequency:
MAPPING = {
8000: SupportedSamplingFrequency.FREQ_8000,
11025: SupportedSamplingFrequency.FREQ_11025,
16000: SupportedSamplingFrequency.FREQ_16000,
22050: SupportedSamplingFrequency.FREQ_22050,
24000: SupportedSamplingFrequency.FREQ_24000,
32000: SupportedSamplingFrequency.FREQ_32000,
44100: SupportedSamplingFrequency.FREQ_44100,
48000: SupportedSamplingFrequency.FREQ_48000,
88200: SupportedSamplingFrequency.FREQ_88200,
96000: SupportedSamplingFrequency.FREQ_96000,
176400: SupportedSamplingFrequency.FREQ_176400,
192000: SupportedSamplingFrequency.FREQ_192000,
384000: SupportedSamplingFrequency.FREQ_384000,
}
return functools.reduce(
lambda x, y: x | MAPPING[y],
frequencies,
cls(0),
)
class FrameDuration(enum.IntEnum):
'''Bluetooth Assigned Numbers, Section 6.12.5.2 - Frame Duration'''
# fmt: off
DURATION_7500_US = 0x00
DURATION_10000_US = 0x01
class SupportedFrameDuration(enum.IntFlag):
'''Bluetooth Assigned Numbers, Section 6.12.4.2 - Frame Duration'''
# fmt: off
DURATION_7500_US_SUPPORTED = 0b0001
DURATION_10000_US_SUPPORTED = 0b0010
DURATION_7500_US_PREFERRED = 0b0001
DURATION_10000_US_PREFERRED = 0b0010
# -----------------------------------------------------------------------------
# Utils
# -----------------------------------------------------------------------------
def bits_to_channel_counts(data: int) -> List[int]:
pos = 0
counts = []
while data != 0:
# Bit 0 = count 1
# Bit 1 = count 2, and so on
pos += 1
if data & 1:
counts.append(pos)
data >>= 1
return counts
def channel_counts_to_bits(counts: Sequence[int]) -> int:
return sum(set([1 << (count - 1) for count in counts]))
# -----------------------------------------------------------------------------
# Structures
# -----------------------------------------------------------------------------
@dataclasses.dataclass
class CodecSpecificCapabilities:
'''See:
* Bluetooth Assigned Numbers, 6.12.4 - Codec Specific Capabilities LTV Structures
* Basic Audio Profile, 4.3.1 - Codec_Specific_Capabilities LTV requirements
'''
class Type(enum.IntEnum):
# fmt: off
SAMPLING_FREQUENCY = 0x01
FRAME_DURATION = 0x02
AUDIO_CHANNEL_COUNT = 0x03
OCTETS_PER_FRAME = 0x04
CODEC_FRAMES_PER_SDU = 0x05
supported_sampling_frequencies: SupportedSamplingFrequency
supported_frame_durations: SupportedFrameDuration
supported_audio_channel_counts: Sequence[int]
min_octets_per_codec_frame: int
max_octets_per_codec_frame: int
supported_max_codec_frames_per_sdu: int
@classmethod
def from_bytes(cls, data: bytes) -> CodecSpecificCapabilities:
offset = 0
# Allowed default values.
supported_audio_channel_counts = [1]
supported_max_codec_frames_per_sdu = 1
while offset < len(data):
length, type = struct.unpack_from('BB', data, offset)
offset += 2
value = int.from_bytes(data[offset : offset + length - 1], 'little')
offset += length - 1
if type == CodecSpecificCapabilities.Type.SAMPLING_FREQUENCY:
supported_sampling_frequencies = SupportedSamplingFrequency(value)
elif type == CodecSpecificCapabilities.Type.FRAME_DURATION:
supported_frame_durations = SupportedFrameDuration(value)
elif type == CodecSpecificCapabilities.Type.AUDIO_CHANNEL_COUNT:
supported_audio_channel_counts = bits_to_channel_counts(value)
elif type == CodecSpecificCapabilities.Type.OCTETS_PER_FRAME:
min_octets_per_sample = value & 0xFFFF
max_octets_per_sample = value >> 16
elif type == 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.
return CodecSpecificCapabilities(
supported_sampling_frequencies=supported_sampling_frequencies,
supported_frame_durations=supported_frame_durations,
supported_audio_channel_counts=supported_audio_channel_counts,
min_octets_per_codec_frame=min_octets_per_sample,
max_octets_per_codec_frame=max_octets_per_sample,
supported_max_codec_frames_per_sdu=supported_max_codec_frames_per_sdu,
)
def __bytes__(self) -> bytes:
return struct.pack(
'<BBHBBBBBBBBHHBBB',
3,
CodecSpecificCapabilities.Type.SAMPLING_FREQUENCY,
self.supported_sampling_frequencies,
2,
CodecSpecificCapabilities.Type.FRAME_DURATION,
self.supported_frame_durations,
2,
CodecSpecificCapabilities.Type.AUDIO_CHANNEL_COUNT,
channel_counts_to_bits(self.supported_audio_channel_counts),
5,
CodecSpecificCapabilities.Type.OCTETS_PER_FRAME,
self.min_octets_per_codec_frame,
self.max_octets_per_codec_frame,
2,
CodecSpecificCapabilities.Type.CODEC_FRAMES_PER_SDU,
self.supported_max_codec_frames_per_sdu,
)
@dataclasses.dataclass
class PacRecord:
coding_format: hci.CodingFormat
codec_specific_capabilities: Union[CodecSpecificCapabilities, bytes]
# TODO: Parse Metadata
metadata: bytes = b''
@classmethod
def from_bytes(cls, data: bytes) -> PacRecord:
offset, coding_format = hci.CodingFormat.parse_from_bytes(data, 0)
codec_specific_capabilities_size = data[offset]
offset += 1
codec_specific_capabilities_bytes = data[
offset : offset + codec_specific_capabilities_size
]
offset += codec_specific_capabilities_size
metadata_size = data[offset]
metadata = data[offset : offset + metadata_size]
codec_specific_capabilities: Union[CodecSpecificCapabilities, bytes]
if coding_format.codec_id == hci.CodecID.VENDOR_SPECIFIC:
codec_specific_capabilities = codec_specific_capabilities_bytes
else:
codec_specific_capabilities = CodecSpecificCapabilities.from_bytes(
codec_specific_capabilities_bytes
)
return PacRecord(
coding_format=coding_format,
codec_specific_capabilities=codec_specific_capabilities,
metadata=metadata,
)
def __bytes__(self) -> bytes:
capabilities_bytes = bytes(self.codec_specific_capabilities)
return (
bytes(self.coding_format)
+ bytes([len(capabilities_bytes)])
+ capabilities_bytes
+ bytes([len(self.metadata)])
+ self.metadata
)
# -----------------------------------------------------------------------------
# Server
# -----------------------------------------------------------------------------
class PublishedAudioCapabilitiesService(gatt.TemplateService):
UUID = gatt.GATT_PUBLISHED_AUDIO_CAPABILITIES_SERVICE
sink_pac: Optional[gatt.Characteristic]
sink_audio_locations: Optional[gatt.Characteristic]
source_pac: Optional[gatt.Characteristic]
source_audio_locations: Optional[gatt.Characteristic]
available_audio_contexts: gatt.Characteristic
supported_audio_contexts: gatt.Characteristic
def __init__(
self,
supported_source_context: ContextType,
supported_sink_context: ContextType,
available_source_context: ContextType,
available_sink_context: ContextType,
sink_pac: Sequence[PacRecord] = [],
sink_audio_locations: Optional[AudioLocation] = None,
source_pac: Sequence[PacRecord] = [],
source_audio_locations: Optional[AudioLocation] = None,
) -> None:
characteristics = []
self.supported_audio_contexts = gatt.Characteristic(
uuid=gatt.GATT_SUPPORTED_AUDIO_CONTEXTS_CHARACTERISTIC,
properties=gatt.Characteristic.Properties.READ,
permissions=gatt.Characteristic.Permissions.READABLE,
value=struct.pack('<HH', supported_sink_context, supported_source_context),
)
characteristics.append(self.supported_audio_contexts)
self.available_audio_contexts = gatt.Characteristic(
uuid=gatt.GATT_AVAILABLE_AUDIO_CONTEXTS_CHARACTERISTIC,
properties=gatt.Characteristic.Properties.READ
| gatt.Characteristic.Properties.NOTIFY,
permissions=gatt.Characteristic.Permissions.READABLE,
value=struct.pack('<HH', available_sink_context, available_source_context),
)
characteristics.append(self.available_audio_contexts)
if sink_pac:
self.sink_pac = gatt.Characteristic(
uuid=gatt.GATT_SINK_PAC_CHARACTERISTIC,
properties=gatt.Characteristic.Properties.READ,
permissions=gatt.Characteristic.Permissions.READABLE,
value=bytes([len(sink_pac)]) + b''.join(map(bytes, sink_pac)),
)
characteristics.append(self.sink_pac)
if sink_audio_locations is not None:
self.sink_audio_locations = gatt.Characteristic(
uuid=gatt.GATT_SINK_AUDIO_LOCATION_CHARACTERISTIC,
properties=gatt.Characteristic.Properties.READ,
permissions=gatt.Characteristic.Permissions.READABLE,
value=struct.pack('<I', sink_audio_locations),
)
characteristics.append(self.sink_audio_locations)
if source_pac:
self.source_pac = gatt.Characteristic(
uuid=gatt.GATT_SOURCE_PAC_CHARACTERISTIC,
properties=gatt.Characteristic.Properties.READ,
permissions=gatt.Characteristic.Permissions.READABLE,
value=bytes([len(source_pac)]) + b''.join(map(bytes, source_pac)),
)
characteristics.append(self.source_pac)
if source_audio_locations is not None:
self.source_audio_locations = gatt.Characteristic(
uuid=gatt.GATT_SOURCE_AUDIO_LOCATION_CHARACTERISTIC,
properties=gatt.Characteristic.Properties.READ,
permissions=gatt.Characteristic.Permissions.READABLE,
value=struct.pack('<I', source_audio_locations),
)
characteristics.append(self.source_audio_locations)
super().__init__(characteristics)
# -----------------------------------------------------------------------------
# Client
# -----------------------------------------------------------------------------
class PublishedAudioCapabilitiesServiceProxy(gatt_client.ProfileServiceProxy):
SERVICE_CLASS = PublishedAudioCapabilitiesService
sink_pac: Optional[gatt_client.CharacteristicProxy] = None
sink_audio_locations: Optional[gatt_client.CharacteristicProxy] = None
source_pac: Optional[gatt_client.CharacteristicProxy] = None
source_audio_locations: Optional[gatt_client.CharacteristicProxy] = None
available_audio_contexts: gatt_client.CharacteristicProxy
supported_audio_contexts: gatt_client.CharacteristicProxy
def __init__(self, service_proxy: gatt_client.ServiceProxy):
self.service_proxy = service_proxy
self.available_audio_contexts = service_proxy.get_characteristics_by_uuid(
gatt.GATT_AVAILABLE_AUDIO_CONTEXTS_CHARACTERISTIC
)[0]
self.supported_audio_contexts = service_proxy.get_characteristics_by_uuid(
gatt.GATT_SUPPORTED_AUDIO_CONTEXTS_CHARACTERISTIC
)[0]
if characteristics := service_proxy.get_characteristics_by_uuid(
gatt.GATT_SINK_PAC_CHARACTERISTIC
):
self.sink_pac = characteristics[0]
if characteristics := service_proxy.get_characteristics_by_uuid(
gatt.GATT_SOURCE_PAC_CHARACTERISTIC
):
self.source_pac = characteristics[0]
if characteristics := service_proxy.get_characteristics_by_uuid(
gatt.GATT_SINK_AUDIO_LOCATION_CHARACTERISTIC
):
self.sink_audio_locations = characteristics[0]
if characteristics := service_proxy.get_characteristics_by_uuid(
gatt.GATT_SOURCE_AUDIO_LOCATION_CHARACTERISTIC
):
self.source_audio_locations = characteristics[0]

147
bumble/profiles/csip.py Normal file
View File

@@ -0,0 +1,147 @@
# Copyright 2021-2023 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 enum
import struct
from typing import Optional
from bumble import gatt
from bumble import gatt_client
# -----------------------------------------------------------------------------
# Constants
# -----------------------------------------------------------------------------
class SirkType(enum.IntEnum):
'''Coordinated Set Identification Service - 5.1 Set Identity Resolving Key.'''
ENCRYPTED = 0x00
PLAINTEXT = 0x01
class MemberLock(enum.IntEnum):
'''Coordinated Set Identification Service - 5.3 Set Member Lock.'''
UNLOCKED = 0x01
LOCKED = 0x02
# -----------------------------------------------------------------------------
# Utils
# -----------------------------------------------------------------------------
# TODO: Implement RSI Generator
# -----------------------------------------------------------------------------
# Server
# -----------------------------------------------------------------------------
class CoordinatedSetIdentificationService(gatt.TemplateService):
UUID = gatt.GATT_COORDINATED_SET_IDENTIFICATION_SERVICE
set_identity_resolving_key_characteristic: gatt.Characteristic
coordinated_set_size_characteristic: Optional[gatt.Characteristic] = None
set_member_lock_characteristic: Optional[gatt.Characteristic] = None
set_member_rank_characteristic: Optional[gatt.Characteristic] = None
def __init__(
self,
set_identity_resolving_key: bytes,
coordinated_set_size: Optional[int] = None,
set_member_lock: Optional[MemberLock] = None,
set_member_rank: Optional[int] = None,
) -> None:
characteristics = []
self.set_identity_resolving_key_characteristic = gatt.Characteristic(
uuid=gatt.GATT_SET_IDENTITY_RESOLVING_KEY_CHARACTERISTIC,
properties=gatt.Characteristic.Properties.READ
| gatt.Characteristic.Properties.NOTIFY,
permissions=gatt.Characteristic.Permissions.READABLE,
# TODO: Implement encrypted SIRK reader.
value=struct.pack('B', SirkType.PLAINTEXT) + set_identity_resolving_key,
)
characteristics.append(self.set_identity_resolving_key_characteristic)
if coordinated_set_size is not None:
self.coordinated_set_size_characteristic = gatt.Characteristic(
uuid=gatt.GATT_COORDINATED_SET_SIZE_CHARACTERISTIC,
properties=gatt.Characteristic.Properties.READ
| gatt.Characteristic.Properties.NOTIFY,
permissions=gatt.Characteristic.Permissions.READABLE,
value=struct.pack('B', coordinated_set_size),
)
characteristics.append(self.coordinated_set_size_characteristic)
if set_member_lock is not None:
self.set_member_lock_characteristic = gatt.Characteristic(
uuid=gatt.GATT_SET_MEMBER_LOCK_CHARACTERISTIC,
properties=gatt.Characteristic.Properties.READ
| gatt.Characteristic.Properties.NOTIFY
| gatt.Characteristic.Properties.WRITE,
permissions=gatt.Characteristic.Permissions.READABLE
| gatt.Characteristic.Permissions.WRITEABLE,
value=struct.pack('B', set_member_lock),
)
characteristics.append(self.set_member_lock_characteristic)
if set_member_rank is not None:
self.set_member_rank_characteristic = gatt.Characteristic(
uuid=gatt.GATT_SET_MEMBER_RANK_CHARACTERISTIC,
properties=gatt.Characteristic.Properties.READ
| gatt.Characteristic.Properties.NOTIFY,
permissions=gatt.Characteristic.Permissions.READABLE,
value=struct.pack('B', set_member_rank),
)
characteristics.append(self.set_member_rank_characteristic)
super().__init__(characteristics)
# -----------------------------------------------------------------------------
# Client
# -----------------------------------------------------------------------------
class CoordinatedSetIdentificationProxy(gatt_client.ProfileServiceProxy):
SERVICE_CLASS = CoordinatedSetIdentificationService
set_identity_resolving_key: gatt_client.CharacteristicProxy
coordinated_set_size: Optional[gatt_client.CharacteristicProxy] = None
set_member_lock: Optional[gatt_client.CharacteristicProxy] = None
set_member_rank: Optional[gatt_client.CharacteristicProxy] = None
def __init__(self, service_proxy: gatt_client.ServiceProxy) -> None:
self.service_proxy = service_proxy
self.set_identity_resolving_key = service_proxy.get_characteristics_by_uuid(
gatt.GATT_SET_IDENTITY_RESOLVING_KEY_CHARACTERISTIC
)[0]
if characteristics := service_proxy.get_characteristics_by_uuid(
gatt.GATT_COORDINATED_SET_SIZE_CHARACTERISTIC
):
self.coordinated_set_size = characteristics[0]
if characteristics := service_proxy.get_characteristics_by_uuid(
gatt.GATT_SET_MEMBER_LOCK_CHARACTERISTIC
):
self.set_member_lock = characteristics[0]
if characteristics := service_proxy.get_characteristics_by_uuid(
gatt.GATT_SET_MEMBER_RANK_CHARACTERISTIC
):
self.set_member_rank = characteristics[0]

View File

@@ -42,12 +42,12 @@ class HeartRateService(TemplateService):
RESET_ENERGY_EXPENDED = 0x01 RESET_ENERGY_EXPENDED = 0x01
class BodySensorLocation(IntEnum): class BodySensorLocation(IntEnum):
OTHER = (0,) OTHER = 0
CHEST = (1,) CHEST = 1
WRIST = (2,) WRIST = 2
FINGER = (3,) FINGER = 3
HAND = (4,) HAND = 4
EAR_LOBE = (5,) EAR_LOBE = 5
FOOT = 6 FOOT = 6
class HeartRateMeasurement: class HeartRateMeasurement:

View File

@@ -674,7 +674,7 @@ class Multiplexer(EventEmitter):
acceptor: Optional[Callable[[int], bool]] acceptor: Optional[Callable[[int], bool]]
dlcs: Dict[int, DLC] dlcs: Dict[int, DLC]
def __init__(self, l2cap_channel: l2cap.Channel, role: Role) -> None: def __init__(self, l2cap_channel: l2cap.ClassicChannel, role: Role) -> None:
super().__init__() super().__init__()
self.role = role self.role = role
self.l2cap_channel = l2cap_channel self.l2cap_channel = l2cap_channel
@@ -887,10 +887,9 @@ class Multiplexer(EventEmitter):
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
class Client: class Client:
multiplexer: Optional[Multiplexer] multiplexer: Optional[Multiplexer]
l2cap_channel: Optional[l2cap.Channel] l2cap_channel: Optional[l2cap.ClassicChannel]
def __init__(self, device: Device, connection: Connection) -> None: def __init__(self, connection: Connection) -> None:
self.device = device
self.connection = connection self.connection = connection
self.l2cap_channel = None self.l2cap_channel = None
self.multiplexer = None self.multiplexer = None
@@ -898,15 +897,15 @@ class Client:
async def start(self) -> Multiplexer: async def start(self) -> Multiplexer:
# Create a new L2CAP connection # Create a new L2CAP connection
try: try:
self.l2cap_channel = await self.device.l2cap_channel_manager.connect( self.l2cap_channel = await self.connection.create_l2cap_channel(
self.connection, RFCOMM_PSM spec=l2cap.ClassicChannelSpec(RFCOMM_PSM)
) )
except ProtocolError as error: except ProtocolError as error:
logger.warning(f'L2CAP connection failed: {error}') logger.warning(f'L2CAP connection failed: {error}')
raise raise
assert self.l2cap_channel is not None assert self.l2cap_channel is not None
# Create a mutliplexer to manage DLCs with the server # Create a multiplexer to manage DLCs with the server
self.multiplexer = Multiplexer(self.l2cap_channel, Multiplexer.Role.INITIATOR) self.multiplexer = Multiplexer(self.l2cap_channel, Multiplexer.Role.INITIATOR)
# Connect the multiplexer # Connect the multiplexer
@@ -936,7 +935,9 @@ class Server(EventEmitter):
self.acceptors = {} self.acceptors = {}
# Register ourselves with the L2CAP channel manager # Register ourselves with the L2CAP channel manager
device.register_l2cap_server(RFCOMM_PSM, self.on_connection) device.create_l2cap_server(
spec=l2cap.ClassicChannelSpec(psm=RFCOMM_PSM), handler=self.on_connection
)
def listen(self, acceptor: Callable[[DLC], None], channel: int = 0) -> int: def listen(self, acceptor: Callable[[DLC], None], channel: int = 0) -> int:
if channel: if channel:
@@ -960,11 +961,11 @@ class Server(EventEmitter):
self.acceptors[channel] = acceptor self.acceptors[channel] = acceptor
return channel return channel
def on_connection(self, l2cap_channel: l2cap.Channel) -> None: def on_connection(self, l2cap_channel: l2cap.ClassicChannel) -> None:
logger.debug(f'+++ new L2CAP connection: {l2cap_channel}') logger.debug(f'+++ new L2CAP connection: {l2cap_channel}')
l2cap_channel.on('open', lambda: self.on_l2cap_channel_open(l2cap_channel)) l2cap_channel.on('open', lambda: self.on_l2cap_channel_open(l2cap_channel))
def on_l2cap_channel_open(self, l2cap_channel: l2cap.Channel) -> None: def on_l2cap_channel_open(self, l2cap_channel: l2cap.ClassicChannel) -> None:
logger.debug(f'$$$ L2CAP channel open: {l2cap_channel}') logger.debug(f'$$$ L2CAP channel open: {l2cap_channel}')
# Create a new multiplexer for the channel # Create a new multiplexer for the channel

View File

@@ -167,7 +167,7 @@ class DataElement:
UUID: lambda x: DataElement( UUID: lambda x: DataElement(
DataElement.UUID, core.UUID.from_bytes(bytes(reversed(x))) DataElement.UUID, core.UUID.from_bytes(bytes(reversed(x)))
), ),
TEXT_STRING: lambda x: DataElement(DataElement.TEXT_STRING, x.decode('utf8')), TEXT_STRING: lambda x: DataElement(DataElement.TEXT_STRING, x),
BOOLEAN: lambda x: DataElement(DataElement.BOOLEAN, x[0] == 1), BOOLEAN: lambda x: DataElement(DataElement.BOOLEAN, x[0] == 1),
SEQUENCE: lambda x: DataElement( SEQUENCE: lambda x: DataElement(
DataElement.SEQUENCE, DataElement.list_from_bytes(x) DataElement.SEQUENCE, DataElement.list_from_bytes(x)
@@ -229,7 +229,7 @@ class DataElement:
return DataElement(DataElement.UUID, value) return DataElement(DataElement.UUID, value)
@staticmethod @staticmethod
def text_string(value: str) -> DataElement: def text_string(value: bytes) -> DataElement:
return DataElement(DataElement.TEXT_STRING, value) return DataElement(DataElement.TEXT_STRING, value)
@staticmethod @staticmethod
@@ -376,7 +376,7 @@ class DataElement:
raise ValueError('invalid value_size') raise ValueError('invalid value_size')
elif self.type == DataElement.UUID: elif self.type == DataElement.UUID:
data = bytes(reversed(bytes(self.value))) data = bytes(reversed(bytes(self.value)))
elif self.type in (DataElement.TEXT_STRING, DataElement.URL): elif self.type == DataElement.URL:
data = self.value.encode('utf8') data = self.value.encode('utf8')
elif self.type == DataElement.BOOLEAN: elif self.type == DataElement.BOOLEAN:
data = bytes([1 if self.value else 0]) data = bytes([1 if self.value else 0])
@@ -758,16 +758,17 @@ class SDP_ServiceSearchAttributeResponse(SDP_PDU):
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
class Client: class Client:
channel: Optional[l2cap.Channel] channel: Optional[l2cap.ClassicChannel]
def __init__(self, device: Device) -> None: def __init__(self, connection: Connection) -> None:
self.device = device self.connection = connection
self.pending_request = None self.pending_request = None
self.channel = None self.channel = None
async def connect(self, connection: Connection) -> None: async def connect(self) -> None:
result = await self.device.l2cap_channel_manager.connect(connection, SDP_PSM) self.channel = await self.connection.create_l2cap_channel(
self.channel = result spec=l2cap.ClassicChannelSpec(SDP_PSM)
)
async def disconnect(self) -> None: async def disconnect(self) -> None:
if self.channel: if self.channel:
@@ -921,7 +922,7 @@ class Client:
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
class Server: class Server:
CONTINUATION_STATE = bytes([0x01, 0x43]) CONTINUATION_STATE = bytes([0x01, 0x43])
channel: Optional[l2cap.Channel] channel: Optional[l2cap.ClassicChannel]
Service = NewType('Service', List[ServiceAttribute]) Service = NewType('Service', List[ServiceAttribute])
service_records: Dict[int, Service] service_records: Dict[int, Service]
current_response: Union[None, bytes, Tuple[int, List[int]]] current_response: Union[None, bytes, Tuple[int, List[int]]]
@@ -933,7 +934,9 @@ class Server:
self.current_response = None self.current_response = None
def register(self, l2cap_channel_manager: l2cap.ChannelManager) -> None: def register(self, l2cap_channel_manager: l2cap.ChannelManager) -> None:
l2cap_channel_manager.register_server(SDP_PSM, self.on_connection) l2cap_channel_manager.create_classic_server(
spec=l2cap.ClassicChannelSpec(psm=SDP_PSM), handler=self.on_connection
)
def send_response(self, response): def send_response(self, response):
logger.debug(f'{color(">>> Sending SDP Response", "blue")}: {response}') logger.debug(f'{color(">>> Sending SDP Response", "blue")}: {response}')

View File

@@ -27,6 +27,7 @@ import logging
import asyncio import asyncio
import enum import enum
import secrets import secrets
from dataclasses import dataclass
from typing import ( from typing import (
TYPE_CHECKING, TYPE_CHECKING,
Any, Any,
@@ -37,6 +38,7 @@ from typing import (
Optional, Optional,
Tuple, Tuple,
Type, Type,
cast,
) )
from pyee import EventEmitter from pyee import EventEmitter
@@ -52,6 +54,7 @@ from .core import (
BT_BR_EDR_TRANSPORT, BT_BR_EDR_TRANSPORT,
BT_CENTRAL_ROLE, BT_CENTRAL_ROLE,
BT_LE_TRANSPORT, BT_LE_TRANSPORT,
AdvertisingData,
ProtocolError, ProtocolError,
name_or_number, name_or_number,
) )
@@ -184,8 +187,8 @@ SMP_KEYPRESS_AUTHREQ = 0b00010000
SMP_CT2_AUTHREQ = 0b00100000 SMP_CT2_AUTHREQ = 0b00100000
# Crypto salt # Crypto salt
SMP_CTKD_H7_LEBR_SALT = bytes.fromhex('00000000000000000000000000000000746D7031') SMP_CTKD_H7_LEBR_SALT = bytes.fromhex('000000000000000000000000746D7031')
SMP_CTKD_H7_BRLE_SALT = bytes.fromhex('00000000000000000000000000000000746D7032') SMP_CTKD_H7_BRLE_SALT = bytes.fromhex('000000000000000000000000746D7032')
# fmt: on # fmt: on
# pylint: enable=line-too-long # pylint: enable=line-too-long
@@ -562,6 +565,54 @@ class PairingMethod(enum.IntEnum):
CTKD_OVER_CLASSIC = 4 CTKD_OVER_CLASSIC = 4
# -----------------------------------------------------------------------------
class OobContext:
"""Cryptographic context for LE SC OOB pairing."""
ecc_key: crypto.EccKey
r: bytes
def __init__(
self, ecc_key: Optional[crypto.EccKey] = None, r: Optional[bytes] = None
) -> None:
self.ecc_key = crypto.EccKey.generate() if ecc_key is None else ecc_key
self.r = crypto.r() if r is None else r
def share(self) -> OobSharedData:
pkx = self.ecc_key.x[::-1]
return OobSharedData(c=crypto.f4(pkx, pkx, self.r, bytes(1)), r=self.r)
# -----------------------------------------------------------------------------
class OobLegacyContext:
"""Cryptographic context for LE Legacy OOB pairing."""
tk: bytes
def __init__(self, tk: Optional[bytes] = None) -> None:
self.tk = crypto.r() if tk is None else tk
# -----------------------------------------------------------------------------
@dataclass
class OobSharedData:
"""Shareable data for LE SC OOB pairing."""
c: bytes
r: bytes
def to_ad(self) -> AdvertisingData:
return AdvertisingData(
[
(AdvertisingData.LE_SECURE_CONNECTIONS_CONFIRMATION_VALUE, self.c),
(AdvertisingData.LE_SECURE_CONNECTIONS_RANDOM_VALUE, self.r),
]
)
def __str__(self) -> str:
return f'OOB(C={self.c.hex()}, R={self.r.hex()})'
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
class Session: class Session:
# I/O Capability to pairing method decision matrix # I/O Capability to pairing method decision matrix
@@ -626,6 +677,13 @@ class Session:
}, },
} }
ea: bytes
eb: bytes
ltk: bytes
preq: bytes
pres: bytes
tk: bytes
def __init__( def __init__(
self, self,
manager: Manager, manager: Manager,
@@ -635,17 +693,10 @@ class Session:
) -> None: ) -> None:
self.manager = manager self.manager = manager
self.connection = connection self.connection = connection
self.preq: Optional[bytes] = None
self.pres: Optional[bytes] = None
self.ea = None
self.eb = None
self.tk = bytes(16)
self.r = bytes(16)
self.stk = None self.stk = None
self.ltk = None
self.ltk_ediv = 0 self.ltk_ediv = 0
self.ltk_rand = bytes(8) self.ltk_rand = bytes(8)
self.link_key = None self.link_key: Optional[bytes] = None
self.initiator_key_distribution: int = 0 self.initiator_key_distribution: int = 0
self.responder_key_distribution: int = 0 self.responder_key_distribution: int = 0
self.peer_random_value: Optional[bytes] = None self.peer_random_value: Optional[bytes] = None
@@ -658,7 +709,7 @@ class Session:
self.peer_bd_addr: Optional[Address] = None self.peer_bd_addr: Optional[Address] = None
self.peer_signature_key = None self.peer_signature_key = None
self.peer_expected_distributions: List[Type[SMP_Command]] = [] self.peer_expected_distributions: List[Type[SMP_Command]] = []
self.dh_key = None self.dh_key = b''
self.confirm_value = None self.confirm_value = None
self.passkey: Optional[int] = None self.passkey: Optional[int] = None
self.passkey_ready = asyncio.Event() self.passkey_ready = asyncio.Event()
@@ -711,8 +762,8 @@ class Session:
self.io_capability = pairing_config.delegate.io_capability self.io_capability = pairing_config.delegate.io_capability
self.peer_io_capability = SMP_NO_INPUT_NO_OUTPUT_IO_CAPABILITY self.peer_io_capability = SMP_NO_INPUT_NO_OUTPUT_IO_CAPABILITY
# OOB (not supported yet) # OOB
self.oob = False self.oob_data_flag = 0 if pairing_config.oob is None else 1
# Set up addresses # Set up addresses
self_address = connection.self_address self_address = connection.self_address
@@ -728,9 +779,35 @@ class Session:
self.ia = bytes(peer_address) self.ia = bytes(peer_address)
self.iat = 1 if peer_address.is_random else 0 self.iat = 1 if peer_address.is_random else 0
# Select the ECC key, TK and r initial value
if pairing_config.oob:
self.peer_oob_data = pairing_config.oob.peer_data
if pairing_config.sc:
if pairing_config.oob.our_context is None:
raise ValueError(
"oob pairing config requires a context when sc is True"
)
self.r = pairing_config.oob.our_context.r
self.ecc_key = pairing_config.oob.our_context.ecc_key
if pairing_config.oob.legacy_context is not None:
self.tk = pairing_config.oob.legacy_context.tk
else:
if pairing_config.oob.legacy_context is None:
raise ValueError(
"oob pairing config requires a legacy context when sc is False"
)
self.r = bytes(16)
self.ecc_key = manager.ecc_key
self.tk = pairing_config.oob.legacy_context.tk
else:
self.peer_oob_data = None
self.r = bytes(16)
self.ecc_key = manager.ecc_key
self.tk = bytes(16)
@property @property
def pkx(self) -> Tuple[bytes, bytes]: def pkx(self) -> Tuple[bytes, bytes]:
return (bytes(reversed(self.manager.ecc_key.x)), self.peer_public_key_x) return (self.ecc_key.x[::-1], self.peer_public_key_x)
@property @property
def pka(self) -> bytes: def pka(self) -> bytes:
@@ -767,7 +844,10 @@ class Session:
return None return None
def decide_pairing_method( def decide_pairing_method(
self, auth_req: int, initiator_io_capability: int, responder_io_capability: int self,
auth_req: int,
initiator_io_capability: int,
responder_io_capability: int,
) -> None: ) -> None:
if self.connection.transport == BT_BR_EDR_TRANSPORT: if self.connection.transport == BT_BR_EDR_TRANSPORT:
self.pairing_method = PairingMethod.CTKD_OVER_CLASSIC self.pairing_method = PairingMethod.CTKD_OVER_CLASSIC
@@ -908,7 +988,7 @@ class Session:
command = SMP_Pairing_Request_Command( command = SMP_Pairing_Request_Command(
io_capability=self.io_capability, io_capability=self.io_capability,
oob_data_flag=0, oob_data_flag=self.oob_data_flag,
auth_req=self.auth_req, auth_req=self.auth_req,
maximum_encryption_key_size=16, maximum_encryption_key_size=16,
initiator_key_distribution=self.initiator_key_distribution, initiator_key_distribution=self.initiator_key_distribution,
@@ -920,7 +1000,7 @@ class Session:
def send_pairing_response_command(self) -> None: def send_pairing_response_command(self) -> None:
response = SMP_Pairing_Response_Command( response = SMP_Pairing_Response_Command(
io_capability=self.io_capability, io_capability=self.io_capability,
oob_data_flag=0, oob_data_flag=self.oob_data_flag,
auth_req=self.auth_req, auth_req=self.auth_req,
maximum_encryption_key_size=16, maximum_encryption_key_size=16,
initiator_key_distribution=self.initiator_key_distribution, initiator_key_distribution=self.initiator_key_distribution,
@@ -981,8 +1061,8 @@ class Session:
def send_public_key_command(self) -> None: def send_public_key_command(self) -> None:
self.send_command( self.send_command(
SMP_Pairing_Public_Key_Command( SMP_Pairing_Public_Key_Command(
public_key_x=bytes(reversed(self.manager.ecc_key.x)), public_key_x=self.ecc_key.x[::-1],
public_key_y=bytes(reversed(self.manager.ecc_key.y)), public_key_y=self.ecc_key.y[::-1],
) )
) )
@@ -1010,7 +1090,7 @@ class Session:
# We can now encrypt the connection with the short term key, so that we can # We can now encrypt the connection with the short term key, so that we can
# distribute the long term and/or other keys over an encrypted connection # distribute the long term and/or other keys over an encrypted connection
self.manager.device.host.send_command_sync( self.manager.device.host.send_command_sync(
HCI_LE_Enable_Encryption_Command( # type: ignore[call-arg] HCI_LE_Enable_Encryption_Command(
connection_handle=self.connection.handle, connection_handle=self.connection.handle,
random_number=bytes(8), random_number=bytes(8),
encrypted_diversifier=0, encrypted_diversifier=0,
@@ -1018,18 +1098,54 @@ class Session:
) )
) )
async def derive_ltk(self) -> None: @classmethod
link_key = await self.manager.device.get_link_key(self.connection.peer_address) def derive_ltk(cls, link_key: bytes, ct2: bool) -> bytes:
assert link_key is not None '''Derives Long Term Key from Link Key.
Args:
link_key: BR/EDR Link Key bytes in little-endian.
ct2: whether ct2 is supported on both devices.
Returns:
LE Long Tern Key bytes in little-endian.
'''
ilk = ( ilk = (
crypto.h7(salt=SMP_CTKD_H7_BRLE_SALT, w=link_key) crypto.h7(salt=SMP_CTKD_H7_BRLE_SALT, w=link_key)
if self.ct2 if ct2
else crypto.h6(link_key, b'tmp2') else crypto.h6(link_key, b'tmp2')
) )
self.ltk = crypto.h6(ilk, b'brle') return crypto.h6(ilk, b'brle')
@classmethod
def derive_link_key(cls, ltk: bytes, ct2: bool) -> bytes:
'''Derives Link Key from Long Term Key.
Args:
ltk: LE Long Term Key bytes in little-endian.
ct2: whether ct2 is supported on both devices.
Returns:
BR/EDR Link Key bytes in little-endian.
'''
ilk = (
crypto.h7(salt=SMP_CTKD_H7_LEBR_SALT, w=ltk)
if ct2
else crypto.h6(ltk, b'tmp1')
)
return crypto.h6(ilk, b'lebr')
async def get_link_key_and_derive_ltk(self) -> None:
'''Retrieves BR/EDR Link Key from storage and derive it to LE LTK.'''
link_key = await self.manager.device.get_link_key(self.connection.peer_address)
if link_key is None:
logging.warning(
'Try to derive LTK but host does not have the LK. Send a SMP_PAIRING_FAILED but the procedure will not be paused!'
)
self.send_pairing_failed(
SMP_CROSS_TRANSPORT_KEY_DERIVATION_NOT_ALLOWED_ERROR
)
else:
self.ltk = self.derive_ltk(link_key, self.ct2)
def distribute_keys(self) -> None: def distribute_keys(self) -> None:
# Distribute the keys as required # Distribute the keys as required
if self.is_initiator: if self.is_initiator:
# CTKD: Derive LTK from LinkKey # CTKD: Derive LTK from LinkKey
@@ -1038,7 +1154,7 @@ class Session:
and self.initiator_key_distribution & SMP_ENC_KEY_DISTRIBUTION_FLAG and self.initiator_key_distribution & SMP_ENC_KEY_DISTRIBUTION_FLAG
): ):
self.ctkd_task = self.connection.abort_on( self.ctkd_task = self.connection.abort_on(
'disconnection', self.derive_ltk() 'disconnection', self.get_link_key_and_derive_ltk()
) )
elif not self.sc: elif not self.sc:
# Distribute the LTK, EDIV and RAND # Distribute the LTK, EDIV and RAND
@@ -1068,12 +1184,7 @@ class Session:
# CTKD, calculate BR/EDR link key # CTKD, calculate BR/EDR link key
if self.initiator_key_distribution & SMP_LINK_KEY_DISTRIBUTION_FLAG: if self.initiator_key_distribution & SMP_LINK_KEY_DISTRIBUTION_FLAG:
ilk = ( self.link_key = self.derive_link_key(self.ltk, self.ct2)
crypto.h7(salt=SMP_CTKD_H7_LEBR_SALT, w=self.ltk)
if self.ct2
else crypto.h6(self.ltk, b'tmp1')
)
self.link_key = crypto.h6(ilk, b'lebr')
else: else:
# CTKD: Derive LTK from LinkKey # CTKD: Derive LTK from LinkKey
@@ -1082,7 +1193,7 @@ class Session:
and self.responder_key_distribution & SMP_ENC_KEY_DISTRIBUTION_FLAG and self.responder_key_distribution & SMP_ENC_KEY_DISTRIBUTION_FLAG
): ):
self.ctkd_task = self.connection.abort_on( self.ctkd_task = self.connection.abort_on(
'disconnection', self.derive_ltk() 'disconnection', self.get_link_key_and_derive_ltk()
) )
# Distribute the LTK, EDIV and RAND # Distribute the LTK, EDIV and RAND
elif not self.sc: elif not self.sc:
@@ -1112,12 +1223,7 @@ class Session:
# CTKD, calculate BR/EDR link key # CTKD, calculate BR/EDR link key
if self.responder_key_distribution & SMP_LINK_KEY_DISTRIBUTION_FLAG: if self.responder_key_distribution & SMP_LINK_KEY_DISTRIBUTION_FLAG:
ilk = ( self.link_key = self.derive_link_key(self.ltk, self.ct2)
crypto.h7(salt=SMP_CTKD_H7_LEBR_SALT, w=self.ltk)
if self.ct2
else crypto.h6(self.ltk, b'tmp1')
)
self.link_key = crypto.h6(ilk, b'lebr')
def compute_peer_expected_distributions(self, key_distribution_flags: int) -> None: def compute_peer_expected_distributions(self, key_distribution_flags: int) -> None:
# Set our expectations for what to wait for in the key distribution phase # Set our expectations for what to wait for in the key distribution phase
@@ -1295,7 +1401,7 @@ class Session:
try: try:
handler(command) handler(command)
except Exception as error: except Exception as error:
logger.warning(f'{color("!!! Exception in handler:", "red")} {error}') logger.exception(f'{color("!!! Exception in handler:", "red")} {error}')
response = SMP_Pairing_Failed_Command( response = SMP_Pairing_Failed_Command(
reason=SMP_UNSPECIFIED_REASON_ERROR reason=SMP_UNSPECIFIED_REASON_ERROR
) )
@@ -1332,15 +1438,28 @@ class Session:
self.sc = self.sc and (command.auth_req & SMP_SC_AUTHREQ != 0) self.sc = self.sc and (command.auth_req & SMP_SC_AUTHREQ != 0)
self.ct2 = self.ct2 and (command.auth_req & SMP_CT2_AUTHREQ != 0) self.ct2 = self.ct2 and (command.auth_req & SMP_CT2_AUTHREQ != 0)
# Check for OOB # Infer the pairing method
if command.oob_data_flag != 0: if (self.sc and (self.oob_data_flag != 0 or command.oob_data_flag != 0)) or (
self.send_pairing_failed(SMP_OOB_NOT_AVAILABLE_ERROR) not self.sc and (self.oob_data_flag != 0 and command.oob_data_flag != 0)
return ):
# Use OOB
self.pairing_method = PairingMethod.OOB
if not self.sc and self.tk is None:
# For legacy OOB, TK is required.
logger.warning("legacy OOB without TK")
self.send_pairing_failed(SMP_OOB_NOT_AVAILABLE_ERROR)
return
if command.oob_data_flag == 0:
# The peer doesn't have OOB data, use r=0
self.r = bytes(16)
else:
# Decide which pairing method to use from the IO capability
self.decide_pairing_method(
command.auth_req,
command.io_capability,
self.io_capability,
)
# Decide which pairing method to use
self.decide_pairing_method(
command.auth_req, command.io_capability, self.io_capability
)
logger.debug(f'pairing method: {self.pairing_method.name}') logger.debug(f'pairing method: {self.pairing_method.name}')
# Key distribution # Key distribution
@@ -1389,15 +1508,26 @@ class Session:
self.bonding = self.bonding and (command.auth_req & SMP_BONDING_AUTHREQ != 0) self.bonding = self.bonding and (command.auth_req & SMP_BONDING_AUTHREQ != 0)
self.sc = self.sc and (command.auth_req & SMP_SC_AUTHREQ != 0) self.sc = self.sc and (command.auth_req & SMP_SC_AUTHREQ != 0)
# Check for OOB # Infer the pairing method
if self.sc and command.oob_data_flag: if (self.sc and (self.oob_data_flag != 0 or command.oob_data_flag != 0)) or (
self.send_pairing_failed(SMP_OOB_NOT_AVAILABLE_ERROR) not self.sc and (self.oob_data_flag != 0 and command.oob_data_flag != 0)
return ):
# Use OOB
self.pairing_method = PairingMethod.OOB
if not self.sc and self.tk is None:
# For legacy OOB, TK is required.
logger.warning("legacy OOB without TK")
self.send_pairing_failed(SMP_OOB_NOT_AVAILABLE_ERROR)
return
if command.oob_data_flag == 0:
# The peer doesn't have OOB data, use r=0
self.r = bytes(16)
else:
# Decide which pairing method to use from the IO capability
self.decide_pairing_method(
command.auth_req, self.io_capability, command.io_capability
)
# Decide which pairing method to use
self.decide_pairing_method(
command.auth_req, self.io_capability, command.io_capability
)
logger.debug(f'pairing method: {self.pairing_method.name}') logger.debug(f'pairing method: {self.pairing_method.name}')
# Key distribution # Key distribution
@@ -1548,12 +1678,13 @@ class Session:
if self.passkey_step < 20: if self.passkey_step < 20:
self.send_pairing_confirm_command() self.send_pairing_confirm_command()
return return
else: elif self.pairing_method != PairingMethod.OOB:
return return
else: else:
if self.pairing_method in ( if self.pairing_method in (
PairingMethod.JUST_WORKS, PairingMethod.JUST_WORKS,
PairingMethod.NUMERIC_COMPARISON, PairingMethod.NUMERIC_COMPARISON,
PairingMethod.OOB,
): ):
self.send_pairing_random_command() self.send_pairing_random_command()
elif self.pairing_method == PairingMethod.PASSKEY: elif self.pairing_method == PairingMethod.PASSKEY:
@@ -1590,6 +1721,7 @@ class Session:
if self.pairing_method in ( if self.pairing_method in (
PairingMethod.JUST_WORKS, PairingMethod.JUST_WORKS,
PairingMethod.NUMERIC_COMPARISON, PairingMethod.NUMERIC_COMPARISON,
PairingMethod.OOB,
): ):
ra = bytes(16) ra = bytes(16)
rb = ra rb = ra
@@ -1598,7 +1730,6 @@ class Session:
ra = self.passkey.to_bytes(16, byteorder='little') ra = self.passkey.to_bytes(16, byteorder='little')
rb = ra rb = ra
else: else:
# OOB not implemented yet
return return
assert self.preq and self.pres assert self.preq and self.pres
@@ -1650,18 +1781,33 @@ class Session:
self.peer_public_key_y = command.public_key_y self.peer_public_key_y = command.public_key_y
# Compute the DH key # Compute the DH key
self.dh_key = bytes( self.dh_key = self.ecc_key.dh(
reversed( command.public_key_x[::-1],
self.manager.ecc_key.dh( command.public_key_y[::-1],
bytes(reversed(command.public_key_x)), )[::-1]
bytes(reversed(command.public_key_y)),
)
)
)
logger.debug(f'DH key: {self.dh_key.hex()}') logger.debug(f'DH key: {self.dh_key.hex()}')
if self.pairing_method == PairingMethod.OOB:
# Check against shared OOB data
if self.peer_oob_data:
confirm_verifier = crypto.f4(
self.peer_public_key_x,
self.peer_public_key_x,
self.peer_oob_data.r,
bytes(1),
)
if not self.check_expected_value(
self.peer_oob_data.c,
confirm_verifier,
SMP_CONFIRM_VALUE_FAILED_ERROR,
):
return
if self.is_initiator: if self.is_initiator:
self.send_pairing_confirm_command() if self.pairing_method == PairingMethod.OOB:
self.send_pairing_random_command()
else:
self.send_pairing_confirm_command()
else: else:
if self.pairing_method == PairingMethod.PASSKEY: if self.pairing_method == PairingMethod.PASSKEY:
self.display_or_input_passkey() self.display_or_input_passkey()
@@ -1672,6 +1818,7 @@ class Session:
if self.pairing_method in ( if self.pairing_method in (
PairingMethod.JUST_WORKS, PairingMethod.JUST_WORKS,
PairingMethod.NUMERIC_COMPARISON, PairingMethod.NUMERIC_COMPARISON,
PairingMethod.OOB,
): ):
# We can now send the confirmation value # We can now send the confirmation value
self.send_pairing_confirm_command() self.send_pairing_confirm_command()
@@ -1700,7 +1847,6 @@ class Session:
else: else:
self.send_pairing_dhkey_check_command() self.send_pairing_dhkey_check_command()
else: else:
assert self.ltk
self.start_encryption(self.ltk) self.start_encryption(self.ltk)
def on_smp_pairing_failed_command( def on_smp_pairing_failed_command(
@@ -1750,6 +1896,7 @@ class Manager(EventEmitter):
sessions: Dict[int, Session] sessions: Dict[int, Session]
pairing_config_factory: Callable[[Connection], PairingConfig] pairing_config_factory: Callable[[Connection], PairingConfig]
session_proxy: Type[Session] session_proxy: Type[Session]
_ecc_key: Optional[crypto.EccKey]
def __init__( def __init__(
self, self,
@@ -1771,7 +1918,26 @@ class Manager(EventEmitter):
cid = SMP_BR_CID if connection.transport == BT_BR_EDR_TRANSPORT else SMP_CID cid = SMP_BR_CID if connection.transport == BT_BR_EDR_TRANSPORT else SMP_CID
connection.send_l2cap_pdu(cid, command.to_bytes()) connection.send_l2cap_pdu(cid, command.to_bytes())
def on_smp_security_request_command(
self, connection: Connection, request: SMP_Security_Request_Command
) -> None:
connection.emit('security_request', request.auth_req)
def on_smp_pdu(self, connection: Connection, pdu: bytes) -> None: def on_smp_pdu(self, connection: Connection, pdu: bytes) -> None:
# Parse the L2CAP payload into an SMP Command object
command = SMP_Command.from_bytes(pdu)
logger.debug(
f'<<< Received SMP Command on connection [0x{connection.handle:04X}] '
f'{connection.peer_address}: {command}'
)
# Security request is more than just pairing, so let applications handle them
if command.code == SMP_SECURITY_REQUEST_COMMAND:
self.on_smp_security_request_command(
connection, cast(SMP_Security_Request_Command, command)
)
return
# Look for a session with this connection, and create one if none exists # Look for a session with this connection, and create one if none exists
if not (session := self.sessions.get(connection.handle)): if not (session := self.sessions.get(connection.handle)):
if connection.role == BT_CENTRAL_ROLE: if connection.role == BT_CENTRAL_ROLE:
@@ -1782,13 +1948,6 @@ class Manager(EventEmitter):
) )
self.sessions[connection.handle] = session self.sessions[connection.handle] = session
# Parse the L2CAP payload into an SMP Command object
command = SMP_Command.from_bytes(pdu)
logger.debug(
f'<<< Received SMP Command on connection [0x{connection.handle:04X}] '
f'{connection.peer_address}: {command}'
)
# Delegate the handling of the command to the session # Delegate the handling of the command to the session
session.on_smp_command(command) session.on_smp_command(command)

View File

@@ -18,6 +18,8 @@
import logging import logging
import grpc.aio import grpc.aio
from typing import Optional, Union
from .common import PumpedTransport, PumpedPacketSource, PumpedPacketSink, Transport from .common import PumpedTransport, PumpedPacketSource, PumpedPacketSink, Transport
# pylint: disable=no-name-in-module # pylint: disable=no-name-in-module
@@ -33,7 +35,7 @@ logger = logging.getLogger(__name__)
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
async def open_android_emulator_transport(spec: str | None) -> Transport: async def open_android_emulator_transport(spec: Optional[str]) -> Transport:
''' '''
Open a transport connection to an Android emulator via its gRPC interface. Open a transport connection to an Android emulator via its gRPC interface.
The parameter string has this syntax: The parameter string has this syntax:
@@ -82,7 +84,7 @@ async def open_android_emulator_transport(spec: str | None) -> Transport:
logger.debug(f'connecting to gRPC server at {server_address}') logger.debug(f'connecting to gRPC server at {server_address}')
channel = grpc.aio.insecure_channel(server_address) channel = grpc.aio.insecure_channel(server_address)
service: EmulatedBluetoothServiceStub | VhciForwardingServiceStub service: Union[EmulatedBluetoothServiceStub, VhciForwardingServiceStub]
if mode == 'host': if mode == 'host':
# Connect as a host # Connect as a host
service = EmulatedBluetoothServiceStub(channel) service = EmulatedBluetoothServiceStub(channel)
@@ -95,10 +97,13 @@ async def open_android_emulator_transport(spec: str | None) -> Transport:
raise ValueError('invalid mode') raise ValueError('invalid mode')
# Create the transport object # Create the transport object
transport = PumpedTransport( class EmulatorTransport(PumpedTransport):
PumpedPacketSource(hci_device.read), async def close(self):
PumpedPacketSink(hci_device.write), await super().close()
channel.close, await channel.close()
transport = EmulatorTransport(
PumpedPacketSource(hci_device.read), PumpedPacketSink(hci_device.write)
) )
transport.start() transport.start()

View File

@@ -18,11 +18,12 @@
import asyncio import asyncio
import atexit import atexit
import logging import logging
import grpc.aio
import os import os
import pathlib import pathlib
import sys import sys
from typing import Optional from typing import Dict, Optional
import grpc.aio
from .common import ( from .common import (
ParserSource, ParserSource,
@@ -33,8 +34,8 @@ from .common import (
) )
# pylint: disable=no-name-in-module # pylint: disable=no-name-in-module
from .grpc_protobuf.packet_streamer_pb2_grpc import PacketStreamerStub
from .grpc_protobuf.packet_streamer_pb2_grpc import ( from .grpc_protobuf.packet_streamer_pb2_grpc import (
PacketStreamerStub,
PacketStreamerServicer, PacketStreamerServicer,
add_PacketStreamerServicer_to_server, add_PacketStreamerServicer_to_server,
) )
@@ -43,6 +44,7 @@ from .grpc_protobuf.hci_packet_pb2 import HCIPacket
from .grpc_protobuf.startup_pb2 import Chip, ChipInfo from .grpc_protobuf.startup_pb2 import Chip, ChipInfo
from .grpc_protobuf.common_pb2 import ChipKind from .grpc_protobuf.common_pb2 import ChipKind
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
# Logging # Logging
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
@@ -74,14 +76,20 @@ def get_ini_dir() -> Optional[pathlib.Path]:
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
def find_grpc_port() -> int: def ini_file_name(instance_number: int) -> str:
suffix = f'_{instance_number}' if instance_number > 0 else ''
return f'netsim{suffix}.ini'
# -----------------------------------------------------------------------------
def find_grpc_port(instance_number: int) -> int:
if not (ini_dir := get_ini_dir()): if not (ini_dir := get_ini_dir()):
logger.debug('no known directory for .ini file') logger.debug('no known directory for .ini file')
return 0 return 0
ini_file = ini_dir / 'netsim.ini' ini_file = ini_dir / ini_file_name(instance_number)
logger.debug(f'Looking for .ini file at {ini_file}')
if ini_file.is_file(): if ini_file.is_file():
logger.debug(f'Found .ini file at {ini_file}')
with open(ini_file, 'r') as ini_file_data: with open(ini_file, 'r') as ini_file_data:
for line in ini_file_data.readlines(): for line in ini_file_data.readlines():
if '=' in line: if '=' in line:
@@ -90,12 +98,14 @@ def find_grpc_port() -> int:
logger.debug(f'gRPC port = {value}') logger.debug(f'gRPC port = {value}')
return int(value) return int(value)
logger.debug('no grpc.port property found in .ini file')
# Not found # Not found
return 0 return 0
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
def publish_grpc_port(grpc_port) -> bool: def publish_grpc_port(grpc_port: int, instance_number: int) -> bool:
if not (ini_dir := get_ini_dir()): if not (ini_dir := get_ini_dir()):
logger.debug('no known directory for .ini file') logger.debug('no known directory for .ini file')
return False return False
@@ -104,7 +114,7 @@ def publish_grpc_port(grpc_port) -> bool:
logger.debug('ini directory does not exist') logger.debug('ini directory does not exist')
return False return False
ini_file = ini_dir / 'netsim.ini' ini_file = ini_dir / ini_file_name(instance_number)
try: try:
ini_file.write_text(f'grpc.port={grpc_port}\n') ini_file.write_text(f'grpc.port={grpc_port}\n')
logger.debug(f"published gRPC port at {ini_file}") logger.debug(f"published gRPC port at {ini_file}")
@@ -122,14 +132,15 @@ def publish_grpc_port(grpc_port) -> bool:
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
async def open_android_netsim_controller_transport( async def open_android_netsim_controller_transport(
server_host: str | None, server_port: int server_host: Optional[str], server_port: int, options: Dict[str, str]
) -> Transport: ) -> Transport:
if not server_port: if not server_port:
raise ValueError('invalid port') raise ValueError('invalid port')
if server_host == '_' or not server_host: if server_host == '_' or not server_host:
server_host = 'localhost' server_host = 'localhost'
if not publish_grpc_port(server_port): instance_number = int(options.get('instance', "0"))
if not publish_grpc_port(server_port, instance_number):
logger.warning("unable to publish gRPC port") logger.warning("unable to publish gRPC port")
class HciDevice: class HciDevice:
@@ -186,15 +197,12 @@ async def open_android_netsim_controller_transport(
logger.debug(f'<<< PACKET: {data.hex()}') logger.debug(f'<<< PACKET: {data.hex()}')
self.on_data_received(data) self.on_data_received(data)
def send_packet(self, data): async def send_packet(self, data):
async def send(): return await self.context.write(
await self.context.write( PacketResponse(
PacketResponse( hci_packet=HCIPacket(packet_type=data[0], packet=data[1:])
hci_packet=HCIPacket(packet_type=data[0], packet=data[1:])
)
) )
)
self.loop.create_task(send())
def terminate(self): def terminate(self):
self.task.cancel() self.task.cancel()
@@ -228,17 +236,17 @@ async def open_android_netsim_controller_transport(
logger.debug('gRPC server cancelled') logger.debug('gRPC server cancelled')
await self.grpc_server.stop(None) await self.grpc_server.stop(None)
def on_packet(self, packet): async def send_packet(self, packet):
if not self.device: if not self.device:
logger.debug('no device, dropping packet') logger.debug('no device, dropping packet')
return return
self.device.send_packet(packet) return await self.device.send_packet(packet)
async def StreamPackets(self, _request_iterator, context): async def StreamPackets(self, _request_iterator, context):
logger.debug('StreamPackets request') logger.debug('StreamPackets request')
# Check that we won't already have a device # Check that we don't already have a device
if self.device: if self.device:
logger.debug('busy, already serving a device') logger.debug('busy, already serving a device')
return PacketResponse(error='Busy') return PacketResponse(error='Busy')
@@ -261,15 +269,42 @@ async def open_android_netsim_controller_transport(
await server.start() await server.start()
asyncio.get_running_loop().create_task(server.serve()) asyncio.get_running_loop().create_task(server.serve())
class GrpcServerTransport(Transport): sink = PumpedPacketSink(server.send_packet)
async def close(self): sink.start()
await super().close() return Transport(server, sink)
return GrpcServerTransport(server, server)
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
async def open_android_netsim_host_transport(server_host, server_port, options): async def open_android_netsim_host_transport_with_address(
server_host: Optional[str],
server_port: int,
options: Optional[Dict[str, str]] = None,
):
if server_host == '_' or not server_host:
server_host = 'localhost'
if not server_port:
# Look for the gRPC config in a .ini file
instance_number = 0 if options is None else int(options.get('instance', '0'))
server_port = find_grpc_port(instance_number)
if not server_port:
raise RuntimeError('gRPC server port not found')
# Connect to the gRPC server
server_address = f'{server_host}:{server_port}'
logger.debug(f'Connecting to gRPC server at {server_address}')
channel = grpc.aio.insecure_channel(server_address)
return await open_android_netsim_host_transport_with_channel(
channel,
options,
)
# -----------------------------------------------------------------------------
async def open_android_netsim_host_transport_with_channel(
channel, options: Optional[Dict[str, str]] = None
):
# Wrapper for I/O operations # Wrapper for I/O operations
class HciDevice: class HciDevice:
def __init__(self, name, manufacturer, hci_device): def __init__(self, name, manufacturer, hci_device):
@@ -288,10 +323,12 @@ async def open_android_netsim_host_transport(server_host, server_port, options):
async def read(self): async def read(self):
response = await self.hci_device.read() response = await self.hci_device.read()
response_type = response.WhichOneof('response_type') response_type = response.WhichOneof('response_type')
if response_type == 'error': if response_type == 'error':
logger.warning(f'received error: {response.error}') logger.warning(f'received error: {response.error}')
raise RuntimeError(response.error) raise RuntimeError(response.error)
elif response_type == 'hci_packet':
if response_type == 'hci_packet':
return ( return (
bytes([response.hci_packet.packet_type]) bytes([response.hci_packet.packet_type])
+ response.hci_packet.packet + response.hci_packet.packet
@@ -306,24 +343,9 @@ async def open_android_netsim_host_transport(server_host, server_port, options):
) )
) )
name = options.get('name', DEFAULT_NAME) name = DEFAULT_NAME if options is None else options.get('name', DEFAULT_NAME)
manufacturer = DEFAULT_MANUFACTURER manufacturer = DEFAULT_MANUFACTURER
if server_host == '_' or not server_host:
server_host = 'localhost'
if not server_port:
# Look for the gRPC config in a .ini file
server_host = 'localhost'
server_port = find_grpc_port()
if not server_port:
raise RuntimeError('gRPC server port not found')
# Connect to the gRPC server
server_address = f'{server_host}:{server_port}'
logger.debug(f'Connecting to gRPC server at {server_address}')
channel = grpc.aio.insecure_channel(server_address)
# Connect as a host # Connect as a host
service = PacketStreamerStub(channel) service = PacketStreamerStub(channel)
hci_device = HciDevice( hci_device = HciDevice(
@@ -334,10 +356,14 @@ async def open_android_netsim_host_transport(server_host, server_port, options):
await hci_device.start() await hci_device.start()
# Create the transport object # Create the transport object
transport = PumpedTransport( class GrpcTransport(PumpedTransport):
async def close(self):
await super().close()
await channel.close()
transport = GrpcTransport(
PumpedPacketSource(hci_device.read), PumpedPacketSource(hci_device.read),
PumpedPacketSink(hci_device.write), PumpedPacketSink(hci_device.write),
channel.close,
) )
transport.start() transport.start()
@@ -345,7 +371,7 @@ async def open_android_netsim_host_transport(server_host, server_port, options):
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
async def open_android_netsim_transport(spec): async def open_android_netsim_transport(spec: Optional[str]) -> Transport:
''' '''
Open a transport connection as a client or server, implementing Android's `netsim` Open a transport connection as a client or server, implementing Android's `netsim`
simulator protocol over gRPC. simulator protocol over gRPC.
@@ -359,6 +385,11 @@ async def open_android_netsim_transport(spec):
to connect *to* a netsim server (netsim is the controller), or accept to connect *to* a netsim server (netsim is the controller), or accept
connections *as* a netsim-compatible server. connections *as* a netsim-compatible server.
instance=<n>
Specifies an instance number, with <n> > 0. This is used to determine which
.init file to use. In `host` mode, it is ignored when the <host>:<port>
specifier is present, since in that case no .ini file is used.
In `host` mode: In `host` mode:
The <host>:<port> part is optional. When not specified, the transport The <host>:<port> part is optional. When not specified, the transport
looks for a netsim .ini file, from which it will read the `grpc.backend.port` looks for a netsim .ini file, from which it will read the `grpc.backend.port`
@@ -387,14 +418,15 @@ async def open_android_netsim_transport(spec):
params = spec.split(',') if spec else [] params = spec.split(',') if spec else []
if params and ':' in params[0]: if params and ':' in params[0]:
# Explicit <host>:<port> # Explicit <host>:<port>
host, port = params[0].split(':') host, port_str = params[0].split(':')
port = int(port_str)
params_offset = 1 params_offset = 1
else: else:
host = None host = None
port = 0 port = 0
params_offset = 0 params_offset = 0
options = {} options: Dict[str, str] = {}
for param in params[params_offset:]: for param in params[params_offset:]:
if '=' not in param: if '=' not in param:
raise ValueError('invalid parameter, expected <name>=<value>') raise ValueError('invalid parameter, expected <name>=<value>')
@@ -403,10 +435,12 @@ async def open_android_netsim_transport(spec):
mode = options.get('mode', 'host') mode = options.get('mode', 'host')
if mode == 'host': if mode == 'host':
return await open_android_netsim_host_transport(host, port, options) return await open_android_netsim_host_transport_with_address(
host, port, options
)
if mode == 'controller': if mode == 'controller':
if host is None: if host is None:
raise ValueError('<host>:<port> missing') raise ValueError('<host>:<port> missing')
return await open_android_netsim_controller_transport(host, port) return await open_android_netsim_controller_transport(host, port, options)
raise ValueError('invalid mode option') raise ValueError('invalid mode option')

View File

@@ -42,6 +42,7 @@ HCI_PACKET_INFO: Dict[int, Tuple[int, int, str]] = {
hci.HCI_ACL_DATA_PACKET: (2, 2, 'H'), hci.HCI_ACL_DATA_PACKET: (2, 2, 'H'),
hci.HCI_SYNCHRONOUS_DATA_PACKET: (1, 2, 'B'), hci.HCI_SYNCHRONOUS_DATA_PACKET: (1, 2, 'B'),
hci.HCI_EVENT_PACKET: (1, 1, 'B'), hci.HCI_EVENT_PACKET: (1, 1, 'B'),
hci.HCI_ISO_DATA_PACKET: (2, 2, 'H'),
} }
@@ -150,7 +151,7 @@ class PacketParser:
try: try:
self.sink.on_packet(bytes(self.packet)) self.sink.on_packet(bytes(self.packet))
except Exception as error: except Exception as error:
logger.warning( logger.exception(
color(f'!!! Exception in on_packet: {error}', 'red') color(f'!!! Exception in on_packet: {error}', 'red')
) )
self.reset() self.reset()
@@ -339,8 +340,9 @@ class PumpedPacketSource(ParserSource):
try: try:
packet = await self.receive_function() packet = await self.receive_function()
self.parser.feed_data(packet) self.parser.feed_data(packet)
except asyncio.exceptions.CancelledError: except asyncio.CancelledError:
logger.debug('source pump task done') logger.debug('source pump task done')
self.terminated.set_result(None)
break break
except Exception as error: except Exception as error:
logger.warning(f'exception while waiting for packet: {error}') logger.warning(f'exception while waiting for packet: {error}')
@@ -370,7 +372,7 @@ class PumpedPacketSink:
try: try:
packet = await self.packet_queue.get() packet = await self.packet_queue.get()
await self.send_function(packet) await self.send_function(packet)
except asyncio.exceptions.CancelledError: except asyncio.CancelledError:
logger.debug('sink pump task done') logger.debug('sink pump task done')
break break
except Exception as error: except Exception as error:
@@ -393,19 +395,13 @@ class PumpedTransport(Transport):
self, self,
source: PumpedPacketSource, source: PumpedPacketSource,
sink: PumpedPacketSink, sink: PumpedPacketSink,
close_function,
) -> None: ) -> None:
super().__init__(source, sink) super().__init__(source, sink)
self.close_function = close_function
def start(self) -> None: def start(self) -> None:
self.source.start() self.source.start()
self.sink.start() self.sink.start()
async def close(self) -> None:
await super().close()
await self.close_function()
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
class SnoopingTransport(Transport): class SnoopingTransport(Transport):

View File

@@ -23,6 +23,8 @@ import socket
import ctypes import ctypes
import collections import collections
from typing import Optional
from .common import Transport, ParserSource from .common import Transport, ParserSource
@@ -33,7 +35,7 @@ logger = logging.getLogger(__name__)
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
async def open_hci_socket_transport(spec: str | None) -> Transport: async def open_hci_socket_transport(spec: Optional[str]) -> Transport:
''' '''
Open an HCI Socket (only available on some platforms). Open an HCI Socket (only available on some platforms).
The parameter string is either empty (to use the first/default Bluetooth adapter) The parameter string is either empty (to use the first/default Bluetooth adapter)
@@ -45,9 +47,9 @@ async def open_hci_socket_transport(spec: str | None) -> Transport:
# Create a raw HCI socket # Create a raw HCI socket
try: try:
hci_socket = socket.socket( hci_socket = socket.socket(
socket.AF_BLUETOOTH, socket.AF_BLUETOOTH, # type: ignore[attr-defined]
socket.SOCK_RAW | socket.SOCK_NONBLOCK, socket.SOCK_RAW | socket.SOCK_NONBLOCK, # type: ignore[attr-defined]
socket.BTPROTO_HCI, # type: ignore socket.BTPROTO_HCI, # type: ignore[attr-defined]
) )
except AttributeError as error: except AttributeError as error:
# Not supported on this platform # Not supported on this platform
@@ -78,7 +80,7 @@ async def open_hci_socket_transport(spec: str | None) -> Transport:
bind_address = struct.pack( bind_address = struct.pack(
# pylint: disable=no-member # pylint: disable=no-member
'<HHH', '<HHH',
socket.AF_BLUETOOTH, socket.AF_BLUETOOTH, # type: ignore[attr-defined]
adapter_index, adapter_index,
HCI_CHANNEL_USER, HCI_CHANNEL_USER,
) )

View File

@@ -23,6 +23,8 @@ import atexit
import os import os
import logging import logging
from typing import Optional
from .common import Transport, StreamPacketSource, StreamPacketSink from .common import Transport, StreamPacketSource, StreamPacketSink
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
@@ -32,7 +34,7 @@ logger = logging.getLogger(__name__)
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
async def open_pty_transport(spec: str | None) -> Transport: async def open_pty_transport(spec: Optional[str]) -> Transport:
''' '''
Open a PTY transport. Open a PTY transport.
The parameter string may be empty, or a path name where a symbolic link The parameter string may be empty, or a path name where a symbolic link

View File

@@ -24,9 +24,10 @@ import platform
import usb1 import usb1
from .common import Transport, ParserSource from bumble.transport.common import Transport, ParserSource
from .. import hci from bumble import hci
from ..colors import color from bumble.colors import color
from bumble.utils import AsyncRunner
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
@@ -113,7 +114,7 @@ async def open_usb_transport(spec: str) -> Transport:
def __init__(self, device, acl_out): def __init__(self, device, acl_out):
self.device = device self.device = device
self.acl_out = acl_out self.acl_out = acl_out
self.transfer = device.getTransfer() self.acl_out_transfer = device.getTransfer()
self.packets = collections.deque() # Queue of packets waiting to be sent self.packets = collections.deque() # Queue of packets waiting to be sent
self.loop = asyncio.get_running_loop() self.loop = asyncio.get_running_loop()
self.cancel_done = self.loop.create_future() self.cancel_done = self.loop.create_future()
@@ -137,21 +138,20 @@ async def open_usb_transport(spec: str) -> Transport:
# The queue was previously empty, re-prime the pump # The queue was previously empty, re-prime the pump
self.process_queue() self.process_queue()
def on_packet_sent(self, transfer): def transfer_callback(self, transfer):
status = transfer.getStatus() status = transfer.getStatus()
# logger.debug(f'<<< USB out transfer callback: status={status}')
# pylint: disable=no-member # pylint: disable=no-member
if status == usb1.TRANSFER_COMPLETED: if status == usb1.TRANSFER_COMPLETED:
self.loop.call_soon_threadsafe(self.on_packet_sent_) self.loop.call_soon_threadsafe(self.on_packet_sent)
elif status == usb1.TRANSFER_CANCELLED: elif status == usb1.TRANSFER_CANCELLED:
self.loop.call_soon_threadsafe(self.cancel_done.set_result, None) self.loop.call_soon_threadsafe(self.cancel_done.set_result, None)
else: else:
logger.warning( logger.warning(
color(f'!!! out transfer not completed: status={status}', 'red') color(f'!!! OUT transfer not completed: status={status}', 'red')
) )
def on_packet_sent_(self): def on_packet_sent(self):
if self.packets: if self.packets:
self.packets.popleft() self.packets.popleft()
self.process_queue() self.process_queue()
@@ -163,22 +163,20 @@ async def open_usb_transport(spec: str) -> Transport:
packet = self.packets[0] packet = self.packets[0]
packet_type = packet[0] packet_type = packet[0]
if packet_type == hci.HCI_ACL_DATA_PACKET: if packet_type == hci.HCI_ACL_DATA_PACKET:
self.transfer.setBulk( self.acl_out_transfer.setBulk(
self.acl_out, packet[1:], callback=self.on_packet_sent self.acl_out, packet[1:], callback=self.transfer_callback
) )
logger.debug('submit ACL') self.acl_out_transfer.submit()
self.transfer.submit()
elif packet_type == hci.HCI_COMMAND_PACKET: elif packet_type == hci.HCI_COMMAND_PACKET:
self.transfer.setControl( self.acl_out_transfer.setControl(
USB_RECIPIENT_DEVICE | USB_REQUEST_TYPE_CLASS, USB_RECIPIENT_DEVICE | USB_REQUEST_TYPE_CLASS,
0, 0,
0, 0,
0, 0,
packet[1:], packet[1:],
callback=self.on_packet_sent, callback=self.transfer_callback,
) )
logger.debug('submit COMMAND') self.acl_out_transfer.submit()
self.transfer.submit()
else: else:
logger.warning(color(f'unsupported packet type {packet_type}', 'red')) logger.warning(color(f'unsupported packet type {packet_type}', 'red'))
@@ -193,11 +191,11 @@ async def open_usb_transport(spec: str) -> Transport:
self.packets.clear() self.packets.clear()
# If we have a transfer in flight, cancel it # If we have a transfer in flight, cancel it
if self.transfer.isSubmitted(): if self.acl_out_transfer.isSubmitted():
# Try to cancel the transfer, but that may fail because it may have # Try to cancel the transfer, but that may fail because it may have
# already completed # already completed
try: try:
self.transfer.cancel() self.acl_out_transfer.cancel()
logger.debug('waiting for OUT transfer cancellation to be done...') logger.debug('waiting for OUT transfer cancellation to be done...')
await self.cancel_done await self.cancel_done
@@ -206,27 +204,22 @@ async def open_usb_transport(spec: str) -> Transport:
logger.debug('OUT transfer likely already completed') logger.debug('OUT transfer likely already completed')
class UsbPacketSource(asyncio.Protocol, ParserSource): class UsbPacketSource(asyncio.Protocol, ParserSource):
def __init__(self, context, device, metadata, acl_in, events_in): def __init__(self, device, metadata, acl_in, events_in):
super().__init__() super().__init__()
self.context = context
self.device = device self.device = device
self.metadata = metadata self.metadata = metadata
self.acl_in = acl_in self.acl_in = acl_in
self.acl_in_transfer = None
self.events_in = events_in self.events_in = events_in
self.events_in_transfer = None
self.loop = asyncio.get_running_loop() self.loop = asyncio.get_running_loop()
self.queue = asyncio.Queue() self.queue = asyncio.Queue()
self.dequeue_task = None self.dequeue_task = None
self.closed = False
self.event_loop_done = self.loop.create_future()
self.cancel_done = { self.cancel_done = {
hci.HCI_EVENT_PACKET: self.loop.create_future(), hci.HCI_EVENT_PACKET: self.loop.create_future(),
hci.HCI_ACL_DATA_PACKET: self.loop.create_future(), hci.HCI_ACL_DATA_PACKET: self.loop.create_future(),
} }
self.events_in_transfer = None self.closed = False
self.acl_in_transfer = None
# Create a thread to process events
self.event_thread = threading.Thread(target=self.run)
def start(self): def start(self):
# Set up transfer objects for input # Set up transfer objects for input
@@ -234,7 +227,7 @@ async def open_usb_transport(spec: str) -> Transport:
self.events_in_transfer.setInterrupt( self.events_in_transfer.setInterrupt(
self.events_in, self.events_in,
READ_SIZE, READ_SIZE,
callback=self.on_packet_received, callback=self.transfer_callback,
user_data=hci.HCI_EVENT_PACKET, user_data=hci.HCI_EVENT_PACKET,
) )
self.events_in_transfer.submit() self.events_in_transfer.submit()
@@ -243,22 +236,23 @@ async def open_usb_transport(spec: str) -> Transport:
self.acl_in_transfer.setBulk( self.acl_in_transfer.setBulk(
self.acl_in, self.acl_in,
READ_SIZE, READ_SIZE,
callback=self.on_packet_received, callback=self.transfer_callback,
user_data=hci.HCI_ACL_DATA_PACKET, user_data=hci.HCI_ACL_DATA_PACKET,
) )
self.acl_in_transfer.submit() self.acl_in_transfer.submit()
self.dequeue_task = self.loop.create_task(self.dequeue()) self.dequeue_task = self.loop.create_task(self.dequeue())
self.event_thread.start()
def on_packet_received(self, transfer): @property
def usb_transfer_submitted(self):
return (
self.events_in_transfer.isSubmitted()
or self.acl_in_transfer.isSubmitted()
)
def transfer_callback(self, transfer):
packet_type = transfer.getUserData() packet_type = transfer.getUserData()
status = transfer.getStatus() status = transfer.getStatus()
# logger.debug(
# f'<<< USB IN transfer callback: status={status} '
# f'packet_type={packet_type} '
# f'length={transfer.getActualLength()}'
# )
# pylint: disable=no-member # pylint: disable=no-member
if status == usb1.TRANSFER_COMPLETED: if status == usb1.TRANSFER_COMPLETED:
@@ -267,18 +261,18 @@ async def open_usb_transport(spec: str) -> Transport:
+ transfer.getBuffer()[: transfer.getActualLength()] + transfer.getBuffer()[: transfer.getActualLength()]
) )
self.loop.call_soon_threadsafe(self.queue.put_nowait, packet) self.loop.call_soon_threadsafe(self.queue.put_nowait, packet)
# Re-submit the transfer so we can receive more data
transfer.submit()
elif status == usb1.TRANSFER_CANCELLED: elif status == usb1.TRANSFER_CANCELLED:
self.loop.call_soon_threadsafe( self.loop.call_soon_threadsafe(
self.cancel_done[packet_type].set_result, None self.cancel_done[packet_type].set_result, None
) )
return
else: else:
logger.warning( logger.warning(
color(f'!!! transfer not completed: status={status}', 'red') color(f'!!! IN transfer not completed: status={status}', 'red')
) )
self.loop.call_soon_threadsafe(self.on_transport_lost)
# Re-submit the transfer so we can receive more data
transfer.submit()
async def dequeue(self): async def dequeue(self):
while not self.closed: while not self.closed:
@@ -288,21 +282,6 @@ async def open_usb_transport(spec: str) -> Transport:
return return
self.parser.feed_data(packet) self.parser.feed_data(packet)
def run(self):
logger.debug('starting USB event loop')
while (
self.events_in_transfer.isSubmitted()
or self.acl_in_transfer.isSubmitted()
):
# pylint: disable=no-member
try:
self.context.handleEvents()
except usb1.USBErrorInterrupted:
pass
logger.debug('USB event loop done')
self.loop.call_soon_threadsafe(self.event_loop_done.set_result, None)
def close(self): def close(self):
self.closed = True self.closed = True
@@ -331,15 +310,14 @@ async def open_usb_transport(spec: str) -> Transport:
f'IN[{packet_type}] transfer likely already completed' f'IN[{packet_type}] transfer likely already completed'
) )
# Wait for the thread to terminate
await self.event_loop_done
class UsbTransport(Transport): class UsbTransport(Transport):
def __init__(self, context, device, interface, setting, source, sink): def __init__(self, context, device, interface, setting, source, sink):
super().__init__(source, sink) super().__init__(source, sink)
self.context = context self.context = context
self.device = device self.device = device
self.interface = interface self.interface = interface
self.loop = asyncio.get_running_loop()
self.event_loop_done = self.loop.create_future()
# Get exclusive access # Get exclusive access
device.claimInterface(interface) device.claimInterface(interface)
@@ -352,6 +330,22 @@ async def open_usb_transport(spec: str) -> Transport:
source.start() source.start()
sink.start() sink.start()
# Create a thread to process events
self.event_thread = threading.Thread(target=self.run)
self.event_thread.start()
def run(self):
logger.debug('starting USB event loop')
while self.source.usb_transfer_submitted:
# pylint: disable=no-member
try:
self.context.handleEvents()
except usb1.USBErrorInterrupted:
pass
logger.debug('USB event loop done')
self.loop.call_soon_threadsafe(self.event_loop_done.set_result, None)
async def close(self): async def close(self):
self.source.close() self.source.close()
self.sink.close() self.sink.close()
@@ -361,6 +355,9 @@ async def open_usb_transport(spec: str) -> Transport:
self.device.close() self.device.close()
self.context.close() self.context.close()
# Wait for the thread to terminate
await self.event_loop_done
# Find the device according to the spec moniker # Find the device according to the spec moniker
load_libusb() load_libusb()
context = usb1.USBContext() context = usb1.USBContext()
@@ -540,7 +537,7 @@ async def open_usb_transport(spec: str) -> Transport:
except usb1.USBError: except usb1.USBError:
logger.warning('failed to set configuration') logger.warning('failed to set configuration')
source = UsbPacketSource(context, device, device_metadata, acl_in, events_in) source = UsbPacketSource(device, device_metadata, acl_in, events_in)
sink = UsbPacketSink(device, acl_out) sink = UsbPacketSink(device, acl_out)
return UsbTransport(context, device, interface, setting, source, sink) return UsbTransport(context, device, interface, setting, source, sink)
except usb1.USBError as error: except usb1.USBError as error:

View File

@@ -17,6 +17,8 @@
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
import logging import logging
from typing import Optional
from .common import Transport from .common import Transport
from .file import open_file_transport from .file import open_file_transport
@@ -27,7 +29,7 @@ logger = logging.getLogger(__name__)
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
async def open_vhci_transport(spec: str | None) -> Transport: async def open_vhci_transport(spec: Optional[str]) -> Transport:
''' '''
Open a VHCI transport (only available on some platforms). Open a VHCI transport (only available on some platforms).
The parameter string is either empty (to use the default VHCI device The parameter string is either empty (to use the default VHCI device

View File

@@ -31,19 +31,21 @@ async def open_ws_client_transport(spec: str) -> Transport:
''' '''
Open a WebSocket client transport. Open a WebSocket client transport.
The parameter string has this syntax: The parameter string has this syntax:
<remote-host>:<remote-port> <websocket-url>
Example: 127.0.0.1:9001 Example: ws://localhost:7681/v1/websocket/bt
''' '''
remote_host, remote_port = spec.split(':') websocket = await websockets.client.connect(spec)
uri = f'ws://{remote_host}:{remote_port}'
websocket = await websockets.client.connect(uri)
transport = PumpedTransport( class WsTransport(PumpedTransport):
async def close(self):
await super().close()
await websocket.close()
transport = WsTransport(
PumpedPacketSource(websocket.recv), PumpedPacketSource(websocket.recv),
PumpedPacketSink(websocket.send), PumpedPacketSink(websocket.send),
websocket.close,
) )
transport.start() transport.start()
return transport return transport

View File

@@ -15,13 +15,26 @@
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
# Imports # Imports
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
from __future__ import annotations
import asyncio import asyncio
import logging import logging
import traceback import traceback
import collections import collections
import sys import sys
from typing import Awaitable, Set, TypeVar import warnings
from functools import wraps from typing import (
Awaitable,
Set,
TypeVar,
List,
Tuple,
Callable,
Any,
Optional,
Union,
overload,
)
from functools import wraps, partial
from pyee import EventEmitter from pyee import EventEmitter
from .colors import color from .colors import color
@@ -64,6 +77,102 @@ def composite_listener(cls):
return cls return cls
# -----------------------------------------------------------------------------
_Handler = TypeVar('_Handler', bound=Callable)
class EventWatcher:
'''A wrapper class to control the lifecycle of event handlers better.
Usage:
```
watcher = EventWatcher()
def on_foo():
...
watcher.on(emitter, 'foo', on_foo)
@watcher.on(emitter, 'bar')
def on_bar():
...
# Close all event handlers watching through this watcher
watcher.close()
```
As context:
```
with contextlib.closing(EventWatcher()) as context:
@context.on(emitter, 'foo')
def on_foo():
...
# on_foo() has been removed here!
```
'''
handlers: List[Tuple[EventEmitter, str, Callable[..., Any]]]
def __init__(self) -> None:
self.handlers = []
@overload
def on(self, emitter: EventEmitter, event: str) -> Callable[[_Handler], _Handler]:
...
@overload
def on(self, emitter: EventEmitter, event: str, handler: _Handler) -> _Handler:
...
def on(
self, emitter: EventEmitter, event: str, handler: Optional[_Handler] = None
) -> Union[_Handler, Callable[[_Handler], _Handler]]:
'''Watch an event until the context is closed.
Args:
emitter: EventEmitter to watch
event: Event name
handler: (Optional) Event handler. When nothing is passed, this method works as a decorator.
'''
def wrapper(f: _Handler) -> _Handler:
self.handlers.append((emitter, event, f))
emitter.on(event, f)
return f
return wrapper if handler is None else wrapper(handler)
@overload
def once(self, emitter: EventEmitter, event: str) -> Callable[[_Handler], _Handler]:
...
@overload
def once(self, emitter: EventEmitter, event: str, handler: _Handler) -> _Handler:
...
def once(
self, emitter: EventEmitter, event: str, handler: Optional[_Handler] = None
) -> Union[_Handler, Callable[[_Handler], _Handler]]:
'''Watch an event for once.
Args:
emitter: EventEmitter to watch
event: Event name
handler: (Optional) Event handler. When nothing passed, this method works as a decorator.
'''
def wrapper(f: _Handler) -> _Handler:
self.handlers.append((emitter, event, f))
emitter.once(event, f)
return f
return wrapper if handler is None else wrapper(handler)
def close(self) -> None:
for emitter, event, handler in self.handlers:
if handler in emitter.listeners(event):
emitter.remove_listener(event, handler)
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
_T = TypeVar('_T') _T = TypeVar('_T')
@@ -302,3 +411,52 @@ class FlowControlAsyncPipe:
self.resume_source() self.resume_source()
self.check_pump() self.check_pump()
async def async_call(function, *args, **kwargs):
"""
Immediately calls the function with provided args and kwargs, wrapping it in an async function.
Rust's `pyo3_asyncio` library needs functions to be marked async to properly inject a running loop.
result = await async_call(some_function, ...)
"""
return function(*args, **kwargs)
def wrap_async(function):
"""
Wraps the provided function in an async function.
"""
return partial(async_call, function)
def deprecated(msg: str):
"""
Throw deprecation warning before execution.
"""
def wrapper(function):
@wraps(function)
def inner(*args, **kwargs):
warnings.warn(msg, DeprecationWarning)
return function(*args, **kwargs)
return inner
return wrapper
def experimental(msg: str):
"""
Throws a future warning before execution.
"""
def wrapper(function):
@wraps(function)
def inner(*args, **kwargs):
warnings.warn(msg, FutureWarning)
return function(*args, **kwargs)
return inner
return wrapper

View File

@@ -10,7 +10,7 @@ nav:
- Contributing: development/contributing.md - Contributing: development/contributing.md
- Code Style: development/code_style.md - Code Style: development/code_style.md
- Use Cases: - Use Cases:
- Overview: use_cases/index.md - use_cases/index.md
- Use Case 1: use_cases/use_case_1.md - Use Case 1: use_cases/use_case_1.md
- Use Case 2: use_cases/use_case_2.md - Use Case 2: use_cases/use_case_2.md
- Use Case 3: use_cases/use_case_3.md - Use Case 3: use_cases/use_case_3.md
@@ -23,7 +23,7 @@ nav:
- GATT: components/gatt.md - GATT: components/gatt.md
- Security Manager: components/security_manager.md - Security Manager: components/security_manager.md
- Transports: - Transports:
- Overview: transports/index.md - transports/index.md
- Serial: transports/serial.md - Serial: transports/serial.md
- USB: transports/usb.md - USB: transports/usb.md
- PTY: transports/pty.md - PTY: transports/pty.md
@@ -37,14 +37,14 @@ nav:
- Android Emulator: transports/android_emulator.md - Android Emulator: transports/android_emulator.md
- File: transports/file.md - File: transports/file.md
- Drivers: - Drivers:
- Overview: drivers/index.md - drivers/index.md
- Realtek: drivers/realtek.md - Realtek: drivers/realtek.md
- API: - API:
- Guide: api/guide.md - Guide: api/guide.md
- Examples: api/examples.md - Examples: api/examples.md
- Reference: api/reference.md - Reference: api/reference.md
- Apps & Tools: - Apps & Tools:
- Overview: apps_and_tools/index.md - apps_and_tools/index.md
- Console: apps_and_tools/console.md - Console: apps_and_tools/console.md
- Bench: apps_and_tools/bench.md - Bench: apps_and_tools/bench.md
- Speaker: apps_and_tools/speaker.md - Speaker: apps_and_tools/speaker.md
@@ -57,16 +57,25 @@ nav:
- USB Probe: apps_and_tools/usb_probe.md - USB Probe: apps_and_tools/usb_probe.md
- Link Relay: apps_and_tools/link_relay.md - Link Relay: apps_and_tools/link_relay.md
- Hardware: - Hardware:
- Overview: hardware/index.md - hardware/index.md
- Platforms: - Platforms:
- Overview: platforms/index.md - platforms/index.md
- macOS: platforms/macos.md - macOS: platforms/macos.md
- Linux: platforms/linux.md - Linux: platforms/linux.md
- Windows: platforms/windows.md - Windows: platforms/windows.md
- Android: platforms/android.md - Android: platforms/android.md
- Zephyr: platforms/zephyr.md - Zephyr: platforms/zephyr.md
- Examples: - Examples:
- Overview: examples/index.md - examples/index.md
- Extras:
- extras/index.md
- Android Remote HCI: extras/android_remote_hci.md
- Android BT Bench: extras/android_bt_bench.md
- Hive:
- hive/index.md
- Speaker: hive/web/speaker/speaker.html
- Scanner: hive/web/scanner/scanner.html
- Heart Rate Monitor: hive/web/heart_rate_monitor/heart_rate_monitor.html
copyright: Copyright 2021-2023 Google LLC copyright: Copyright 2021-2023 Google LLC
@@ -75,6 +84,8 @@ theme:
logo: 'images/logo.png' logo: 'images/logo.png'
favicon: 'images/favicon.ico' favicon: 'images/favicon.ico'
custom_dir: 'theme' custom_dir: 'theme'
features:
- navigation.indexes
plugins: plugins:
- mkdocstrings: - mkdocstrings:
@@ -99,6 +110,8 @@ markdown_extensions:
- pymdownx.emoji: - pymdownx.emoji:
emoji_index: !!python/name:materialx.emoji.twemoji emoji_index: !!python/name:materialx.emoji.twemoji
emoji_generator: !!python/name:materialx.emoji.to_svg emoji_generator: !!python/name:materialx.emoji.to_svg
- pymdownx.tabbed:
alternate_style: true
- codehilite: - codehilite:
guess_lang: false guess_lang: false
- toc: - toc:

View File

@@ -0,0 +1,64 @@
ANDROID BENCH APP
=================
This Android app that is compatible with the Bumble `bench` command line app.
This app can be used to test the throughput and latency between two Android
devices, or between an Android device and another device running the Bumble
`bench` app.
Only the RFComm Client, RFComm Server, L2CAP Client and L2CAP Server modes are
supported.
Building
--------
You can build the app by running `./gradlew build` (use `gradlew.bat` on Windows) from the `BtBench` top level directory.
You can also build with Android Studio: open the `BtBench` project. You can build and/or debug from there.
If the build succeeds, you can find the app APKs (debug and release) at:
* [Release] ``app/build/outputs/apk/release/app-release-unsigned.apk``
* [Debug] ``app/build/outputs/apk/debug/app-debug.apk``
Running
-------
### Starting the app
You can start the app from the Android launcher, from Android Studio, or with `adb`
#### Launching from the launcher
Just tap the app icon on the launcher, check the parameters, and tap
one of the benchmark action buttons.
#### Launching with `adb`
Using the `am` command, you can start the activity, and pass it arguments so that you can
automatically start the benchmark test, and/or set the parameters.
| Parameter Name | Parameter Type | Description
|------------------------|----------------|------------
| autostart | String | Benchmark to start. (rfcomm-client, rfcomm-server, l2cap-client or l2cap-server)
| packet-count | Integer | Number of packets to send (rfcomm-client and l2cap-client only)
| packet-size | Integer | Number of bytes per packet (rfcomm-client and l2cap-client only)
| peer-bluetooth-address | Integer | Peer Bluetooth address to connect to (rfcomm-client and l2cap-client | only)
!!! tip "Launching from adb with auto-start"
In this example, we auto-start the Rfcomm Server bench action.
```bash
$ adb shell am start -n com.github.google.bumble.btbench/.MainActivity --es autostart rfcomm-server
```
!!! tip "Launching from adb with auto-start and some parameters"
In this example, we auto-start the Rfcomm Client bench action, set the packet count to 100,
and the packet size to 1024, and connect to DA:4C:10:DE:17:02
```bash
$ adb shell am start -n com.github.google.bumble.btbench/.MainActivity --es autostart rfcomm-client --ei packet-count 100 --ei packet-size 1024 --es peer-bluetooth-address DA:4C:10:DE:17:02
```
#### Selecting a Peer Bluetooth Address
The app's main activity has a "Peer Bluetooth Address" setting where you can change the address.
!!! note "Bluetooth Address for L2CAP vs RFComm"
For BLE (L2CAP mode), the address of a device typically changes regularly (it is randomized for privacy), whereas the Bluetooth Classic addresses will remain the same (RFComm mode).
If two devices are paired and bonded, then they will each "see" a non-changing address for each other even with BLE (Resolvable Private Address)

View File

@@ -0,0 +1,181 @@
ANDROID REMOTE HCI APP
======================
This application allows using an android phone's built-in Bluetooth controller with
a Bumble host stack running outside the phone (typically a development laptop or desktop).
The app runs an HCI proxy between a TCP socket on the "outside" and the Bluetooth HCI HAL
on the "inside". (See [this page](https://source.android.com/docs/core/connect/bluetooth) for a high level
description of the Android Bluetooth HCI HAL).
The HCI packets received on the TCP socket are forwarded to the phone's controller, and the
packets coming from the controller are forwarded to the TCP socket.
Building
--------
You can build the app by running `./gradlew build` (use `gradlew.bat` on Windows) from the `extras/android/RemoteHCI` top level directory.
You can also build with Android Studio: open the `RemoteHCI` project. You can build and/or debug from there.
If the build succeeds, you can find the app APKs (debug and release) at:
* [Release] ``app/build/outputs/apk/release/app-release-unsigned.apk``
* [Debug] ``app/build/outputs/apk/debug/app-debug.apk``
Running
-------
!!! note
In the following examples, it is assumed that shell commands are executed while in the
app's root directory, `extras/android/RemoteHCI`. If you are in a different directory,
adjust the relative paths accordingly.
### Preconditions
When the proxy starts (tapping the "Start" button in the app's main activity, or running the proxy
from an `adb shell` command line), it will try to bind to the Bluetooth HAL.
This requires that there is no other HAL client, and requires certain privileges.
For running as a regular app, this requires disabling SELinux temporarily.
For running as a command-line executable, this just requires a root shell.
#### Root Shell
!!! tip "Restart `adb` as root"
```bash
$ adb root
```
#### Disabling SELinux
Binding to the Bluetooth HCI HAL requires certain SELinux permissions that can't simply be changed
on a device without rebuilding its system image. To bypass these restrictions, you will need
to disable SELinux on your phone (please be aware that this is global, not just for the proxy app,
so proceed with caution).
In order to disable SELinux, you need to root the phone (it may be advisable to do this on a
development phone).
!!! tip "Disabling SELinux Temporarily"
Restart `adb` as root:
```bash
$ adb root
```
Then disable SELinux
```bash
$ adb shell setenforce 0
```
Once you're done using the proxy, you can restore SELinux, if you need to, with
```bash
$ adb shell setenforce 1
```
This state will also reset to the normal SELinux enforcement when you reboot.
#### Stopping the bluetooth process
Since the Bluetooth HAL service can only accept one client, and that in normal conditions
that client is the Android's bluetooth stack, it is required to first shut down the
Android bluetooth stack process.
!!! tip "Checking if the Bluetooth process is running"
```bash
$ adb shell "ps -A | grep com.google.android.bluetooth"
```
If the process is running, you will get a line like:
```
bluetooth 10759 876 17455796 136620 do_epoll_wait 0 S com.google.android.bluetooth
```
If you don't, it means that the process is not running and you are clear to proceed.
Simply turning Bluetooth off from the phone's settings does not ensure that the bluetooth process will exit.
If the bluetooth process is still running after toggling Bluetooth off from the settings, you may try enabling
Airplane Mode, then rebooting. The bluetooth process should, in theory, not restart after the reboot.
!!! tip "Stopping the bluetooth process with adb"
```bash
$ adb shell cmd bluetooth_manager disable
```
### Running as a command line app
You push the built APK to a temporary location on the phone's filesystem, then launch the command
line executable with an `adb shell` command.
!!! tip "Pushing the executable"
```bash
$ adb push app/build/outputs/apk/release/app-release-unsigned.apk /data/local/tmp/remotehci.apk
```
Do this every time you rebuild. Alternatively, you can push the `debug` APK instead:
```bash
$ adb push app/build/outputs/apk/debug/app-debug.apk /data/local/tmp/remotehci.apk
```
!!! tip "Start the proxy from the command line"
```bash
adb shell "CLASSPATH=/data/local/tmp/remotehci.apk app_process /system/bin com.github.google.bumble.remotehci.CommandLineInterface"
```
This will run the proxy, listening on the default TCP port.
If you want a different port, pass it as a command line parameter
!!! tip "Start the proxy from the command line with a specific TCP port"
```bash
adb shell "CLASSPATH=/data/local/tmp/remotehci.apk app_process /system/bin com.github.google.bumble.remotehci.CommandLineInterface 12345"
```
### Running as a normal app
You can start the app from the Android launcher, from Android Studio, or with `adb`
#### Launching from the launcher
Just tap the app icon on the launcher, check the TCP port that is configured, and tap
the "Start" button.
#### Launching with `adb`
Using the `am` command, you can start the activity, and pass it arguments so that you can
automatically start the proxy, and/or set the port number.
!!! tip "Launching from adb with auto-start"
```bash
$ adb shell am start -n com.github.google.bumble.remotehci/.MainActivity --ez autostart true
```
!!! tip "Launching from adb with auto-start and a port"
In this example, we auto-start the proxy upon launch, with the port set to 9995
```bash
$ adb shell am start -n com.github.google.bumble.remotehci/.MainActivity --ez autostart true --ei port 9995
```
#### Selecting a TCP port
The RemoteHCI app's main activity has a "TCP Port" setting where you can change the port on
which the proxy is accepting connections. If the default value isn't suitable, you can
change it there (you can also use the special value 0 to let the OS assign a port number for you).
### Connecting to the proxy
To connect the Bumble stack to the proxy, you need to be able to reach the phone's network
stack. This can be done over the phone's WiFi connection, or, alternatively, using an `adb`
TCP forward (which should be faster than over WiFi).
!!! tip "Forwarding TCP with `adb`"
To connect to the proxy via an `adb` TCP forward, use:
```bash
$ adb forward tcp:<outside-port> tcp:<inside-port>
```
Where ``<outside-port>`` is the port number for a listening socket on your laptop or
desktop machine, and <inside-port> is the TCP port selected in the app's user interface.
Those two ports may be the same, of course.
For example, with the default TCP port 9993:
```bash
$ adb forward tcp:9993 tcp:9993
```
Once you've ensured that you can reach the proxy's TCP port on the phone, either directly or
via an `adb` forward, you can then use it as a Bumble transport, using the transport name:
``tcp-client:<host>:<port>`` syntax.
!!! example "Connecting a Bumble client"
Connecting the `bumble-controller-info` app to the phone's controller.
Assuming you have set up an `adb` forward on port 9993:
```bash
$ bumble-controller-info tcp-client:localhost:9993
```
Or over WiFi with, in this example, the IP address of the phone being ```192.168.86.27```
```bash
$ bumble-controller-info tcp-client:192.168.86.27:9993
```

View File

@@ -0,0 +1,19 @@
EXTRAS
======
A collection of add-ons, apps and tools, to the Bumble project.
Android Remote HCI
------------------
Allows using an Android phone's built-in Bluetooth controller with a Bumble
stack running on a development machine.
See [Android Remote HCI](android_remote_hci.md) for details.
Android BT Bench
----------------
An Android app that is compatible with the Bumble `bench` command line app.
This app can be used to test the throughput and latency between two Android
devices, or between an Android device and another device running the Bumble
`bench` app.

View File

@@ -3,7 +3,7 @@ HARDWARE
The Bumble Host connects to a controller over an [HCI Transport](../transports/index.md). The Bumble Host connects to a controller over an [HCI Transport](../transports/index.md).
To use a hardware controller attached to the host on which the host application is running, the transport is typically either [HCI over UART](../transports/serial.md) or [HCI over USB](../transports/usb.md). To use a hardware controller attached to the host on which the host application is running, the transport is typically either [HCI over UART](../transports/serial.md) or [HCI over USB](../transports/usb.md).
On Linux, the [VHCI Transport](../transports/vhci.md) can be used to communicate with any controller hardware managed by the operating system. Alternatively, a remote controller (a phyiscal controller attached to a remote host) can be used by connecting one of the networked transports (such as the [TCP Client transport](../transports/tcp_client.md), the [TCP Server transport](../transports/tcp_server.md) or the [UDP Transport](../transports/udp.md)) to an [HCI Bridge](../apps_and_tools/hci_bridge) bridging the network transport to a physical controller on a remote host. On Linux, the [VHCI Transport](../transports/vhci.md) can be used to communicate with any controller hardware managed by the operating system. Alternatively, a remote controller (a phyiscal controller attached to a remote host) can be used by connecting one of the networked transports (such as the [TCP Client transport](../transports/tcp_client.md), the [TCP Server transport](../transports/tcp_server.md) or the [UDP Transport](../transports/udp.md)) to an [HCI Bridge](../apps_and_tools/hci_bridge.md) bridging the network transport to a physical controller on a remote host.
In theory, any controller that is compliant with the HCI over UART or HCI over USB protocols can be used. In theory, any controller that is compliant with the HCI over UART or HCI over USB protocols can be used.

View File

@@ -0,0 +1,59 @@
HIVE
====
Welcome to the Bumble Hive.
This is a collection of apps and virtual devices that can run entirely in a browser page.
The code for the apps and devices, as well as the Bumble runtime code, runs via [Pyodide](https://pyodide.org/).
Pyodide is a Python distribution for the browser and Node.js based on WebAssembly.
The Bumble stack uses a WebSocket to exchange HCI packets with a virtual or physical
Bluetooth controller.
The apps and devices in the hive can be accessed by following the links below. Each
page has a settings button that may be used to configure the WebSocket URL to use for
the virtual HCI connection. This will typically be the WebSocket URL for a `netsim`
daemon.
There is also a [TOML index](index.toml) that can be used by tools to know at which URL to access
each of the apps and devices, as well as their names and short descriptions.
!!! tip "Using `netsim`"
When the `netsimd` daemon is running (for example when using the Android Emulator that
is included in Android Studio), the daemon listens for connections on a TCP port.
To find out what this TCP port is, you can read the `netsim.ini` file that `netsimd`
creates, it includes a line with `web.port=<tcp-port>` (for example `web.port=7681`).
The location of the `netsim.ini` file is platform-specific.
=== "macOS"
On macOS, the directory where `netsim.ini` is stored is $TMPDIR
```bash
$ cat $TMPDIR/netsim.ini
```
=== "Linux"
On Linux, the directory where `netsim.ini` is stored is $XDG_RUNTIME_DIR
```bash
$ cat $XDG_RUNTIME_DIR/netsim.ini
```
!!! tip "Using a local radio"
You can connect the hive virtual apps and devices to a local Bluetooth radio, like,
for example, a USB dongle.
For that, you need to run a local HCI bridge to bridge a local HCI device to a WebSocket
that a web page can connect to.
Use the `bumble-hci-bridge` app, with the host transport set to a WebSocket server on an
available port (ex: `ws-server:_:7682`) and the controller transport set to the transport
name for the radio you want to use (ex: `usb:0` for the first USB dongle)
Applications
------------
* [Scanner](web/scanner/scanner.html) - Scans for BLE devices.
Virtual Devices
---------------
* [Speaker](web/speaker/speaker.html) - Virtual speaker that plays audio in a browser page.
* [Heart Rate Monitor](web/heart_rate_monitor/heart_rate_monitor.html) - Virtual heart rate monitor.

View File

@@ -0,0 +1,21 @@
version = "1.0.0"
base_url = "https://google.github.io/bumble/hive/web"
default_hci_query_param = "hci"
[[index]]
name = "speaker"
description = "Bumble Virtual Speaker"
type = "Device"
url = "speaker/speaker.html"
[[index]]
name = "scanner"
description = "Simple Scanner Application"
type = "Application"
url = "scanner/scanner.html"
[[index]]
name = "heart-rate-monitor"
description = "Virtual Heart Rate Monitor"
type = "Device"
url = "heart_rate_monitor/heart_rate_monitor.html"

View File

@@ -0,0 +1 @@
../../../../../web/bumble.js

View File

@@ -0,0 +1 @@
../../../../../../web/heart_rate_monitor/heart_rate_monitor.html

View File

@@ -0,0 +1 @@
../../../../../../web/heart_rate_monitor/heart_rate_monitor.js

View File

@@ -0,0 +1 @@
../../../../../../web/heart_rate_monitor/heart_rate_monitor.py

View File

@@ -0,0 +1 @@
../../../../../../web/scanner/scanner.css

View File

@@ -0,0 +1 @@
../../../../../../web/scanner/scanner.html

View File

@@ -0,0 +1 @@
../../../../../../web/scanner/scanner.js

View File

@@ -0,0 +1 @@
../../../../../../web/scanner/scanner.py

View File

@@ -0,0 +1 @@
../../../../../../web/speaker/logo.svg

View File

@@ -0,0 +1 @@
../../../../../../web/speaker/speaker.css

View File

@@ -0,0 +1 @@
../../../../../../web/speaker/speaker.html

View File

@@ -0,0 +1 @@
../../../../../../web/speaker/speaker.js

View File

@@ -0,0 +1 @@
../../../../../../web/speaker/speaker.py

View File

@@ -0,0 +1 @@
../../../../../web/ui.js

View File

@@ -152,11 +152,23 @@ Some platforms support features that not all platforms support
See the [Platforms page](platforms/index.md) for details. See the [Platforms page](platforms/index.md) for details.
Hive
----
The Hive is a collection of example apps and virtual devices that are implemented using the
Python Bumble API, running entirely in a web page. This is a convenient way to try out some
of the examples without any Python installation, when you have some other virtual Bluetooth
device that you can connect to or from, such as the Android Emulator.
See the [Bumble Hive](hive/index.md) for details.
Roadmap Roadmap
------- -------
Future features to be considered include: Future features to be considered include:
* More profiles
* More device examples * More device examples
* Add a new type of virtual link (beyond the two existing ones) to allow for link-level simulation (timing, loss, etc) * Add a new type of virtual link (beyond the two existing ones) to allow for link-level simulation (timing, loss, etc)
* Bindings for languages other than Python * Bindings for languages other than Python

View File

@@ -14,7 +14,7 @@ connections.
## Moniker ## Moniker
The moniker syntax for an Android Emulator "netsim" transport is: `android-netsim:[<host>:<port>][<options>]`, The moniker syntax for an Android Emulator "netsim" transport is: `android-netsim:[<host>:<port>][<options>]`,
where `<options>` is a ','-separated list of `<name>=<value>` pairs`. where `<options>` is a comma-separated list of `<name>=<value>` pairs.
The `mode` parameter name can specify running as a host or a controller, and `<hostname>:<port>` can specify a host name (or IP address) and TCP port number on which to reach the gRPC server for the emulator (in "host" mode), or to accept gRPC connections (in "controller" mode). The `mode` parameter name can specify running as a host or a controller, and `<hostname>:<port>` can specify a host name (or IP address) and TCP port number on which to reach the gRPC server for the emulator (in "host" mode), or to accept gRPC connections (in "controller" mode).
Both the `mode=<host|controller>` and `<hostname>:<port>` parameters are optional (so the moniker `android-netsim` by itself is a valid moniker, which will create a transport in `host` mode, connected to `localhost` on the default gRPC port for the Netsim background process). Both the `mode=<host|controller>` and `<hostname>:<port>` parameters are optional (so the moniker `android-netsim` by itself is a valid moniker, which will create a transport in `host` mode, connected to `localhost` on the default gRPC port for the Netsim background process).

View File

@@ -29,6 +29,7 @@ from bumble.device import Device
from bumble.transport import open_transport_or_link from bumble.transport import open_transport_or_link
from bumble.profiles.device_information_service import DeviceInformationService from bumble.profiles.device_information_service import DeviceInformationService
from bumble.profiles.heart_rate_service import HeartRateService from bumble.profiles.heart_rate_service import HeartRateService
from bumble.utils import AsyncRunner
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
@@ -98,6 +99,17 @@ async def main():
) )
) )
# Notify subscribers of the current value as soon as they subscribe
@heart_rate_service.heart_rate_measurement_characteristic.on('subscription')
def on_subscription(connection, notify_enabled, indicate_enabled):
if notify_enabled or indicate_enabled:
AsyncRunner.spawn(
device.notify_subscriber(
connection,
heart_rate_service.heart_rate_measurement_characteristic,
)
)
# Go! # Go!
await device.power_on() await device.power_on()
await device.start_advertising(auto_restart=True) await device.start_advertising(auto_restart=True)

248
examples/hid_key_map.py Normal file
View File

@@ -0,0 +1,248 @@
# shift map
# letters
shift_map = {
'a': 'A',
'b': 'B',
'c': 'C',
'd': 'D',
'e': 'E',
'f': 'F',
'g': 'G',
'h': 'H',
'i': 'I',
'j': 'J',
'k': 'K',
'l': 'L',
'm': 'M',
'n': 'N',
'o': 'O',
'p': 'P',
'q': 'Q',
'r': 'R',
's': 'S',
't': 'T',
'u': 'U',
'v': 'V',
'w': 'W',
'x': 'X',
'y': 'Y',
'z': 'Z',
# numbers
'1': '!',
'2': '@',
'3': '#',
'4': '$',
'5': '%',
'6': '^',
'7': '&',
'8': '*',
'9': '(',
'0': ')',
# symbols
'-': '_',
'=': '+',
'[': '{',
']': '}',
'\\': '|',
';': ':',
'\'': '"',
',': '<',
'.': '>',
'/': '?',
'`': '~',
}
# hex map
# modifier keys
mod_keys = {
'00': '',
'01': 'left_ctrl',
'02': 'left_shift',
'04': 'left_alt',
'08': 'left_meta',
'10': 'right_ctrl',
'20': 'right_shift',
'40': 'right_alt',
'80': 'right_meta',
}
# base keys
base_keys = {
# meta
'00': '', # none
'01': 'error_ovf',
# letters
'04': 'a',
'05': 'b',
'06': 'c',
'07': 'd',
'08': 'e',
'09': 'f',
'0a': 'g',
'0b': 'h',
'0c': 'i',
'0d': 'j',
'0e': 'k',
'0f': 'l',
'10': 'm',
'11': 'n',
'12': 'o',
'13': 'p',
'14': 'q',
'15': 'r',
'16': 's',
'17': 't',
'18': 'u',
'19': 'v',
'1a': 'w',
'1b': 'x',
'1c': 'y',
'1d': 'z',
# numbers
'1e': '1',
'1f': '2',
'20': '3',
'21': '4',
'22': '5',
'23': '6',
'24': '7',
'25': '8',
'26': '9',
'27': '0',
# misc
'28': 'enter', # enter \n
'29': 'esc',
'2a': 'backspace',
'2b': 'tab',
'2c': 'spacebar', # space
'2d': '-',
'2e': '=',
'2f': '[',
'30': ']',
'31': '\\',
'32': '=',
'33': '_SEMICOLON',
'34': 'KEY_APOSTROPHE',
'35': 'KEY_GRAVE',
'36': 'KEY_COMMA',
'37': 'KEY_DOT',
'38': 'KEY_SLASH',
'39': 'KEY_CAPSLOCK',
'3a': 'KEY_F1',
'3b': 'KEY_F2',
'3c': 'KEY_F3',
'3d': 'KEY_F4',
'3e': 'KEY_F5',
'3f': 'KEY_F6',
'40': 'KEY_F7',
'41': 'KEY_F8',
'42': 'KEY_F9',
'43': 'KEY_F10',
'44': 'KEY_F11',
'45': 'KEY_F12',
'46': 'KEY_SYSRQ',
'47': 'KEY_SCROLLLOCK',
'48': 'KEY_PAUSE',
'49': 'KEY_INSERT',
'4a': 'KEY_HOME',
'4b': 'KEY_PAGEUP',
'4c': 'KEY_DELETE',
'4d': 'KEY_END',
'4e': 'KEY_PAGEDOWN',
'4f': 'KEY_RIGHT',
'50': 'KEY_LEFT',
'51': 'KEY_DOWN',
'52': 'KEY_UP',
'53': 'KEY_NUMLOCK',
'54': 'KEY_KPSLASH',
'55': 'KEY_KPASTERISK',
'56': 'KEY_KPMINUS',
'57': 'KEY_KPPLUS',
'58': 'KEY_KPENTER',
'59': 'KEY_KP1',
'5a': 'KEY_KP2',
'5b': 'KEY_KP3',
'5c': 'KEY_KP4',
'5d': 'KEY_KP5',
'5e': 'KEY_KP6',
'5f': 'KEY_KP7',
'60': 'KEY_KP8',
'61': 'KEY_KP9',
'62': 'KEY_KP0',
'63': 'KEY_KPDOT',
'64': 'KEY_102ND',
'65': 'KEY_COMPOSE',
'66': 'KEY_POWER',
'67': 'KEY_KPEQUAL',
'68': 'KEY_F13',
'69': 'KEY_F14',
'6a': 'KEY_F15',
'6b': 'KEY_F16',
'6c': 'KEY_F17',
'6d': 'KEY_F18',
'6e': 'KEY_F19',
'6f': 'KEY_F20',
'70': 'KEY_F21',
'71': 'KEY_F22',
'72': 'KEY_F23',
'73': 'KEY_F24',
'74': 'KEY_OPEN',
'75': 'KEY_HELP',
'76': 'KEY_PROPS',
'77': 'KEY_FRONT',
'78': 'KEY_STOP',
'79': 'KEY_AGAIN',
'7a': 'KEY_UNDO',
'7b': 'KEY_CUT',
'7c': 'KEY_COPY',
'7d': 'KEY_PASTE',
'7e': 'KEY_FIND',
'7f': 'KEY_MUTE',
'80': 'KEY_VOLUMEUP',
'81': 'KEY_VOLUMEDOWN',
'85': 'KEY_KPCOMMA',
'87': 'KEY_RO',
'88': 'KEY_KATAKANAHIRAGANA',
'89': 'KEY_YEN',
'8a': 'KEY_HENKAN',
'8b': 'KEY_MUHENKAN',
'8c': 'KEY_KPJPCOMMA',
'90': 'KEY_HANGEUL',
'91': 'KEY_HANJA',
'92': 'KEY_KATAKANA',
'93': 'KEY_HIRAGANA',
'94': 'KEY_ZENKAKUHANKAKU',
'b6': 'KEY_KPLEFTPAREN',
'b7': 'KEY_KPRIGHTPAREN',
'e0': 'KEY_LEFTCTRL',
'e1': 'KEY_LEFTSHIFT',
'e2': 'KEY_LEFTALT',
'e3': 'KEY_LEFTMETA',
'e4': 'KEY_RIGHTCTRL',
'e5': 'KEY_RIGHTSHIFT',
'e6': 'KEY_RIGHTALT',
'e7': 'KEY_RIGHTMETA',
'e8': 'KEY_MEDIA_PLAYPAUSE',
'e9': 'KEY_MEDIA_STOPCD',
'ea': 'KEY_MEDIA_PREVIOUSSONG',
'eb': 'KEY_MEDIA_NEXTSONG',
'ec': 'KEY_MEDIA_EJECTCD',
'ed': 'KEY_MEDIA_VOLUMEUP',
'ee': 'KEY_MEDIA_VOLUMEDOWN',
'ef': 'KEY_MEDIA_MUTE',
'f0': 'KEY_MEDIA_WWW',
'f1': 'KEY_MEDIA_BACK',
'f2': 'KEY_MEDIA_FORWARD',
'f3': 'KEY_MEDIA_STOP',
'f4': 'KEY_MEDIA_FIND',
'f5': 'KEY_MEDIA_SCROLLUP',
'f6': 'KEY_MEDIA_SCROLLDOWN',
'f7': 'KEY_MEDIA_EDIT',
'f8': 'KEY_MEDIA_SLEEP',
'f9': 'KEY_MEDIA_COFFEE',
'fa': 'KEY_MEDIA_REFRESH',
'fb': 'KEY_MEDIA_CALC',
}

View File

@@ -0,0 +1,159 @@
from bumble.colors import color
from hid_key_map import base_keys, mod_keys, shift_map
# ------------------------------------------------------------------------------
def get_key(modifier: str, key: str) -> str:
if modifier == '22':
modifier = '02'
if modifier in mod_keys:
modifier = mod_keys[modifier]
else:
return ''
if key in base_keys:
key = base_keys[key]
else:
return ''
if (modifier == 'left_shift' or modifier == 'right_shift') and key in shift_map:
key = shift_map[key]
return key
class Keyboard:
def __init__(self): # type: ignore
self.report = [
[ # Bit array for Modifier keys
0, # Right GUI - (usually the Windows key)
0, # Right ALT
0, # Right Shift
0, # Right Control
0, # Left GUI - (usually the Windows key)
0, # Left ALT
0, # Left Shift
0, # Left Control
],
0x00, # Vendor reserved
'', # Rest is space for 6 keys
'',
'',
'',
'',
'',
]
def decode_keyboard_report(self, input_report: bytes, report_length: int) -> None:
if report_length >= 8:
modifier = input_report[1]
self.report[0] = [int(x) for x in '{0:08b}'.format(modifier)]
self.report[0].reverse() # type: ignore
modifier_key = str((modifier & 0x22).to_bytes(1, "big").hex())
keycodes = []
for k in range(3, report_length):
keycodes.append(str(input_report[k].to_bytes(1, "big").hex()))
self.report[k - 1] = get_key(modifier_key, keycodes[k - 3])
else:
print(color('Warning: Not able to parse report', 'yellow'))
def print_keyboard_report(self) -> None:
print(color('\tKeyboard Input Received', 'green', None, 'bold'))
print(color(f'Keys:', 'white', None, 'bold'))
for i in range(1, 7):
print(
color(f' Key{i}{" ":>8s}= ', 'cyan', None, 'bold'), self.report[i + 1]
)
print(color(f'\nModifier Keys:', 'white', None, 'bold'))
print(
color(f' Left Ctrl : ', 'cyan'),
f'{self.report[0][0] == 1!s:<5}', # type: ignore
color(f' Left Shift : ', 'cyan'),
f'{self.report[0][1] == 1!s:<5}', # type: ignore
color(f' Left ALT : ', 'cyan'),
f'{self.report[0][2] == 1!s:<5}', # type: ignore
color(f' Left GUI : ', 'cyan'),
f'{self.report[0][3] == 1!s:<5}\n', # type: ignore
color(f' Right Ctrl : ', 'cyan'),
f'{self.report[0][4] == 1!s:<5}', # type: ignore
color(f' Right Shift : ', 'cyan'),
f'{self.report[0][5] == 1!s:<5}', # type: ignore
color(f' Right ALT : ', 'cyan'),
f'{self.report[0][6] == 1!s:<5}', # type: ignore
color(f' Right GUI : ', 'cyan'),
f'{self.report[0][7] == 1!s:<5}', # type: ignore
)
# ------------------------------------------------------------------------------
class Mouse:
def __init__(self): # type: ignore
self.report = [
[ # Bit array for Buttons
0, # Button 1 (primary/trigger
0, # Button 2 (secondary)
0, # Button 3 (tertiary)
0, # Button 4
0, # Button 5
0, # unused padding bits
0, # unused padding bits
0, # unused padding bits
],
0, # X
0, # Y
0, # Wheel
0, # AC Pan
]
def decode_mouse_report(self, input_report: bytes, report_length: int) -> None:
self.report[0] = [int(x) for x in '{0:08b}'.format(input_report[1])]
self.report[0].reverse() # type: ignore
self.report[1] = input_report[2]
self.report[2] = input_report[3]
if report_length in [5, 6]:
self.report[3] = input_report[4]
self.report[4] = input_report[5] if report_length == 6 else 0
def print_mouse_report(self) -> None:
print(color('\tMouse Input Received', 'green', None, 'bold'))
print(
color(f' Button 1 (primary/trigger) = ', 'cyan'),
self.report[0][0] == 1, # type: ignore
color(f'\n Button 2 (secondary) = ', 'cyan'),
self.report[0][1] == 1, # type: ignore
color(f'\n Button 3 (tertiary) = ', 'cyan'),
self.report[0][2] == 1, # type: ignore
color(f'\n Button4 = ', 'cyan'),
self.report[0][3] == 1, # type: ignore
color(f'\n Button5 = ', 'cyan'),
self.report[0][4] == 1, # type: ignore
color(f'\n X (X-axis displacement) = ', 'cyan'),
self.report[1],
color(f'\n Y (Y-axis displacement) = ', 'cyan'),
self.report[2],
color(f'\n Wheel = ', 'cyan'),
self.report[3],
color(f'\n AC PAN = ', 'cyan'),
self.report[4],
)
# ------------------------------------------------------------------------------
class ReportParser:
@staticmethod
def parse_input_report(input_report: bytes) -> None:
report_id = input_report[0] # pylint: disable=unsubscriptable-object
report_length = len(input_report)
# Keyboard input report (report id = 1)
if report_id == 1 and report_length >= 8:
keyboard = Keyboard() # type: ignore
keyboard.decode_keyboard_report(input_report, report_length)
keyboard.print_keyboard_report()
# Mouse input report (report id = 2)
elif report_id == 2 and report_length in [4, 5, 6]:
mouse = Mouse() # type: ignore
mouse.decode_mouse_report(input_report, report_length)
mouse.print_mouse_report()
else:
print(color(f'Warning: Parse Error Report ID {report_id}', 'yellow'))

5
examples/leaudio.json Normal file
View File

@@ -0,0 +1,5 @@
{
"name": "Bumble-LEA",
"keystore": "JsonKeyStore",
"advertising_interval": 100
}

View File

@@ -53,10 +53,10 @@ def sdp_records():
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
# pylint: disable-next=too-many-nested-blocks # pylint: disable-next=too-many-nested-blocks
async def find_a2dp_service(device, connection): async def find_a2dp_service(connection):
# Connect to the SDP Server # Connect to the SDP Server
sdp_client = SDP_Client(device) sdp_client = SDP_Client(connection)
await sdp_client.connect(connection) await sdp_client.connect()
# Search for services with an Audio Sink service class # Search for services with an Audio Sink service class
search_result = await sdp_client.search_attributes( search_result = await sdp_client.search_attributes(
@@ -177,7 +177,7 @@ async def main():
print('*** Encryption on') print('*** Encryption on')
# Look for an A2DP service # Look for an A2DP service
avdtp_version = await find_a2dp_service(device, connection) avdtp_version = await find_a2dp_service(connection)
if not avdtp_version: if not avdtp_version:
print(color('!!! no AVDTP service found')) print(color('!!! no AVDTP service found'))
return return

View File

@@ -131,7 +131,7 @@ async def main():
await device.power_on() await device.power_on()
# Create a listener to wait for AVDTP connections # Create a listener to wait for AVDTP connections
listener = Listener(Listener.create_registrar(device)) listener = Listener.for_device(device)
listener.on('connection', on_avdtp_connection) listener.on('connection', on_avdtp_connection)
if len(sys.argv) >= 5: if len(sys.argv) >= 5:

View File

@@ -165,9 +165,7 @@ async def main():
print('*** Encryption on') print('*** Encryption on')
# Look for an A2DP service # Look for an A2DP service
avdtp_version = await find_avdtp_service_with_connection( avdtp_version = await find_avdtp_service_with_connection(connection)
device, connection
)
if not avdtp_version: if not avdtp_version:
print(color('!!! no A2DP service found')) print(color('!!! no A2DP service found'))
return return
@@ -179,7 +177,7 @@ async def main():
await stream_packets(read, protocol) await stream_packets(read, protocol)
else: else:
# Create a listener to wait for AVDTP connections # Create a listener to wait for AVDTP connections
listener = Listener(Listener.create_registrar(device), version=(1, 2)) listener = Listener.for_device(device=device, version=(1, 2))
listener.on( listener.on(
'connection', lambda protocol: on_avdtp_connection(read, protocol) 'connection', lambda protocol: on_avdtp_connection(read, protocol)
) )

View File

@@ -21,6 +21,7 @@ import sys
import os import os
import logging import logging
from bumble import l2cap
from bumble.core import AdvertisingData from bumble.core import AdvertisingData
from bumble.device import Device from bumble.device import Device
from bumble.transport import open_transport_or_link from bumble.transport import open_transport_or_link
@@ -95,8 +96,10 @@ async def main():
channel.sink = on_data channel.sink = on_data
psm = device.register_l2cap_channel_server(0, on_coc, 8) server = device.create_l2cap_server(
print(f'### LE_PSM_OUT = {psm}') spec=l2cap.LeCreditBasedChannelSpec(max_credits=8), handler=on_coc
)
print(f'### LE_PSM_OUT = {server.psm}')
# Add the ASHA service to the GATT server # Add the ASHA service to the GATT server
read_only_properties_characteristic = Characteristic( read_only_properties_characteristic = Characteristic(
@@ -147,7 +150,7 @@ async def main():
ASHA_LE_PSM_OUT_CHARACTERISTIC, ASHA_LE_PSM_OUT_CHARACTERISTIC,
Characteristic.Properties.READ, Characteristic.Properties.READ,
Characteristic.READABLE, Characteristic.READABLE,
struct.pack('<H', psm), struct.pack('<H', server.psm),
) )
device.add_service( device.add_service(
Service( Service(

107
examples/run_cig_setup.py Normal file
View File

@@ -0,0 +1,107 @@
# Copyright 2021-2023 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 asyncio
import logging
import sys
import os
from bumble.device import (
Device,
Connection,
)
from bumble.hci import (
OwnAddressType,
HCI_LE_Set_Extended_Advertising_Parameters_Command,
)
from bumble.transport import open_transport_or_link
# -----------------------------------------------------------------------------
async def main() -> None:
if len(sys.argv) < 3:
print(
'Usage: run_cig_setup.py <config-file>'
'<transport-spec-for-device-1> <transport-spec-for-device-2>'
)
print(
'example: run_cig_setup.py device1.json'
'tcp-client:127.0.0.1:6402 tcp-client:127.0.0.1:6402'
)
return
print('<<< connecting to HCI...')
hci_transports = await asyncio.gather(
open_transport_or_link(sys.argv[2]), open_transport_or_link(sys.argv[3])
)
print('<<< connected')
devices = [
Device.from_config_file_with_hci(
sys.argv[1], hci_transport.source, hci_transport.sink
)
for hci_transport in hci_transports
]
devices[0].cis_enabled = True
devices[1].cis_enabled = True
await asyncio.gather(*[device.power_on() for device in devices])
await devices[0].start_extended_advertising(
advertising_properties=(
HCI_LE_Set_Extended_Advertising_Parameters_Command.AdvertisingProperties.CONNECTABLE_ADVERTISING
),
own_address_type=OwnAddressType.PUBLIC,
)
connection = await devices[1].connect(
devices[0].public_address, own_address_type=OwnAddressType.PUBLIC
)
cid_ids = [2, 3]
cis_handles = await devices[1].setup_cig(
cig_id=1,
cis_id=cid_ids,
sdu_interval=(10000, 0),
framing=0,
max_sdu=(120, 0),
retransmission_number=13,
max_transport_latency=(100, 0),
)
def on_cis_request(
connection: Connection, cis_handle: int, _cig_id: int, _cis_id: int
):
connection.abort_on('disconnection', devices[0].accept_cis_request(cis_handle))
devices[0].on('cis_request', on_cis_request)
cis_links = await devices[1].create_cis(
[(cis, connection.handle) for cis in cis_handles]
)
for cis_link in cis_links:
await cis_link.disconnect()
await asyncio.gather(
*[hci_transport.source.terminated for hci_transport in hci_transports]
)
# -----------------------------------------------------------------------------
logging.basicConfig(level=os.environ.get('BUMBLE_LOGLEVEL', 'DEBUG').upper())
asyncio.run(main())

View File

@@ -63,8 +63,8 @@ async def main():
print(f'=== Connected to {connection.peer_address}!') print(f'=== Connected to {connection.peer_address}!')
# Connect to the SDP Server # Connect to the SDP Server
sdp_client = SDP_Client(device) sdp_client = SDP_Client(connection)
await sdp_client.connect(connection) await sdp_client.connect()
# List all services in the root browse group # List all services in the root browse group
service_record_handles = await sdp_client.search_services( service_record_handles = await sdp_client.search_services(

View File

@@ -0,0 +1,86 @@
# 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 asyncio
import dataclasses
import logging
import sys
import os
from bumble.core import BT_BR_EDR_TRANSPORT
from bumble.device import Device, ScoLink
from bumble.hci import HCI_Enhanced_Setup_Synchronous_Connection_Command
from bumble.hfp import DefaultCodecParameters, ESCO_PARAMETERS
from bumble.transport import open_transport_or_link
# -----------------------------------------------------------------------------
async def main() -> None:
if len(sys.argv) < 3:
print(
'Usage: run_esco_connection.py <config-file>'
'<transport-spec-for-device-1> <transport-spec-for-device-2>'
)
print(
'example: run_esco_connection.py classic1.json'
'tcp-client:127.0.0.1:6402 tcp-client:127.0.0.1:6402'
)
return
print('<<< connecting to HCI...')
hci_transports = await asyncio.gather(
open_transport_or_link(sys.argv[2]), open_transport_or_link(sys.argv[3])
)
print('<<< connected')
devices = [
Device.from_config_file_with_hci(
sys.argv[1], hci_transport.source, hci_transport.sink
)
for hci_transport in hci_transports
]
devices[0].classic_enabled = True
devices[1].classic_enabled = True
await asyncio.gather(*[device.power_on() for device in devices])
connections = await asyncio.gather(
devices[0].accept(devices[1].public_address),
devices[1].connect(devices[0].public_address, transport=BT_BR_EDR_TRANSPORT),
)
def on_sco(sco_link: ScoLink):
connections[0].abort_on('disconnection', sco_link.disconnect())
devices[0].once('sco_connection', on_sco)
await devices[0].send_command(
HCI_Enhanced_Setup_Synchronous_Connection_Command(
connection_handle=connections[0].handle,
**ESCO_PARAMETERS[DefaultCodecParameters.ESCO_CVSD_S3].asdict(),
)
)
await asyncio.gather(
*[hci_transport.source.terminated for hci_transport in hci_transports]
)
# -----------------------------------------------------------------------------
logging.basicConfig(level=os.environ.get('BUMBLE_LOGLEVEL', 'DEBUG').upper())
asyncio.run(main())

View File

@@ -0,0 +1,69 @@
# 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 asyncio
import logging
import sys
import os
from bumble.device import AdvertisingType, Device
from bumble.hci import Address, HCI_LE_Set_Extended_Advertising_Parameters_Command
from bumble.transport import open_transport_or_link
# -----------------------------------------------------------------------------
async def main() -> None:
if len(sys.argv) < 3:
print(
'Usage: run_extended_advertiser.py <config-file> <transport-spec> [type] [address]'
)
print('example: run_extended_advertiser.py device1.json usb:0')
return
if len(sys.argv) >= 4:
advertising_properties = (
HCI_LE_Set_Extended_Advertising_Parameters_Command.AdvertisingProperties(
int(sys.argv[3])
)
)
else:
advertising_properties = (
HCI_LE_Set_Extended_Advertising_Parameters_Command.AdvertisingProperties.CONNECTABLE_ADVERTISING
)
if len(sys.argv) >= 5:
target = Address(sys.argv[4])
else:
target = Address.ANY
print('<<< connecting to HCI...')
async with await open_transport_or_link(sys.argv[2]) as hci_transport:
print('<<< connected')
device = Device.from_config_file_with_hci(
sys.argv[1], hci_transport.source, hci_transport.sink
)
await device.power_on()
await device.start_extended_advertising(
advertising_properties=advertising_properties, target=target
)
await hci_transport.source.terminated
# -----------------------------------------------------------------------------
logging.basicConfig(level=os.environ.get('BUMBLE_LOGLEVEL', 'DEBUG').upper())
asyncio.run(main())

View File

@@ -31,6 +31,7 @@ from bumble.core import (
BT_BR_EDR_TRANSPORT, BT_BR_EDR_TRANSPORT,
) )
from bumble import rfcomm, hfp from bumble import rfcomm, hfp
from bumble.hci import HCI_SynchronousDataPacket
from bumble.sdp import ( from bumble.sdp import (
Client as SDP_Client, Client as SDP_Client,
DataElement, DataElement,
@@ -48,8 +49,8 @@ logger = logging.getLogger(__name__)
# pylint: disable-next=too-many-nested-blocks # pylint: disable-next=too-many-nested-blocks
async def list_rfcomm_channels(device, connection): async def list_rfcomm_channels(device, connection):
# Connect to the SDP Server # Connect to the SDP Server
sdp_client = SDP_Client(device) sdp_client = SDP_Client(connection)
await sdp_client.connect(connection) await sdp_client.connect()
# Search for services that support the Handsfree Profile # Search for services that support the Handsfree Profile
search_result = await sdp_client.search_attributes( search_result = await sdp_client.search_attributes(
@@ -183,7 +184,7 @@ async def main():
# Create a client and start it # Create a client and start it
print('@@@ Starting to RFCOMM client...') print('@@@ Starting to RFCOMM client...')
rfcomm_client = rfcomm.Client(device, connection) rfcomm_client = rfcomm.Client(connection)
rfcomm_mux = await rfcomm_client.start() rfcomm_mux = await rfcomm_client.start()
print('@@@ Started') print('@@@ Started')
@@ -197,6 +198,13 @@ async def main():
print('@@@ Disconnected from RFCOMM server') print('@@@ Disconnected from RFCOMM server')
return return
def on_sco(connection_handle: int, packet: HCI_SynchronousDataPacket):
# Reset packet and loopback
packet.packet_status = 0
device.host.send_hci_packet(packet)
device.host.on('sco_packet', on_sco)
# Protocol loop (just for testing at this point) # Protocol loop (just for testing at this point)
protocol = hfp.HfpProtocol(session) protocol = hfp.HfpProtocol(session)
while True: while True:

535
examples/run_hid_host.py Normal file
View File

@@ -0,0 +1,535 @@
# 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 asyncio
import sys
import os
import logging
from bumble.colors import color
from bumble.device import Device
from bumble.transport import open_transport_or_link
from bumble.core import (
BT_HUMAN_INTERFACE_DEVICE_SERVICE,
BT_BR_EDR_TRANSPORT,
)
from bumble.hci import Address
from bumble.hid import Host, Message
from bumble.sdp import (
Client as SDP_Client,
SDP_PROTOCOL_DESCRIPTOR_LIST_ATTRIBUTE_ID,
SDP_SERVICE_CLASS_ID_LIST_ATTRIBUTE_ID,
SDP_BLUETOOTH_PROFILE_DESCRIPTOR_LIST_ATTRIBUTE_ID,
SDP_ALL_ATTRIBUTES_RANGE,
SDP_LANGUAGE_BASE_ATTRIBUTE_ID_LIST_ATTRIBUTE_ID,
SDP_ADDITIONAL_PROTOCOL_DESCRIPTOR_LIST_ATTRIBUTE_ID,
SDP_SERVICE_RECORD_HANDLE_ATTRIBUTE_ID,
SDP_BROWSE_GROUP_LIST_ATTRIBUTE_ID,
)
from hid_report_parser import ReportParser
# -----------------------------------------------------------------------------
# SDP attributes for Bluetooth HID devices
SDP_HID_SERVICE_NAME_ATTRIBUTE_ID = 0x0100
SDP_HID_SERVICE_DESCRIPTION_ATTRIBUTE_ID = 0x0101
SDP_HID_PROVIDER_NAME_ATTRIBUTE_ID = 0x0102
SDP_HID_DEVICE_RELEASE_NUMBER_ATTRIBUTE_ID = 0x0200 # [DEPRECATED]
SDP_HID_PARSER_VERSION_ATTRIBUTE_ID = 0x0201
SDP_HID_DEVICE_SUBCLASS_ATTRIBUTE_ID = 0x0202
SDP_HID_COUNTRY_CODE_ATTRIBUTE_ID = 0x0203
SDP_HID_VIRTUAL_CABLE_ATTRIBUTE_ID = 0x0204
SDP_HID_RECONNECT_INITIATE_ATTRIBUTE_ID = 0x0205
SDP_HID_DESCRIPTOR_LIST_ATTRIBUTE_ID = 0x0206
SDP_HID_LANGID_BASE_LIST_ATTRIBUTE_ID = 0x0207
SDP_HID_SDP_DISABLE_ATTRIBUTE_ID = 0x0208 # [DEPRECATED]
SDP_HID_BATTERY_POWER_ATTRIBUTE_ID = 0x0209
SDP_HID_REMOTE_WAKE_ATTRIBUTE_ID = 0x020A
SDP_HID_PROFILE_VERSION_ATTRIBUTE_ID = 0x020B # DEPRECATED]
SDP_HID_SUPERVISION_TIMEOUT_ATTRIBUTE_ID = 0x020C
SDP_HID_NORMALLY_CONNECTABLE_ATTRIBUTE_ID = 0x020D
SDP_HID_BOOT_DEVICE_ATTRIBUTE_ID = 0x020E
SDP_HID_SSR_HOST_MAX_LATENCY_ATTRIBUTE_ID = 0x020F
SDP_HID_SSR_HOST_MIN_TIMEOUT_ATTRIBUTE_ID = 0x0210
# -----------------------------------------------------------------------------
async def get_hid_device_sdp_record(connection):
# Connect to the SDP Server
sdp_client = SDP_Client(connection)
await sdp_client.connect()
if sdp_client:
print(color('Connected to SDP Server', 'blue'))
else:
print(color('Failed to connect to SDP Server', 'red'))
# List BT HID Device service in the root browse group
service_record_handles = await sdp_client.search_services(
[BT_HUMAN_INTERFACE_DEVICE_SERVICE]
)
if len(service_record_handles) < 1:
await sdp_client.disconnect()
raise Exception(
color(f'BT HID Device service not found on peer device!!!!', 'red')
)
# For BT_HUMAN_INTERFACE_DEVICE_SERVICE service, get all its attributes
for service_record_handle in service_record_handles:
attributes = await sdp_client.get_attributes(
service_record_handle, [SDP_ALL_ATTRIBUTES_RANGE]
)
print(color(f'SERVICE {service_record_handle:04X} attributes:', 'yellow'))
print(color(f'SDP attributes for HID device', 'magenta'))
for attribute in attributes:
if attribute.id == SDP_SERVICE_RECORD_HANDLE_ATTRIBUTE_ID:
print(
color(' Service Record Handle : ', 'cyan'),
hex(attribute.value.value),
)
elif attribute.id == SDP_SERVICE_CLASS_ID_LIST_ATTRIBUTE_ID:
print(
color(' Service Class : ', 'cyan'), attribute.value.value[0].value
)
elif attribute.id == SDP_BROWSE_GROUP_LIST_ATTRIBUTE_ID:
print(
color(' SDP Browse Group List : ', 'cyan'),
attribute.value.value[0].value,
)
elif attribute.id == SDP_PROTOCOL_DESCRIPTOR_LIST_ATTRIBUTE_ID:
print(
color(' BT_L2CAP_PROTOCOL_ID : ', 'cyan'),
attribute.value.value[0].value[0].value,
)
print(
color(' PSM for Bluetooth HID Control channel : ', 'cyan'),
hex(attribute.value.value[0].value[1].value),
)
print(
color(' BT_HIDP_PROTOCOL_ID : ', 'cyan'),
attribute.value.value[1].value[0].value,
)
elif attribute.id == SDP_LANGUAGE_BASE_ATTRIBUTE_ID_LIST_ATTRIBUTE_ID:
print(
color(' Lanugage : ', 'cyan'), hex(attribute.value.value[0].value)
)
print(
color(' Encoding : ', 'cyan'), hex(attribute.value.value[1].value)
)
print(
color(' PrimaryLanguageBaseID : ', 'cyan'),
hex(attribute.value.value[2].value),
)
elif attribute.id == SDP_BLUETOOTH_PROFILE_DESCRIPTOR_LIST_ATTRIBUTE_ID:
print(
color(' BT_HUMAN_INTERFACE_DEVICE_SERVICE ', 'cyan'),
attribute.value.value[0].value[0].value,
)
print(
color(' HID Profileversion number : ', 'cyan'),
hex(attribute.value.value[0].value[1].value),
)
elif attribute.id == SDP_ADDITIONAL_PROTOCOL_DESCRIPTOR_LIST_ATTRIBUTE_ID:
print(
color(' BT_L2CAP_PROTOCOL_ID : ', 'cyan'),
attribute.value.value[0].value[0].value[0].value,
)
print(
color(' PSM for Bluetooth HID Interrupt channel : ', 'cyan'),
hex(attribute.value.value[0].value[0].value[1].value),
)
print(
color(' BT_HIDP_PROTOCOL_ID : ', 'cyan'),
attribute.value.value[0].value[1].value[0].value,
)
elif attribute.id == SDP_HID_SERVICE_NAME_ATTRIBUTE_ID:
print(color(' Service Name: ', 'cyan'), attribute.value.value)
elif attribute.id == SDP_HID_SERVICE_DESCRIPTION_ATTRIBUTE_ID:
print(color(' Service Description: ', 'cyan'), attribute.value.value)
elif attribute.id == SDP_HID_PROVIDER_NAME_ATTRIBUTE_ID:
print(color(' Provider Name: ', 'cyan'), attribute.value.value)
elif attribute.id == SDP_HID_DEVICE_RELEASE_NUMBER_ATTRIBUTE_ID:
print(color(' Release Number: ', 'cyan'), hex(attribute.value.value))
elif attribute.id == SDP_HID_PARSER_VERSION_ATTRIBUTE_ID:
print(
color(' HID Parser Version: ', 'cyan'), hex(attribute.value.value)
)
elif attribute.id == SDP_HID_DEVICE_SUBCLASS_ATTRIBUTE_ID:
print(
color(' HIDDeviceSubclass: ', 'cyan'), hex(attribute.value.value)
)
elif attribute.id == SDP_HID_COUNTRY_CODE_ATTRIBUTE_ID:
print(color(' HIDCountryCode: ', 'cyan'), hex(attribute.value.value))
elif attribute.id == SDP_HID_VIRTUAL_CABLE_ATTRIBUTE_ID:
print(color(' HIDVirtualCable: ', 'cyan'), attribute.value.value)
elif attribute.id == SDP_HID_RECONNECT_INITIATE_ATTRIBUTE_ID:
print(color(' HIDReconnectInitiate: ', 'cyan'), attribute.value.value)
elif attribute.id == SDP_HID_DESCRIPTOR_LIST_ATTRIBUTE_ID:
print(
color(' HID Report Descriptor type: ', 'cyan'),
hex(attribute.value.value[0].value[0].value),
)
print(
color(' HID Report DescriptorList: ', 'cyan'),
attribute.value.value[0].value[1].value,
)
elif attribute.id == SDP_HID_LANGID_BASE_LIST_ATTRIBUTE_ID:
print(
color(' HID LANGID Base Language: ', 'cyan'),
hex(attribute.value.value[0].value[0].value),
)
print(
color(' HID LANGID Base Bluetooth String Offset: ', 'cyan'),
hex(attribute.value.value[0].value[1].value),
)
elif attribute.id == SDP_HID_BATTERY_POWER_ATTRIBUTE_ID:
print(color(' HIDBatteryPower: ', 'cyan'), attribute.value.value)
elif attribute.id == SDP_HID_REMOTE_WAKE_ATTRIBUTE_ID:
print(color(' HIDRemoteWake: ', 'cyan'), attribute.value.value)
elif attribute.id == SDP_HID_PROFILE_VERSION_ATTRIBUTE_ID:
print(
color(' HIDProfileVersion : ', 'cyan'), hex(attribute.value.value)
)
elif attribute.id == SDP_HID_SUPERVISION_TIMEOUT_ATTRIBUTE_ID:
print(
color(' HIDSupervisionTimeout: ', 'cyan'),
hex(attribute.value.value),
)
elif attribute.id == SDP_HID_NORMALLY_CONNECTABLE_ATTRIBUTE_ID:
print(
color(' HIDNormallyConnectable: ', 'cyan'), attribute.value.value
)
elif attribute.id == SDP_HID_BOOT_DEVICE_ATTRIBUTE_ID:
print(color(' HIDBootDevice: ', 'cyan'), attribute.value.value)
elif attribute.id == SDP_HID_SSR_HOST_MAX_LATENCY_ATTRIBUTE_ID:
print(
color(' HIDSSRHostMaxLatency: ', 'cyan'),
hex(attribute.value.value),
)
elif attribute.id == SDP_HID_SSR_HOST_MIN_TIMEOUT_ATTRIBUTE_ID:
print(
color(' HIDSSRHostMinTimeout: ', 'cyan'),
hex(attribute.value.value),
)
else:
print(
color(
f' Warning: Attribute ID: {attribute.id} match not found.\n Attribute Info: {attribute}',
'yellow',
)
)
await sdp_client.disconnect()
# -----------------------------------------------------------------------------
async def get_stream_reader(pipe) -> asyncio.StreamReader:
loop = asyncio.get_event_loop()
reader = asyncio.StreamReader(loop=loop)
protocol = asyncio.StreamReaderProtocol(reader)
await loop.connect_read_pipe(lambda: protocol, pipe)
return reader
# -----------------------------------------------------------------------------
async def main():
if len(sys.argv) < 4:
print(
'Usage: run_hid_host.py <device-config> <transport-spec> '
'<bluetooth-address> [test-mode]'
)
print('example: run_hid_host.py classic1.json usb:0 E1:CA:72:48:C4:E8/P')
return
def on_hid_data_cb(pdu):
report_type = pdu[0] & 0x0F
if len(pdu) == 1:
print(color(f'Warning: No report received', 'yellow'))
return
report_length = len(pdu[1:])
report_id = pdu[1]
if report_type != Message.ReportType.OTHER_REPORT:
print(
color(
f' Report type = {report_type}, Report length = {report_length}, Report id = {report_id}',
'blue',
None,
'bold',
)
)
if (report_length <= 1) or (report_id == 0):
return
if report_type == Message.ReportType.INPUT_REPORT:
ReportParser.parse_input_report(pdu[1:]) # type: ignore
async def handle_virtual_cable_unplug():
await hid_host.disconnect_interrupt_channel()
await hid_host.disconnect_control_channel()
await device.keystore.delete(target_address) # type: ignore
await connection.disconnect()
def on_hid_virtual_cable_unplug_cb():
asyncio.create_task(handle_virtual_cable_unplug())
print('<<< connecting to HCI...')
async with await open_transport_or_link(sys.argv[2]) as (hci_source, hci_sink):
print('<<< CONNECTED')
# Create a device
device = Device.from_config_file_with_hci(sys.argv[1], hci_source, hci_sink)
device.classic_enabled = True
await device.power_on()
# Connect to a peer
target_address = sys.argv[3]
print(f'=== Connecting to {target_address}...')
connection = await device.connect(target_address, transport=BT_BR_EDR_TRANSPORT)
print(f'=== Connected to {connection.peer_address}!')
# Request authentication
print('*** Authenticating...')
await connection.authenticate()
print('*** Authenticated...')
# Enable encryption
print('*** Enabling encryption...')
await connection.encrypt()
print('*** Encryption on')
await get_hid_device_sdp_record(connection)
# Create HID host and start it
print('@@@ Starting HID Host...')
hid_host = Host(device, connection)
# Register for HID data call back
hid_host.on('data', on_hid_data_cb)
# Register for virtual cable unplug call back
hid_host.on('virtual_cable_unplug', on_hid_virtual_cable_unplug_cb)
async def menu():
reader = await get_stream_reader(sys.stdin)
while True:
print(
"\n************************ HID Host Menu *****************************\n"
)
print(" 1. Connect Control Channel")
print(" 2. Connect Interrupt Channel")
print(" 3. Disconnect Control Channel")
print(" 4. Disconnect Interrupt Channel")
print(" 5. Get Report")
print(" 6. Set Report")
print(" 7. Set Protocol Mode")
print(" 8. Get Protocol Mode")
print(" 9. Send Report")
print("10. Suspend")
print("11. Exit Suspend")
print("12. Virtual Cable Unplug")
print("13. Disconnect device")
print("14. Delete Bonding")
print("15. Re-connect to device")
print("\nEnter your choice : \n")
choice = await reader.readline()
choice = choice.decode('utf-8').strip()
if choice == '1':
await hid_host.connect_control_channel()
elif choice == '2':
await hid_host.connect_interrupt_channel()
elif choice == '3':
await hid_host.disconnect_control_channel()
elif choice == '4':
await hid_host.disconnect_interrupt_channel()
elif choice == '5':
print(" 1. Report ID 0x02")
print(" 2. Report ID 0x03")
print(" 3. Report ID 0x05")
choice1 = await reader.readline()
choice1 = choice1.decode('utf-8').strip()
if choice1 == '1':
hid_host.get_report(1, 2, 3)
elif choice1 == '2':
hid_host.get_report(2, 3, 2)
elif choice1 == '3':
hid_host.get_report(3, 5, 3)
else:
print('Incorrect option selected')
elif choice == '6':
print(" 1. Report type 1 and Report id 0x01")
print(" 2. Report type 2 and Report id 0x03")
print(" 3. Report type 3 and Report id 0x05")
choice1 = await reader.readline()
choice1 = choice1.decode('utf-8').strip()
if choice1 == '1':
# data includes first octet as report id
data = bytearray(
[0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01]
)
hid_host.set_report(1, data)
elif choice1 == '2':
data = bytearray([0x03, 0x01, 0x01])
hid_host.set_report(2, data)
elif choice1 == '3':
data = bytearray([0x05, 0x01, 0x01, 0x01])
hid_host.set_report(3, data)
else:
print('Incorrect option selected')
elif choice == '7':
print(" 0. Boot")
print(" 1. Report")
choice1 = await reader.readline()
choice1 = choice1.decode('utf-8').strip()
if choice1 == '0':
hid_host.set_protocol(Message.ProtocolMode.BOOT_PROTOCOL)
elif choice1 == '1':
hid_host.set_protocol(Message.ProtocolMode.REPORT_PROTOCOL)
else:
print('Incorrect option selected')
elif choice == '8':
hid_host.get_protocol()
elif choice == '9':
print(" 1. Report ID 0x01")
print(" 2. Report ID 0x03")
choice1 = await reader.readline()
choice1 = choice1.decode('utf-8').strip()
if choice1 == '1':
data = bytearray(
[0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00]
)
hid_host.send_data(data)
elif choice1 == '2':
data = bytearray([0x03, 0x00, 0x0D, 0xFD, 0x00, 0x00])
hid_host.send_data(data)
else:
print('Incorrect option selected')
elif choice == '10':
hid_host.suspend()
elif choice == '11':
hid_host.exit_suspend()
elif choice == '12':
hid_host.virtual_cable_unplug()
try:
await device.keystore.delete(target_address)
except KeyError:
print('Device not found or Device already unpaired.')
elif choice == '13':
peer_address = Address.from_string_for_transport(
target_address, transport=BT_BR_EDR_TRANSPORT
)
connection = device.find_connection_by_bd_addr(
peer_address, transport=BT_BR_EDR_TRANSPORT
)
if connection is not None:
await connection.disconnect()
else:
print("Already disconnected from device")
elif choice == '14':
try:
await device.keystore.delete(target_address)
print("Unpair successful")
except KeyError:
print('Device not found or Device already unpaired.')
elif choice == '15':
connection = await device.connect(
target_address, transport=BT_BR_EDR_TRANSPORT
)
await connection.authenticate()
await connection.encrypt()
else:
print("Invalid option selected.")
if (len(sys.argv) > 4) and (sys.argv[4] == 'test-mode'):
# Enabling menu for testing
await menu()
else:
# HID Connection
# Control channel
await hid_host.connect_control_channel()
# Interrupt Channel
await hid_host.connect_interrupt_channel()
await hci_source.wait_for_termination()
# -----------------------------------------------------------------------------
logging.basicConfig(level=os.environ.get('BUMBLE_LOGLEVEL', 'DEBUG').upper())
asyncio.run(main())

View File

@@ -42,10 +42,10 @@ from bumble.sdp import (
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
async def list_rfcomm_channels(device, connection): async def list_rfcomm_channels(connection):
# Connect to the SDP Server # Connect to the SDP Server
sdp_client = SDP_Client(device) sdp_client = SDP_Client(connection)
await sdp_client.connect(connection) await sdp_client.connect()
# Search for services with an L2CAP service attribute # Search for services with an L2CAP service attribute
search_result = await sdp_client.search_attributes( search_result = await sdp_client.search_attributes(
@@ -194,7 +194,7 @@ async def main():
channel = sys.argv[4] channel = sys.argv[4]
if channel == 'discover': if channel == 'discover':
await list_rfcomm_channels(device, connection) await list_rfcomm_channels(connection)
return return
# Request authentication # Request authentication
@@ -209,7 +209,7 @@ async def main():
# Create a client and start it # Create a client and start it
print('@@@ Starting RFCOMM client...') print('@@@ Starting RFCOMM client...')
rfcomm_client = Client(device, connection) rfcomm_client = Client(connection)
rfcomm_mux = await rfcomm_client.start() rfcomm_mux = await rfcomm_client.start()
print('@@@ Started') print('@@@ Started')

View File

@@ -0,0 +1,134 @@
# Copyright 2021-2023 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 asyncio
import logging
import sys
import os
from bumble.core import AdvertisingData
from bumble.device import Device
from bumble.hci import (
CodecID,
CodingFormat,
OwnAddressType,
HCI_LE_Set_Extended_Advertising_Parameters_Command,
)
from bumble.profiles.bap import (
CodecSpecificCapabilities,
ContextType,
AudioLocation,
SupportedSamplingFrequency,
SupportedFrameDuration,
PacRecord,
PublishedAudioCapabilitiesService,
)
from bumble.transport import open_transport_or_link
# -----------------------------------------------------------------------------
async def main() -> None:
if len(sys.argv) < 3:
print('Usage: run_cig_setup.py <config-file>' '<transport-spec-for-device>')
return
print('<<< connecting to HCI...')
async with await open_transport_or_link(sys.argv[2]) as hci_transport:
print('<<< connected')
device = Device.from_config_file_with_hci(
sys.argv[1], hci_transport.source, hci_transport.sink
)
device.cis_enabled = True
await device.power_on()
device.add_service(
PublishedAudioCapabilitiesService(
supported_source_context=ContextType.PROHIBITED,
available_source_context=ContextType.PROHIBITED,
supported_sink_context=ContextType.MEDIA,
available_sink_context=ContextType.MEDIA,
sink_audio_locations=(
AudioLocation.FRONT_LEFT | AudioLocation.FRONT_RIGHT
),
sink_pac=[
# Codec Capability Setting 16_2
PacRecord(
coding_format=CodingFormat(CodecID.LC3),
codec_specific_capabilities=CodecSpecificCapabilities(
supported_sampling_frequencies=(
SupportedSamplingFrequency.FREQ_16000
),
supported_frame_durations=(
SupportedFrameDuration.DURATION_10000_US_SUPPORTED
),
supported_audio_channel_counts=[1],
min_octets_per_codec_frame=40,
max_octets_per_codec_frame=40,
supported_max_codec_frames_per_sdu=1,
),
),
# Codec Capability Setting 24_2
PacRecord(
coding_format=CodingFormat(CodecID.LC3),
codec_specific_capabilities=CodecSpecificCapabilities(
supported_sampling_frequencies=(
SupportedSamplingFrequency.FREQ_24000
),
supported_frame_durations=(
SupportedFrameDuration.DURATION_10000_US_SUPPORTED
),
supported_audio_channel_counts=[1],
min_octets_per_codec_frame=60,
max_octets_per_codec_frame=60,
supported_max_codec_frames_per_sdu=1,
),
),
],
)
)
advertising_data = bytes(
AdvertisingData(
[
(
AdvertisingData.COMPLETE_LOCAL_NAME,
bytes('Bumble LE Audio', 'utf-8'),
),
(
AdvertisingData.INCOMPLETE_LIST_OF_16_BIT_SERVICE_CLASS_UUIDS,
bytes(PublishedAudioCapabilitiesService.UUID),
),
]
)
)
await device.start_extended_advertising(
advertising_properties=(
HCI_LE_Set_Extended_Advertising_Parameters_Command.AdvertisingProperties.CONNECTABLE_ADVERTISING
),
own_address_type=OwnAddressType.RANDOM,
advertising_data=advertising_data,
)
await hci_transport.source.terminated
# -----------------------------------------------------------------------------
logging.basicConfig(level=os.environ.get('BUMBLE_LOGLEVEL', 'DEBUG').upper())
asyncio.run(main())

15
extras/android/BtBench/.gitignore vendored Normal file
View File

@@ -0,0 +1,15 @@
*.iml
.gradle
/local.properties
/.idea/caches
/.idea/libraries
/.idea/modules.xml
/.idea/workspace.xml
/.idea/navEditor.xml
/.idea/assetWizardSettings.xml
.DS_Store
/build
/captures
.externalNativeBuild
.cxx
local.properties

1
extras/android/BtBench/app/.gitignore vendored Normal file
View File

@@ -0,0 +1 @@
/build

View File

@@ -0,0 +1,70 @@
@Suppress("DSL_SCOPE_VIOLATION") // TODO: Remove once KTIJ-19369 is fixed
plugins {
alias(libs.plugins.androidApplication)
alias(libs.plugins.kotlinAndroid)
}
android {
namespace = "com.github.google.bumble.btbench"
compileSdk = 34
defaultConfig {
applicationId = "com.github.google.bumble.btbench"
minSdk = 30
targetSdk = 34
versionCode = 1
versionName = "1.0"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
vectorDrawables {
useSupportLibrary = true
}
}
buildTypes {
release {
isMinifyEnabled = false
proguardFiles(
getDefaultProguardFile("proguard-android-optimize.txt"),
"proguard-rules.pro"
)
}
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_1_8
targetCompatibility = JavaVersion.VERSION_1_8
}
kotlinOptions {
jvmTarget = "1.8"
}
buildFeatures {
compose = true
}
composeOptions {
kotlinCompilerExtensionVersion = "1.5.1"
}
packaging {
resources {
excludes += "/META-INF/{AL2.0,LGPL2.1}"
}
}
}
dependencies {
implementation(libs.core.ktx)
implementation(libs.lifecycle.runtime.ktx)
implementation(libs.activity.compose)
implementation(platform(libs.compose.bom))
implementation(libs.ui)
implementation(libs.ui.graphics)
implementation(libs.ui.tooling.preview)
implementation(libs.material3)
testImplementation(libs.junit)
androidTestImplementation(libs.androidx.test.ext.junit)
androidTestImplementation(libs.espresso.core)
androidTestImplementation(platform(libs.compose.bom))
androidTestImplementation(libs.ui.test.junit4)
debugImplementation(libs.ui.tooling)
debugImplementation(libs.ui.test.manifest)
}

View File

@@ -0,0 +1,21 @@
# Add project specific ProGuard rules here.
# You can control the set of applied configuration files using the
# proguardFiles setting in build.gradle.
#
# For more details, see
# http://developer.android.com/guide/developing/tools/proguard.html
# If your project uses WebView with JS, uncomment the following
# and specify the fully qualified class name to the JavaScript interface
# class:
#-keepclassmembers class fqcn.of.javascript.interface.for.webview {
# public *;
#}
# Uncomment this to preserve the line number information for
# debugging stack traces.
#-keepattributes SourceFile,LineNumberTable
# If you keep the line number information, uncomment this to
# hide the original source file name.
#-renamesourcefileattribute SourceFile

View File

@@ -0,0 +1,40 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">
<!-- Request legacy Bluetooth permissions on older devices. -->
<uses-permission android:name="android.permission.BLUETOOTH" android:maxSdkVersion="30" />
<uses-permission android:name="android.permission.BLUETOOTH_ADMIN" android:maxSdkVersion="30" />
<uses-permission android:name="android.permission.BLUETOOTH_SCAN" android:usesPermissionFlags="neverForLocation"/>
<uses-permission android:name="android.permission.BLUETOOTH_ADVERTISE" />
<uses-permission android:name="android.permission.BLUETOOTH_CONNECT" />
<uses-feature android:name="android.hardware.bluetooth" android:required="true"/>
<uses-feature android:name="android.hardware.bluetooth_le" android:required="true"/>
<application
android:allowBackup="true"
android:dataExtractionRules="@xml/data_extraction_rules"
android:fullBackupContent="@xml/backup_rules"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true"
android:theme="@style/Theme.BTBench"
tools:targetApi="31">
<activity
android:name=".MainActivity"
android:exported="true"
android:label="@string/app_name"
android:theme="@style/Theme.BTBench">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
<!-- <profileable android:shell="true"/>-->
</application>
</manifest>

Binary file not shown.

After

Width:  |  Height:  |  Size: 42 KiB

View File

@@ -0,0 +1,95 @@
// Copyright 2023 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.
package com.github.google.bumble.btbench
import android.annotation.SuppressLint
import android.bluetooth.BluetoothAdapter
import android.bluetooth.BluetoothDevice
import android.bluetooth.BluetoothGatt
import android.bluetooth.BluetoothGattCallback
import android.bluetooth.BluetoothProfile
import android.content.Context
import android.os.Build
import java.util.logging.Logger
private val Log = Logger.getLogger("btbench.l2cap-client")
class L2capClient(
private val viewModel: AppViewModel,
val bluetoothAdapter: BluetoothAdapter,
val context: Context
) {
@SuppressLint("MissingPermission")
fun run() {
viewModel.running = true
val addressIsPublic = viewModel.peerBluetoothAddress.endsWith("/P")
val address = viewModel.peerBluetoothAddress.take(17)
val remoteDevice = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
bluetoothAdapter.getRemoteLeDevice(
address,
if (addressIsPublic) {
BluetoothDevice.ADDRESS_TYPE_PUBLIC
} else {
BluetoothDevice.ADDRESS_TYPE_RANDOM
}
)
} else {
bluetoothAdapter.getRemoteDevice(address)
}
val gatt = remoteDevice.connectGatt(
context,
false,
object : BluetoothGattCallback() {
override fun onMtuChanged(gatt: BluetoothGatt, mtu: Int, status: Int) {
Log.info("MTU update: mtu=$mtu status=$status")
viewModel.mtu = mtu
}
override fun onPhyUpdate(gatt: BluetoothGatt, txPhy: Int, rxPhy: Int, status: Int) {
Log.info("PHY update: tx=$txPhy, rx=$rxPhy, status=$status")
viewModel.txPhy = txPhy
viewModel.rxPhy = rxPhy
}
override fun onPhyRead(gatt: BluetoothGatt, txPhy: Int, rxPhy: Int, status: Int) {
Log.info("PHY: tx=$txPhy, rx=$rxPhy, status=$status")
viewModel.txPhy = txPhy
viewModel.rxPhy = rxPhy
}
override fun onConnectionStateChange(
gatt: BluetoothGatt?, status: Int, newState: Int
) {
if (gatt != null && newState == BluetoothProfile.STATE_CONNECTED) {
gatt.setPreferredPhy(
BluetoothDevice.PHY_LE_2M_MASK,
BluetoothDevice.PHY_LE_2M_MASK,
BluetoothDevice.PHY_OPTION_NO_PREFERRED
)
gatt.readPhy()
}
}
},
BluetoothDevice.TRANSPORT_LE,
if (viewModel.use2mPhy) BluetoothDevice.PHY_LE_2M_MASK else BluetoothDevice.PHY_LE_1M_MASK
)
val socket = remoteDevice.createInsecureL2capChannel(viewModel.l2capPsm)
val client = SocketClient(viewModel, socket)
client.run()
}
}

View File

@@ -0,0 +1,61 @@
// Copyright 2023 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.
package com.github.google.bumble.btbench
import android.annotation.SuppressLint
import android.bluetooth.BluetoothAdapter
import android.bluetooth.le.AdvertiseCallback
import android.bluetooth.le.AdvertiseData
import android.bluetooth.le.AdvertiseSettings
import android.bluetooth.le.AdvertiseSettings.ADVERTISE_MODE_LOW_LATENCY
import android.os.Build
import java.io.IOException
import java.util.logging.Logger
import kotlin.concurrent.thread
private val Log = Logger.getLogger("btbench.l2cap-server")
class L2capServer(private val viewModel: AppViewModel, private val bluetoothAdapter: BluetoothAdapter) {
@SuppressLint("MissingPermission")
fun run() {
// Advertise so that the peer can find us and connect.
val callback = object: AdvertiseCallback() {
override fun onStartFailure(errorCode: Int) {
Log.warning("failed to start advertising: $errorCode")
}
override fun onStartSuccess(settingsInEffect: AdvertiseSettings) {
Log.info("advertising started: $settingsInEffect")
}
}
val advertiseSettingsBuilder = AdvertiseSettings.Builder()
.setAdvertiseMode(ADVERTISE_MODE_LOW_LATENCY)
.setConnectable(true)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
advertiseSettingsBuilder.setDiscoverable(true)
}
val advertiseSettings = advertiseSettingsBuilder.build()
val advertiseData = AdvertiseData.Builder().build()
val scanData = AdvertiseData.Builder().setIncludeDeviceName(true).build()
val advertiser = bluetoothAdapter.bluetoothLeAdvertiser
val serverSocket = bluetoothAdapter.listenUsingInsecureL2capChannel()
viewModel.l2capPsm = serverSocket.psm
Log.info("psm = $serverSocket.psm")
val server = SocketServer(viewModel, serverSocket)
server.run({ advertiser.stopAdvertising(callback) }, { advertiser.startAdvertising(advertiseSettings, advertiseData, scanData, callback) })
}
}

View File

@@ -0,0 +1,347 @@
// Copyright 2023 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.
package com.github.google.bumble.btbench
import android.Manifest
import android.annotation.SuppressLint
import android.bluetooth.BluetoothAdapter
import android.bluetooth.BluetoothManager
import android.content.Context
import android.content.Intent
import android.content.pm.PackageManager
import android.os.Build
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.Button
import androidx.compose.material3.Divider
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Slider
import androidx.compose.material3.Surface
import androidx.compose.material3.Switch
import androidx.compose.material3.Text
import androidx.compose.material3.TextField
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.ExperimentalComposeUiApi
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.platform.LocalFocusManager
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.core.content.ContextCompat
import com.github.google.bumble.btbench.ui.theme.BTBenchTheme
import java.util.logging.Logger
private val Log = Logger.getLogger("bumble.main-activity")
const val PEER_BLUETOOTH_ADDRESS_PREF_KEY = "peer_bluetooth_address"
const val SENDER_PACKET_COUNT_PREF_KEY = "sender_packet_count"
const val SENDER_PACKET_SIZE_PREF_KEY = "sender_packet_size"
class MainActivity : ComponentActivity() {
private val appViewModel = AppViewModel()
private var bluetoothAdapter: BluetoothAdapter? = null
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
appViewModel.loadPreferences(getPreferences(Context.MODE_PRIVATE))
checkPermissions()
}
private fun checkPermissions() {
val neededPermissions = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
arrayOf(
Manifest.permission.BLUETOOTH_ADVERTISE,
Manifest.permission.BLUETOOTH_SCAN,
Manifest.permission.BLUETOOTH_CONNECT
)
} else {
arrayOf(Manifest.permission.BLUETOOTH, Manifest.permission.BLUETOOTH_ADMIN)
}
val missingPermissions = neededPermissions.filter {
ContextCompat.checkSelfPermission(baseContext, it) != PackageManager.PERMISSION_GRANTED
}
if (missingPermissions.isEmpty()) {
start()
return
}
val requestPermissionsLauncher = registerForActivityResult(
ActivityResultContracts.RequestMultiplePermissions()
) { permissions ->
permissions.entries.forEach {
Log.info("permission: ${it.key} = ${it.value}")
}
val grantCount = permissions.count { it.value }
if (grantCount == neededPermissions.size) {
// We have all the permissions we need.
start()
} else {
Log.warning("not all permissions granted")
}
}
requestPermissionsLauncher.launch(missingPermissions.toTypedArray())
return
}
@SuppressLint("MissingPermission")
private fun initBluetooth() {
val bluetoothManager = ContextCompat.getSystemService(this, BluetoothManager::class.java)
bluetoothAdapter = bluetoothManager?.adapter
if (bluetoothAdapter == null) {
Log.warning("no bluetooth adapter")
return
}
if (!bluetoothAdapter!!.isEnabled) {
Log.warning("bluetooth not enabled")
return
}
}
private fun start() {
initBluetooth()
setContent {
MainView(
appViewModel,
::becomeDiscoverable,
::runRfcommClient,
::runRfcommServer,
::runL2capClient,
::runL2capServer
)
}
// Process intent parameters, if any.
intent.getStringExtra("peer-bluetooth-address")?.let {
appViewModel.peerBluetoothAddress = it
}
val packetCount = intent.getIntExtra("packet-count", 0)
if (packetCount > 0) {
appViewModel.senderPacketCount = packetCount
}
appViewModel.updateSenderPacketCountSlider()
val packetSize = intent.getIntExtra("packet-size", 0)
if (packetSize > 0) {
appViewModel.senderPacketSize = packetSize
}
appViewModel.updateSenderPacketSizeSlider()
intent.getStringExtra("autostart")?.let {
when (it) {
"rfcomm-client" -> runRfcommClient()
"rfcomm-server" -> runRfcommServer()
"l2cap-client" -> runL2capClient()
"l2cap-server" -> runL2capServer()
}
}
}
private fun runRfcommClient() {
val rfcommClient = bluetoothAdapter?.let { RfcommClient(appViewModel, it) }
rfcommClient?.run()
}
private fun runRfcommServer() {
val rfcommServer = bluetoothAdapter?.let { RfcommServer(appViewModel, it) }
rfcommServer?.run()
}
private fun runL2capClient() {
val l2capClient = bluetoothAdapter?.let { L2capClient(appViewModel, it, baseContext) }
l2capClient?.run()
}
private fun runL2capServer() {
val l2capServer = bluetoothAdapter?.let { L2capServer(appViewModel, it) }
l2capServer?.run()
}
@SuppressLint("MissingPermission")
fun becomeDiscoverable() {
val discoverableIntent = Intent(BluetoothAdapter.ACTION_REQUEST_DISCOVERABLE)
discoverableIntent.putExtra(BluetoothAdapter.EXTRA_DISCOVERABLE_DURATION, 300)
startActivity(discoverableIntent)
}
}
@OptIn(ExperimentalComposeUiApi::class)
@Composable
fun MainView(
appViewModel: AppViewModel,
becomeDiscoverable: () -> Unit,
runRfcommClient: () -> Unit,
runRfcommServer: () -> Unit,
runL2capClient: () -> Unit,
runL2capServer: () -> Unit
) {
BTBenchTheme {
val scrollState = rememberScrollState()
Surface(
modifier = Modifier
.fillMaxSize()
.verticalScroll(scrollState),
color = MaterialTheme.colorScheme.background
) {
Column(modifier = Modifier.padding(horizontal = 16.dp)) {
Text(
text = "Bumble Bench",
fontSize = 24.sp,
fontWeight = FontWeight.Bold,
textAlign = TextAlign.Center
)
Divider()
val keyboardController = LocalSoftwareKeyboardController.current
val focusRequester = remember { FocusRequester() }
val focusManager = LocalFocusManager.current
TextField(
label = {
Text(text = "Peer Bluetooth Address")
},
value = appViewModel.peerBluetoothAddress,
modifier = Modifier.fillMaxWidth().focusRequester(focusRequester),
keyboardOptions = KeyboardOptions.Default.copy(
keyboardType = KeyboardType.Ascii, imeAction = ImeAction.Done
),
onValueChange = {
appViewModel.updatePeerBluetoothAddress(it)
},
keyboardActions = KeyboardActions(onDone = {
keyboardController?.hide()
focusManager.clearFocus()
})
)
Divider()
TextField(label = {
Text(text = "L2CAP PSM")
},
value = appViewModel.l2capPsm.toString(),
modifier = Modifier.fillMaxWidth().focusRequester(focusRequester),
keyboardOptions = KeyboardOptions.Default.copy(
keyboardType = KeyboardType.Number, imeAction = ImeAction.Done
),
onValueChange = {
if (it.isNotEmpty()) {
val psm = it.toIntOrNull()
if (psm != null) {
appViewModel.l2capPsm = psm
}
}
},
keyboardActions = KeyboardActions(onDone = {
keyboardController?.hide()
focusManager.clearFocus()
})
)
Divider()
Slider(
value = appViewModel.senderPacketCountSlider, onValueChange = {
appViewModel.senderPacketCountSlider = it
appViewModel.updateSenderPacketCount()
}, steps = 4
)
Text(text = "Packet Count: " + appViewModel.senderPacketCount.toString())
Divider()
Slider(
value = appViewModel.senderPacketSizeSlider, onValueChange = {
appViewModel.senderPacketSizeSlider = it
appViewModel.updateSenderPacketSize()
}, steps = 4
)
Text(text = "Packet Size: " + appViewModel.senderPacketSize.toString())
Divider()
ActionButton(
text = "Become Discoverable", onClick = becomeDiscoverable, true
)
Row(
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Text(text = "2M PHY")
Spacer(modifier = Modifier.padding(start = 8.dp))
Switch(
checked = appViewModel.use2mPhy,
onCheckedChange = { appViewModel.use2mPhy = it }
)
}
Row {
ActionButton(
text = "RFCOMM Client", onClick = runRfcommClient, !appViewModel.running
)
ActionButton(
text = "RFCOMM Server", onClick = runRfcommServer, !appViewModel.running
)
}
Row {
ActionButton(
text = "L2CAP Client", onClick = runL2capClient, !appViewModel.running
)
ActionButton(
text = "L2CAP Server", onClick = runL2capServer, !appViewModel.running
)
}
Divider()
Text(
text = if (appViewModel.mtu != 0) "MTU: ${appViewModel.mtu}" else ""
)
Text(
text = if (appViewModel.rxPhy != 0 || appViewModel.txPhy != 0) "PHY: tx=${appViewModel.txPhy}, rx=${appViewModel.rxPhy}" else ""
)
Text(
text = "Packets Sent: ${appViewModel.packetsSent}"
)
Text(
text = "Packets Received: ${appViewModel.packetsReceived}"
)
Text(
text = "Throughput: ${appViewModel.throughput}"
)
Divider()
ActionButton(
text = "Abort", onClick = appViewModel::abort, appViewModel.running
)
}
}
}
}
@Composable
fun ActionButton(text: String, onClick: () -> Unit, enabled: Boolean) {
Button(onClick = onClick, enabled = enabled) {
Text(text = text)
}
}

View File

@@ -0,0 +1,168 @@
// Copyright 2023 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.
package com.github.google.bumble.btbench
import android.content.SharedPreferences
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.lifecycle.ViewModel
import java.util.UUID
val DEFAULT_RFCOMM_UUID = UUID.fromString("E6D55659-C8B4-4B85-96BB-B1143AF6D3AE")
const val DEFAULT_PEER_BLUETOOTH_ADDRESS = "AA:BB:CC:DD:EE:FF"
const val DEFAULT_SENDER_PACKET_COUNT = 100
const val DEFAULT_SENDER_PACKET_SIZE = 1024
class AppViewModel : ViewModel() {
private var preferences: SharedPreferences? = null
var peerBluetoothAddress by mutableStateOf(DEFAULT_PEER_BLUETOOTH_ADDRESS)
var l2capPsm by mutableStateOf(0)
var use2mPhy by mutableStateOf(true)
var mtu by mutableStateOf(0)
var rxPhy by mutableStateOf(0)
var txPhy by mutableStateOf(0)
var senderPacketCountSlider by mutableFloatStateOf(0.0F)
var senderPacketSizeSlider by mutableFloatStateOf(0.0F)
var senderPacketCount by mutableIntStateOf(DEFAULT_SENDER_PACKET_COUNT)
var senderPacketSize by mutableIntStateOf(DEFAULT_SENDER_PACKET_SIZE)
var packetsSent by mutableIntStateOf(0)
var packetsReceived by mutableIntStateOf(0)
var throughput by mutableIntStateOf(0)
var running by mutableStateOf(false)
var aborter: (() -> Unit)? = null
fun loadPreferences(preferences: SharedPreferences) {
this.preferences = preferences
val savedPeerBluetoothAddress = preferences.getString(PEER_BLUETOOTH_ADDRESS_PREF_KEY, null)
if (savedPeerBluetoothAddress != null) {
peerBluetoothAddress = savedPeerBluetoothAddress
}
val savedSenderPacketCount = preferences.getInt(SENDER_PACKET_COUNT_PREF_KEY, 0)
if (savedSenderPacketCount != 0) {
senderPacketCount = savedSenderPacketCount
}
updateSenderPacketCountSlider()
val savedSenderPacketSize = preferences.getInt(SENDER_PACKET_SIZE_PREF_KEY, 0)
if (savedSenderPacketSize != 0) {
senderPacketSize = savedSenderPacketSize
}
updateSenderPacketSizeSlider()
}
fun updatePeerBluetoothAddress(peerBluetoothAddress: String) {
val address = peerBluetoothAddress.uppercase()
this.peerBluetoothAddress = address
// Save the address to the preferences
with(preferences!!.edit()) {
putString(PEER_BLUETOOTH_ADDRESS_PREF_KEY, address)
apply()
}
}
fun updateSenderPacketCountSlider() {
if (senderPacketCount <= 10) {
senderPacketCountSlider = 0.0F
} else if (senderPacketCount <= 50) {
senderPacketCountSlider = 0.2F
} else if (senderPacketCount <= 100) {
senderPacketCountSlider = 0.4F
} else if (senderPacketCount <= 500) {
senderPacketCountSlider = 0.6F
} else if (senderPacketCount <= 1000) {
senderPacketCountSlider = 0.8F
} else {
senderPacketCountSlider = 1.0F
}
with(preferences!!.edit()) {
putInt(SENDER_PACKET_COUNT_PREF_KEY, senderPacketCount)
apply()
}
}
fun updateSenderPacketCount() {
if (senderPacketCountSlider < 0.1F) {
senderPacketCount = 10
} else if (senderPacketCountSlider < 0.3F) {
senderPacketCount = 50
} else if (senderPacketCountSlider < 0.5F) {
senderPacketCount = 100
} else if (senderPacketCountSlider < 0.7F) {
senderPacketCount = 500
} else if (senderPacketCountSlider < 0.9F) {
senderPacketCount = 1000
} else {
senderPacketCount = 10000
}
with(preferences!!.edit()) {
putInt(SENDER_PACKET_COUNT_PREF_KEY, senderPacketCount)
apply()
}
}
fun updateSenderPacketSizeSlider() {
if (senderPacketSize <= 16) {
senderPacketSizeSlider = 0.0F
} else if (senderPacketSize <= 256) {
senderPacketSizeSlider = 0.02F
} else if (senderPacketSize <= 512) {
senderPacketSizeSlider = 0.4F
} else if (senderPacketSize <= 1024) {
senderPacketSizeSlider = 0.6F
} else if (senderPacketSize <= 2048) {
senderPacketSizeSlider = 0.8F
} else {
senderPacketSizeSlider = 1.0F
}
with(preferences!!.edit()) {
putInt(SENDER_PACKET_SIZE_PREF_KEY, senderPacketSize)
apply()
}
}
fun updateSenderPacketSize() {
if (senderPacketSizeSlider < 0.1F) {
senderPacketSize = 16
} else if (senderPacketSizeSlider < 0.3F) {
senderPacketSize = 256
} else if (senderPacketSizeSlider < 0.5F) {
senderPacketSize = 512
} else if (senderPacketSizeSlider < 0.7F) {
senderPacketSize = 1024
} else if (senderPacketSizeSlider < 0.9F) {
senderPacketSize = 2048
} else {
senderPacketSize = 4096
}
with(preferences!!.edit()) {
putInt(SENDER_PACKET_SIZE_PREF_KEY, senderPacketSize)
apply()
}
}
fun abort() {
aborter?.let { it() }
}
}

View File

@@ -0,0 +1,178 @@
// Copyright 2023 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.
package com.github.google.bumble.btbench
import android.bluetooth.BluetoothSocket
import java.io.IOException
import java.nio.ByteBuffer
import java.util.logging.Logger
import kotlin.math.min
private val Log = Logger.getLogger("btbench.packet")
fun ByteArray.toHex(): String = joinToString(separator = "") { eachByte -> "%02x".format(eachByte) }
abstract class Packet(val type: Int, val payload: ByteArray = ByteArray(0)) {
companion object {
const val RESET = 0
const val SEQUENCE = 1
const val ACK = 2
const val LAST_FLAG = 1
fun from(data: ByteArray): Packet {
return when (data[0].toInt()) {
RESET -> ResetPacket()
SEQUENCE -> SequencePacket(
data[1].toInt(),
ByteBuffer.wrap(data, 2, 4).getInt(),
data.sliceArray(6..<data.size)
)
ACK -> AckPacket(data[1].toInt(), ByteBuffer.wrap(data, 2, 4).getInt())
else -> GenericPacket(data[0].toInt(), data.sliceArray(1..<data.size))
}
}
}
open fun toBytes(): ByteArray {
return ByteBuffer.allocate(1 + payload.size).put(type.toByte()).put(payload).array()
}
}
class GenericPacket(type: Int, payload: ByteArray) : Packet(type, payload)
class ResetPacket : Packet(RESET)
class AckPacket(val flags: Int, val sequenceNumber: Int) : Packet(ACK) {
override fun toBytes(): ByteArray {
return ByteBuffer.allocate(1 + 1 + 4).put(type.toByte()).put(flags.toByte())
.putInt(sequenceNumber).array()
}
}
class SequencePacket(val flags: Int, val sequenceNumber: Int, payload: ByteArray) :
Packet(SEQUENCE, payload) {
override fun toBytes(): ByteArray {
return ByteBuffer.allocate(1 + 1 + 4 + payload.size).put(type.toByte()).put(flags.toByte())
.putInt(sequenceNumber).put(payload).array()
}
}
abstract class PacketSink {
fun onPacket(packet: Packet) {
when (packet) {
is ResetPacket -> onResetPacket()
is AckPacket -> onAckPacket()
is SequencePacket -> onSequencePacket(packet)
}
}
abstract fun onResetPacket()
abstract fun onAckPacket()
abstract fun onSequencePacket(packet: SequencePacket)
}
interface DataSink {
fun onData(data: ByteArray)
}
interface PacketIO {
var packetSink: PacketSink?
fun sendPacket(packet: Packet)
}
class StreamedPacketIO(private val dataSink: DataSink) : PacketIO {
private var bytesNeeded: Int = 0
private var rxPacket: ByteBuffer? = null
private var rxHeader = ByteBuffer.allocate(2)
override var packetSink: PacketSink? = null
fun onData(data: ByteArray) {
var current = data
while (current.isNotEmpty()) {
if (bytesNeeded > 0) {
val chunk = current.sliceArray(0..<min(bytesNeeded, current.size))
rxPacket!!.put(chunk)
current = current.sliceArray(chunk.size..<current.size)
bytesNeeded -= chunk.size
if (bytesNeeded == 0) {
// Packet completed.
//Log.fine("packet complete: ${current.toHex()}")
packetSink?.onPacket(Packet.from(rxPacket!!.array()))
// Reset.
reset()
}
} else {
val headerBytesNeeded = 2 - rxHeader.position()
val headerBytes = current.sliceArray(0..<min(headerBytesNeeded, current.size))
current = current.sliceArray(headerBytes.size..<current.size)
rxHeader.put(headerBytes)
if (rxHeader.position() != 2) {
return
}
bytesNeeded = rxHeader.getShort(0).toInt()
if (bytesNeeded == 0) {
Log.warning("found 0 size packet!")
reset()
return
}
rxPacket = ByteBuffer.allocate(bytesNeeded)
}
}
}
private fun reset() {
rxPacket = null
rxHeader.position(0)
}
override fun sendPacket(packet: Packet) {
val packetBytes = packet.toBytes()
val packetData =
ByteBuffer.allocate(2 + packetBytes.size).putShort(packetBytes.size.toShort())
.put(packetBytes).array()
dataSink.onData(packetData)
}
}
class SocketDataSink(private val socket: BluetoothSocket) : DataSink {
override fun onData(data: ByteArray) {
socket.outputStream.write(data)
}
}
class SocketDataSource(
private val socket: BluetoothSocket,
private val onData: (data: ByteArray) -> Unit
) {
fun receive() {
val buffer = ByteArray(4096)
do {
try {
val bytesRead = socket.inputStream.read(buffer)
if (bytesRead <= 0) {
break
}
onData(buffer.sliceArray(0..<bytesRead))
} catch (error: IOException) {
Log.warning("IO Exception: $error")
break
}
} while (true)
Log.info("end of stream")
}
}

Some files were not shown because too many files have changed in this diff Show More