Compare commits

...

150 Commits

Author SHA1 Message Date
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
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
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
Gilles Boccon-Gibod
2478d45673 more windows compat fixes 2023-09-12 14:52:42 -07:00
Gilles Boccon-Gibod
1bc7d94111 windows NamedTemporaryFile compatibility 2023-09-12 14:33:12 -07:00
Gilles Boccon-Gibod
6432414cd5 run tests on windows and mac in addition to linux 2023-09-12 13:50:15 -07:00
Gilles Boccon-Gibod
179064ba15 run pre-commit tests with all supported Python versions 2023-09-12 13:42:33 -07:00
William Escande
783b2d70a5 Add connection parameter update from peripheral 2023-09-12 11:08:04 -07:00
zxzxwu
80824f3fc1 Merge pull request #280 from zxzxwu/device_typing
Add terminated to TransportSource protocol
2023-09-12 20:46:35 +08:00
Josh Wu
f39f5f531c Replace | typing usage with Optional and Union 2023-09-12 15:50:51 +08:00
Gilles Boccon-Gibod
56139c622f Merge pull request #258 from mogenson/vsc_tx_power
Add support for Zephyr HCI VSC set TX power command
2023-09-11 21:34:11 -07:00
Michael Mogenson
da02f6a39b Add HCI Zephyr vendor commands to read and write TX power
Create platforms/zephyr/hci.py with definitions of vendor HCI commands
to read and write TX power.

Add documentation for how to prepare an nRF52840 dongle with a Zephyr
HCI USB firmware application that includes dynamic TX power support and
how to send a write TX power vendor HCI command from Bumble.
2023-09-11 10:06:10 -04:00
Josh Wu
548d5597c0 Transport: Add termination protocol signature 2023-09-11 14:36:40 +08:00
zxzxwu
7fd65d2412 Merge pull request #279 from zxzxwu/typo
Fix typo
2023-09-11 03:02:11 +08:00
Josh Wu
05a54a4af9 Fix typo 2023-09-10 20:32:58 +08:00
Gilles Boccon-Gibod
1e00c8f456 Merge pull request #276 from google/gbg/add-zephyr-zip-to-docs
add zephyr binary to docs
2023-09-08 18:07:15 -07:00
Gilles Boccon-Gibod
90d165aa01 add zephyr binary 2023-09-08 14:17:15 -07:00
zxzxwu
01603ca9e4 Merge pull request #271 from zxzxwu/device_typing
Typing transport and relateds
2023-09-09 00:55:59 +08:00
Gilles Boccon-Gibod
a1b6eb61f2 Merge pull request #269 from google/gbg/android_vendor_hci
add support for vendor HCI commands and events
2023-09-08 08:50:49 -07:00
zxzxwu
25f300d3ec Merge pull request #270 from zxzxwu/typo
Fix typos
2023-09-08 17:32:33 +08:00
Josh Wu
41fe63df06 Fix typos 2023-09-08 16:30:06 +08:00
Josh Wu
b312170d5f Typing transport 2023-09-08 15:27:01 +08:00
David Duarte
cf7f2e8f44 Make platformdirs import lazy
platformdirs is not available in Android
2023-09-07 21:13:29 -07:00
Gilles Boccon-Gibod
d292083ed1 Merge pull request #272 from zxzxwu/gfp
Bring HfpProtocol back
2023-09-07 13:03:36 -07:00
Gilles Boccon-Gibod
9b11142b45 Merge pull request #267 from google/gbg/rfcomm-with-uuid
rfcomm with UUID
2023-09-07 13:01:56 -07:00
Hui Peng
acdbc4d7b9 Raise an exception when an L2cap connection fails 2023-09-07 19:24:38 +02:00
Josh Wu
838d10a09d Add HFP tests 2023-09-07 23:20:16 +08:00
Josh Wu
3852aa056b Bring HfpProtocol back 2023-09-07 23:20:09 +08:00
Gilles Boccon-Gibod
ae77e4528f add support for vendor HCI commands and events 2023-09-06 20:00:15 -07:00
Gilles Boccon-Gibod
9303f4fc5b Merge pull request #262 from whitevegagabriel/l2cap
Port l2cap_bridge sample to Rust
2023-09-06 17:13:12 -07:00
Gilles Boccon-Gibod
8be9f4cb0e add doc and fix types 2023-09-06 17:05:30 -07:00
Gilles Boccon-Gibod
1ea12b1bf7 rebase 2023-09-06 17:05:24 -07:00
Gilles Boccon-Gibod
65e6d68355 add tcp server 2023-09-06 16:49:21 -07:00
Gabriel White-Vega
9732eb8836 Address PR feedback 2023-09-06 09:47:08 -04:00
Gabriel White-Vega
5ae668bc70 Port l2cap_bridge sample to Rust
- Added Rust wrappers where relevant
- Edited a couple logs in python l2cap_bridge to be more symmetrical
- Created cli subcommand for running the rustified l2cap bridge
2023-09-05 16:03:02 -04:00
Gilles Boccon-Gibod
fd4d1bcca3 Merge pull request #261 from marshallpierce/mp/rust-realtek-tools
Rust tools for working with Realtek firmware
2023-09-05 10:55:29 -07:00
Gilles Boccon-Gibod
0a251c9f8e Merge pull request #265 from mogenson/grpcio-update
Update grpcio and pip package versions
2023-08-31 14:53:54 -07:00
Michael Mogenson
351d77be59 Update grpcio and pip package versions
The current grpcio version 1.51.1 fails to build on aarch64 based MacOS
computers. Update the version of the grpcio and grpcio-tools packages to
the latest 1.57.0 version. There are binary wheels available for this
version from PyPi for aarch64 MacOS.

Also update the pip version for the Conda environment. It seems a newer
version of pip is required to detect and install these wheels.

Testing:

invoke test passes and I can start the bumble-pandora-server
successfully.
2023-08-31 14:01:14 -04:00
Marshall Pierce
0e2fc80509 Rust tools for working with Realtek firmware
Further adventures in porting tools to Rust to flesh out the supported
API.

These tools didn't feel like `example`s, so I made a top level `bumble`
CLI tool that hosts them all as subcommands. I also moved the usb probe
not-really-an-`example` into it as well. I'm open to suggestions on how
best to organize the subcommands to make them intuitive to explore with
`--help`, and how to leave room for other future tools.

I also adopted the per-OS project data dir for a default firmware
location so that users can download once and then use those .bin files
from anywhere without having to sprinkle .bin files in project
directories or reaching inside the python package dir hierarchy.
2023-08-30 15:37:35 -06:00
Gilles Boccon-Gibod
8f3fdecb93 Merge pull request #263 from zxzxwu/pdu
Typing packet transmission flow
2023-08-30 11:15:12 -07:00
Josh Wu
249a205d8e Typing packet transmission flow 2023-08-30 01:47:46 +08:00
Gilles Boccon-Gibod
7485801222 Merge pull request #256 from zxzxwu/sdp-type-fix
Typing SDP and add tests
2023-08-28 08:41:02 -07:00
Gilles Boccon-Gibod
4678e59737 Merge pull request #250 from google/gbg/new-rtk-dongles
add entry to the list of supported USB devices
2023-08-28 08:40:40 -07:00
Gilles Boccon-Gibod
952d351c00 Merge pull request #247 from google/gbg/wasm-with-ws
wasm with ws
2023-08-28 08:40:18 -07:00
Josh Wu
901eb55b0e Add SDP self tests 2023-08-24 01:27:07 +08:00
Josh Wu
727586e40e Typing SDP 2023-08-23 14:52:44 +08:00
Gilles Boccon-Gibod
3aa678a58e Merge pull request #253 from zxzxwu/rfcomm_type_fix
Adding more typing in rfcomm.py
2023-08-22 09:47:38 -07:00
Gilles Boccon-Gibod
fc7c1a8113 Merge pull request #255 from zxzxwu/player
Remove accidentally added files
2023-08-22 07:34:31 -07:00
Josh Wu
f62a0bbe75 Remove accidentally added files 2023-08-22 22:12:41 +08:00
Josh Wu
7341172739 Use __future__.annotations for typing 2023-08-22 14:44:15 +08:00
Gilles Boccon-Gibod
91b9fbe450 Merge pull request #240 from zxzxwu/ssp
Handle SSP Complete events
2023-08-21 18:01:28 -07:00
Josh Wu
e6b566b848 RFCOMM: Refactor role to enum 2023-08-21 15:16:34 +08:00
Josh Wu
2527a711dc Refactor RFCOMM states to enum 2023-08-21 15:12:52 +08:00
Josh Wu
5fba6b1cae Complete typing in RFCOMM 2023-08-21 15:12:52 +08:00
Gilles Boccon-Gibod
43e632f83c Merge pull request #244 from google/gbg/hci-source-termination-mode
add sink method for lost transports
2023-08-18 10:17:11 -07:00
Gilles Boccon-Gibod
623298b0e9 emit flush event when transport lost 2023-08-18 09:59:15 -07:00
Gilles Boccon-Gibod
85a61dc39d add entry to the list of supported USB devices 2023-08-18 09:56:06 -07:00
Gilles Boccon-Gibod
6e8c44b5e6 Merge pull request #249 from zxzxwu/player
Support SBC in speaker.app
2023-08-18 09:55:23 -07:00
Josh Wu
ec4dcc174e Support SBC in speaker.app 2023-08-18 17:13:11 +08:00
Charlie Boutier
b247aca3b4 pandora_server: add support to accept bumble config file 2023-08-17 14:24:56 -07:00
Gilles Boccon-Gibod
6226bfd196 fix typo after refactor 2023-08-17 09:51:56 -07:00
Gilles Boccon-Gibod
71e11b7cf8 format 2023-08-15 15:20:48 -07:00
Gilles Boccon-Gibod
800c62fdb6 add readme for web examples 2023-08-15 15:17:38 -07:00
Gilles Boccon-Gibod
640b9cd53a refactor pyiodide support and add examples 2023-08-15 13:36:58 -07:00
Gilles Boccon-Gibod
f4add16aea Merge pull request #241 from hchataing/hfp-hf
hfp: Implement initiate SLC procedure for HFP-HF
2023-08-14 10:32:55 -07:00
Gilles Boccon-Gibod
2bfec3c4ed add sink method for lost transports 2023-08-12 10:54:20 -07:00
Henri Chataing
9963b51c04 hfp: Implement initiate SLC procedure for HFP-HF 2023-08-10 08:37:54 -07:00
Josh Wu
2af3494d8c Handle SSP Complete events 2023-08-10 10:58:41 +08:00
Gilles Boccon-Gibod
fe28473ba8 Merge pull request #234 from zxzxwu/addr
Support address resolution offload
2023-08-08 21:30:13 -07:00
Gilles Boccon-Gibod
53d66bc74a Merge pull request #237 from marshallpierce/mp/company-ids
Faster company id table
2023-08-08 21:29:45 -07:00
Marshall Pierce
e2c1ad5342 Faster company id table
Following up on the [loose end from the initial
PR](https://github.com/google/bumble/pull/207#discussion_r1278015116),
we can avoid accessing the Python company id map at runtime by doing
code gen ahead of time.

Using an example to do the code gen avoids even the small build slowdown
from invoking the code gen logic in build.rs, but more importantly,
means that it's still a totally boring normal build that won't require
any IDE setup, etc, to work for everyone. Since the company ID list
changes rarely, and there's a test to ensure it always matches, this
seems like a good trade.
2023-08-04 10:12:52 -06:00
Josh Wu
6399c5fb04 Auto add device to resolving list after pairing 2023-08-03 20:51:00 +08:00
Josh Wu
784cf4f26a Add a flag to enable LE address resolution 2023-08-03 20:50:57 +08:00
Josh Wu
0301b1a999 Pandora: Configure identity address type 2023-08-02 11:31:07 -07:00
Lucas Abel
3ab2cd5e71 pandora: decrease all info logs to debug 2023-08-02 10:56:41 -07:00
uael
6ea669531a pandora: add tcp option to transport configuration
* Add a fallback to `tcp` when `transport` is not set.
* Default the `tcp` transport to the default rootcanal HCI address.
2023-08-01 08:51:12 -07:00
Josh Wu
cbbada4748 SMP: Delegate distributed address type 2023-08-01 08:38:03 -07:00
Gilles Boccon-Gibod
152b8d1233 Merge pull request #230 from google/gbg/hci-object-array
add support for field arrays in hci packet definitions
2023-08-01 07:44:31 -07:00
Gilles Boccon-Gibod
bdad225033 add support for field arrays in hci packet definitions 2023-07-30 22:19:10 -07:00
Gilles Boccon-Gibod
8eeb58e467 Merge pull request #207 from marshallpierce/mp/rust-poc
Proof-of-concept Rust wrapper
2023-07-28 20:14:23 -07:00
Marshall Pierce
91971433d2 PR feedback 2023-07-28 14:34:02 -06:00
Gilles Boccon-Gibod
a0a4bd457f Merge pull request #227 from google/gbg/py11
compatibility with python 11
2023-07-28 12:54:30 -07:00
Gilles Boccon-Gibod
4ffc050eed restore python < 11 compat 2023-07-27 16:37:27 -07:00
Gilles Boccon-Gibod
60678419a0 compatibility with python 11 2023-07-27 14:55:28 -07:00
Gilles Boccon-Gibod
648dcc9305 use type object instead of type strings 2023-07-27 13:19:37 -07:00
Josh Wu
190529184e L2CAP: Import device.Connection for typing 2023-07-27 09:07:55 -07:00
Josh Wu
46eb81466d Add more argement hints in L2CAP 2023-07-27 09:07:55 -07:00
Josh Wu
9c70c487b9 Add type hint to L2CAP module 2023-07-27 09:07:55 -07:00
Josh Wu
43234d7c3e Use with-patch to mock SMP session 2023-07-27 08:00:36 -07:00
Josh Wu
dbf878dc3f SMP: Remove PairingMethod.__str__ 2023-07-27 08:00:36 -07:00
Josh Wu
f6c0bd88d7 SMP: Do not send phase 2 commands in CTKD 2023-07-27 08:00:36 -07:00
Josh Wu
8440b7fbf1 SMP: Refactor pairing method as enum 2023-07-27 08:00:36 -07:00
Gilles Boccon-Gibod
808ab54135 Merge pull request #221 from google/gbg/core-classes
add new device class major/minor identifiers
2023-07-25 09:49:05 -07:00
Gilles Boccon-Gibod
52b29ad680 add new device class major/minor identifiers 2023-07-24 17:41:57 -07:00
Gilles Boccon-Gibod
d41bf9c587 Merge pull request #216 from google/gbg/host-buffer-size-command
accept Host Buffer Size Command in the controller
2023-07-24 09:05:10 -07:00
Gilles Boccon-Gibod
b758825164 add flow control command 2023-07-22 13:04:39 -07:00
Gilles Boccon-Gibod
779dfe5473 accept Host Buffer Size Command in the controller 2023-07-21 19:36:26 -07:00
Marshall Pierce
afb21220e2 Proof-of-concept Rust wrapper
This contains Rust wrappers around enough of the Python API to implement Rust versions of the `battery_client` and `run_scanner` examples. The goal is to gather feedback on the approach, and of course to show that it is possible.

The module structure mirrors that of the Python. The Rust API is not optimally Rust-y, but given the constraints of everything having to delegate to Python, it's at least usable.

Notably, this does not yet solve the packaging problem: users must have an appropriate virtualenv, libpython, etc. [PyOxidizer](https://github.com/indygreg/PyOxidizer) may be a viable path there.
2023-07-20 10:50:15 -06:00
Gilles Boccon-Gibod
f9a4c7518e Merge pull request #214 from marshallpierce/mp/scanner-rssi
Add a space after RSSI
2023-07-14 10:52:54 -07:00
Marshall Pierce
bad2fdf69f Add a space after RSSI
The other data elements have a space, so I'm guessing that RSSI
is intended to as well. Perhaps there's some subtle reason why
it should have a space, though, in which case feel free to
close this.

Output now looks like this:

```
>>> 58:D3:49:E7:40:DA/P [PUBLIC]:
  RSSI: -67
  [Flags]: LE General,BR/EDR C,BR/EDR H
  [TX Power Level]: 4
  [Manufacturer Specific Data]: company=Apple, Inc., data=0f08c00af4392b00040c10020f04
```
2023-07-13 12:47:45 -06:00
Lucas Abel
a84df469cd pairing: handle user errors from all delegate calls 2023-07-12 11:03:21 -07:00
Gilles Boccon-Gibod
03e33e39bd Merge pull request #211 from google/gbg/fix-ws-transport-doc
fix doc for ws-client ws-server transports
2023-07-12 07:06:32 -07:00
Gilles Boccon-Gibod
753fb69272 fix doc for ws-client ws-server transports 2023-07-12 06:06:20 -07:00
Gilles Boccon-Gibod
81a5f3a395 Merge pull request #203 from google/gbg/realtek-driver
realtek driver
2023-07-11 07:06:07 -07:00
Gilles Boccon-Gibod
696a8d82fd look for files in linux FW dir 2023-07-11 06:41:34 -07:00
Gilles Boccon-Gibod
5f294b1fea python 3.8 compatibility 2023-07-11 06:41:34 -07:00
Gilles Boccon-Gibod
2d8f5e80fb add missing doc files 2023-07-11 06:41:34 -07:00
Gilles Boccon-Gibod
7a042db78e add more USB ids 2023-07-11 06:41:34 -07:00
Gilles Boccon-Gibod
41ce311836 allow custom driver factories 2023-07-11 06:41:34 -07:00
Gilles Boccon-Gibod
03538d0f8a add doc 2023-07-11 06:41:34 -07:00
Gilles Boccon-Gibod
86bc222dc0 add missing file 2023-07-11 06:41:34 -07:00
Gilles Boccon-Gibod
e8d285fdab add downloader tool 2023-07-11 06:41:34 -07:00
Gilles Boccon-Gibod
852c933c92 wip (+4 squashed commits)
Squashed commits:
[d29a350] wip
[7f541ed] wip
[1e2902e] basic working version
[14b497a] wip
2023-07-11 06:41:34 -07:00
Lucas Abel
7867a99a54 Merge pull request #209 from google/click-types-quick-fix
temporarily pin click to 8.1.3
2023-07-11 06:21:11 -07:00
142 changed files with 16044 additions and 1942 deletions

View File

@@ -14,6 +14,10 @@ jobs:
check:
name: Check Code
runs-on: ubuntu-latest
strategy:
matrix:
python-version: ["3.8", "3.9", "3.10", "3.11"]
fail-fast: false
steps:
- name: Check out from Git

View File

@@ -12,11 +12,11 @@ permissions:
jobs:
build:
runs-on: ubuntu-latest
runs-on: ${{ matrix.os }}
strategy:
matrix:
python-version: ["3.8", "3.9", "3.10"]
os: ['ubuntu-latest', 'macos-latest', 'windows-latest']
python-version: ["3.8", "3.9", "3.10", "3.11"]
fail-fast: false
steps:
@@ -41,3 +41,40 @@ jobs:
run: |
inv build
inv build.mkdocs
build-rust:
runs-on: ubuntu-latest
strategy:
matrix:
python-version: [ "3.8", "3.9", "3.10", "3.11" ]
rust-version: [ "1.70.0", "stable" ]
fail-fast: false
steps:
- name: Check out from Git
uses: actions/checkout@v3
- name: Set up Python ${{ matrix.python-version }}
uses: actions/setup-python@v4
with:
python-version: ${{ matrix.python-version }}
- name: Install dependencies
run: |
python -m pip install --upgrade pip
python -m pip install ".[build,test,development,documentation]"
- name: Install Rust toolchain
uses: actions-rust-lang/setup-rust-toolchain@v1
with:
components: clippy,rustfmt
toolchain: ${{ matrix.rust-version }}
- name: Check License Headers
run: cd rust && cargo run --features dev-tools --bin file-header check-all
- name: Rust Build
run: cd rust && cargo build --all-targets && cargo build --all-features --all-targets
# Lints after build so what clippy needs is already built
- name: Rust Lints
run: cd rust && cargo fmt --check && cargo clippy --all-targets -- --deny warnings && cargo clippy --all-features --all-targets -- --deny warnings
- name: Rust Tests
run: cd rust && cargo test
# 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`.
- name: Build Bumble CLI
run: cd rust && cargo build --features bumble-tools --bin bumble

1
.gitignore vendored
View File

@@ -9,3 +9,4 @@ __pycache__
# generated by setuptools_scm
bumble/_version.py
.vscode/launch.json
/.idea

View File

@@ -39,10 +39,12 @@
"libusb",
"MITM",
"NDIS",
"netsim",
"NONBLOCK",
"NONCONN",
"OXIMETER",
"popleft",
"protobuf",
"psms",
"pyee",
"pyusb",

View File

@@ -1172,7 +1172,7 @@ class ScanResult:
name = ''
# 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
bar_string = rssi_bar(self.rssi)

View File

@@ -63,7 +63,8 @@ async def get_classic_info(host):
if command_succeeded(response):
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):

View File

@@ -105,7 +105,7 @@ class ServerBridge:
asyncio.create_task(self.pipe.l2cap_channel.disconnect())
def data_received(self, data):
print(f'<<< Received on TCP: {len(data)}')
print(color(f'<<< [TCP DATA]: {len(data)} bytes', 'blue'))
self.pipe.l2cap_channel.write(data)
try:
@@ -123,6 +123,7 @@ class ServerBridge:
await self.l2cap_channel.disconnect()
def on_l2cap_close(self):
print(color('*** L2CAP channel closed', 'red'))
self.l2cap_channel = None
if self.tcp_transport is not None:
self.tcp_transport.close()

View File

@@ -1,8 +1,10 @@
import asyncio
import click
import logging
import json
from bumble.pandora import PandoraDevice, serve
from bumble.pandora import PandoraDevice, Config, serve
from typing import Dict, Any
BUMBLE_SERVER_GRPC_PORT = 7999
ROOTCANAL_PORT_CUTTLEFISH = 7300
@@ -18,12 +20,31 @@ ROOTCANAL_PORT_CUTTLEFISH = 7300
help='HCI transport',
default=f'tcp-client:127.0.0.1:<rootcanal-port>',
)
def main(grpc_port: int, rootcanal_port: int, transport: str) -> None:
@click.option(
'--config',
help='Bumble json configuration file',
)
def main(grpc_port: int, rootcanal_port: int, transport: str, config: str) -> None:
if '<rootcanal-port>' in transport:
transport = transport.replace('<rootcanal-port>', str(rootcanal_port))
device = PandoraDevice({'transport': transport})
bumble_config = retrieve_config(config)
bumble_config.setdefault('transport', transport)
device = PandoraDevice(bumble_config)
server_config = Config()
server_config.load_from_dict(bumble_config.get('server', {}))
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]:
if not config:
return {}
with open(config, 'r') as f:
return json.load(f)
if __name__ == '__main__':

View File

@@ -102,9 +102,21 @@ class SnoopPacketReader:
default='h4',
help='Format of the input file',
)
@click.option(
'--vendors',
type=click.Choice(['android', 'zephyr']),
multiple=True,
help='Support vendor-specific commands (list one or more)',
)
@click.argument('filename')
# pylint: disable=redefined-builtin
def main(format, filename):
def main(format, vendors, filename):
for vendor in vendors:
if vendor == 'android':
import bumble.vendor.android.hci
elif vendor == 'zephyr':
import bumble.vendor.zephyr.hci
input = open(filename, 'rb')
if format == 'h4':
packet_reader = PacketReader(input)
@@ -124,7 +136,6 @@ def main(format, filename):
if packet is None:
break
tracer.trace(hci.HCI_Packet.from_bytes(packet), direction)
except Exception as error:
print(color(f'!!! {error}', 'red'))

View File

@@ -56,7 +56,7 @@ body, h1, h2, h3, h4, h5, h6 {
border-radius: 4px;
padding: 4px;
margin: 6px;
margin-left: 0px;
margin-left: 0;
}
th, td {
@@ -65,7 +65,7 @@ th, td {
}
.properties td:nth-child(even) {
background-color: #D6EEEE;
background-color: #d6eeee;
font-family: monospace;
}

View File

@@ -2,7 +2,7 @@
<html>
<head>
<title>Bumble Speaker</title>
<script type="text/javascript" src="speaker.js"></script>
<script src="speaker.js"></script>
<link rel="stylesheet" href="speaker.css">
</head>
<body>

View File

@@ -195,7 +195,7 @@ class WebSocketOutput(QueuedOutput):
except HCI_StatusError:
pass
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(
'connection',
peer_address=peer_address,
@@ -228,10 +228,11 @@ class FfplayOutput(QueuedOutput):
subprocess: Optional[asyncio.subprocess.Process]
ffplay_task: Optional[asyncio.Task]
def __init__(self) -> None:
super().__init__(AacAudioExtractor())
def __init__(self, codec: str) -> None:
super().__init__(AudioExtractor.create(codec))
self.subprocess = None
self.ffplay_task = None
self.codec = codec
async def start(self):
if self.started:
@@ -240,7 +241,7 @@ class FfplayOutput(QueuedOutput):
await super().start()
self.subprocess = await asyncio.create_subprocess_shell(
'ffplay -acodec aac pipe:0',
f'ffplay -f {self.codec} pipe:0',
stdin=asyncio.subprocess.PIPE,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
@@ -375,7 +376,7 @@ class UiServer:
if connection := self.speaker().connection:
await self.send_message(
'connection',
peer_address=str(connection.peer_address).replace('/P', ''),
peer_address=connection.peer_address.to_string(False),
peer_name=connection.peer_name,
)
@@ -419,7 +420,7 @@ class Speaker:
self.outputs = []
for output in outputs:
if output == '@ffplay':
self.outputs.append(FfplayOutput())
self.outputs.append(FfplayOutput(codec))
continue
# Default to FileOutput
@@ -708,17 +709,6 @@ def speaker(
):
"""Run the speaker."""
# ffplay only works with AAC for now
if codec != 'aac' and '@ffplay' in output:
print(
color(
f'{codec} not supported with @ffplay output, '
'@ffplay output will be skipped',
'yellow',
)
)
output = list(filter(lambda x: x != '@ffplay', output))
if '@ffplay' in output:
# Check if ffplay is installed
try:

85
bumble/at.py Normal file
View File

@@ -0,0 +1,85 @@
# 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.
from typing import List, Union
def tokenize_parameters(buffer: bytes) -> List[bytes]:
"""Split input parameters into tokens.
Removes space characters outside of double quote blocks:
T-rec-V-25 - 5.2.1 Command line general format: "Space characters (IA5 2/0)
are ignored [..], unless they are embedded in numeric or string constants"
Raises ValueError in case of invalid input string."""
tokens = []
in_quotes = False
token = bytearray()
for b in buffer:
char = bytearray([b])
if in_quotes:
token.extend(char)
if char == b'\"':
in_quotes = False
tokens.append(token[1:-1])
token = bytearray()
else:
if char == b' ':
pass
elif char == b',' or char == b')':
tokens.append(token)
tokens.append(char)
token = bytearray()
elif char == b'(':
if len(token) > 0:
raise ValueError("open_paren following regular character")
tokens.append(char)
elif char == b'"':
if len(token) > 0:
raise ValueError("quote following regular character")
in_quotes = True
token.extend(char)
else:
token.extend(char)
tokens.append(token)
return [bytes(token) for token in tokens if len(token) > 0]
def parse_parameters(buffer: bytes) -> List[Union[bytes, list]]:
"""Parse the parameters using the comma and parenthesis separators.
Raises ValueError in case of invalid input string."""
tokens = tokenize_parameters(buffer)
accumulator: List[list] = [[]]
current: Union[bytes, list] = bytes()
for token in tokens:
if token == b',':
accumulator[-1].append(current)
current = bytes()
elif token == b'(':
accumulator.append([])
elif token == b')':
if len(accumulator) < 2:
raise ValueError("close_paren without matching open_paren")
accumulator[-1].append(current)
current = accumulator.pop()
else:
current = token
accumulator[-1].append(current)
if len(accumulator) > 1:
raise ValueError("missing close_paren")
return accumulator[0]

View File

@@ -23,13 +23,14 @@
# Imports
# -----------------------------------------------------------------------------
from __future__ import annotations
import enum
import functools
import struct
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.hci import HCI_Object, key_with_value, HCI_Constant
from bumble.core import UUID, name_or_number, ProtocolError
from bumble.hci import HCI_Object, key_with_value
from bumble.colors import color
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: disable=invalid-name
# -----------------------------------------------------------------------------
# Exceptions
# -----------------------------------------------------------------------------
@@ -209,7 +211,7 @@ class ATT_PDU:
pdu_classes: Dict[int, Type[ATT_PDU]] = {}
op_code = 0
name = None
name: str
@staticmethod
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):
# Permission flags
READABLE = 0x01
WRITEABLE = 0x02
READ_REQUIRES_ENCRYPTION = 0x04
WRITE_REQUIRES_ENCRYPTION = 0x08
READ_REQUIRES_AUTHENTICATION = 0x10
WRITE_REQUIRES_AUTHENTICATION = 0x20
READ_REQUIRES_AUTHORIZATION = 0x40
WRITE_REQUIRES_AUTHORIZATION = 0x80
class Permissions(enum.IntFlag):
READABLE = 0x01
WRITEABLE = 0x02
READ_REQUIRES_ENCRYPTION = 0x04
WRITE_REQUIRES_ENCRYPTION = 0x08
READ_REQUIRES_AUTHENTICATION = 0x10
WRITE_REQUIRES_AUTHENTICATION = 0x20
READ_REQUIRES_AUTHORIZATION = 0x40
WRITE_REQUIRES_AUTHORIZATION = 0x80
PERMISSION_NAMES = {
READABLE: 'READABLE',
WRITEABLE: 'WRITEABLE',
READ_REQUIRES_ENCRYPTION: 'READ_REQUIRES_ENCRYPTION',
WRITE_REQUIRES_ENCRYPTION: 'WRITE_REQUIRES_ENCRYPTION',
READ_REQUIRES_AUTHENTICATION: 'READ_REQUIRES_AUTHENTICATION',
WRITE_REQUIRES_AUTHENTICATION: 'WRITE_REQUIRES_AUTHENTICATION',
READ_REQUIRES_AUTHORIZATION: 'READ_REQUIRES_AUTHORIZATION',
WRITE_REQUIRES_AUTHORIZATION: 'WRITE_REQUIRES_AUTHORIZATION',
}
@classmethod
def from_string(cls, permissions_str: str) -> Attribute.Permissions:
try:
return functools.reduce(
lambda x, y: x | Attribute.Permissions[y],
permissions_str.replace('|', ',').split(","),
Attribute.Permissions(0),
)
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
def string_to_permissions(permissions_str: str):
try:
return functools.reduce(
lambda x, y: x | get_dict_key_by_value(Attribute.PERMISSION_NAMES, y),
permissions_str.split(","),
0,
)
except TypeError as exc:
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
# Permission flags(legacy-use only)
READABLE = Permissions.READABLE
WRITEABLE = Permissions.WRITEABLE
READ_REQUIRES_ENCRYPTION = Permissions.READ_REQUIRES_ENCRYPTION
WRITE_REQUIRES_ENCRYPTION = Permissions.WRITE_REQUIRES_ENCRYPTION
READ_REQUIRES_AUTHENTICATION = Permissions.READ_REQUIRES_AUTHENTICATION
WRITE_REQUIRES_AUTHENTICATION = Permissions.WRITE_REQUIRES_AUTHENTICATION
READ_REQUIRES_AUTHORIZATION = Permissions.READ_REQUIRES_AUTHORIZATION
WRITE_REQUIRES_AUTHORIZATION = Permissions.WRITE_REQUIRES_AUTHORIZATION
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)
self.handle = 0
self.end_group_handle = 0
if isinstance(permissions, str):
self.permissions = self.string_to_permissions(permissions)
self.permissions = Attribute.Permissions.from_string(permissions)
else:
self.permissions = permissions
@@ -778,22 +800,26 @@ class Attribute(EventEmitter):
else:
self.value = value
def encode_value(self, value):
def encode_value(self, value: Any) -> bytes:
return value
def decode_value(self, value_bytes):
def decode_value(self, value_bytes: bytes) -> Any:
return value_bytes
def read_value(self, connection: Connection):
def read_value(self, connection: Optional[Connection]) -> bytes:
if (
self.permissions & self.READ_REQUIRES_ENCRYPTION
) and not connection.encryption:
(self.permissions & self.READ_REQUIRES_ENCRYPTION)
and connection is not None
and not connection.encryption
):
raise ATT_Error(
error_code=ATT_INSUFFICIENT_ENCRYPTION_ERROR, att_handle=self.handle
)
if (
self.permissions & self.READ_REQUIRES_AUTHENTICATION
) and not connection.authenticated:
(self.permissions & self.READ_REQUIRES_AUTHENTICATION)
and connection is not None
and not connection.authenticated
):
raise ATT_Error(
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
)
if read := getattr(self.value, 'read', None):
if hasattr(self.value, 'read'):
try:
value = read(connection) # pylint: disable=not-callable
value = self.value.read(connection)
except ATT_Error as error:
raise ATT_Error(
error_code=error.error_code, att_handle=self.handle
@@ -815,7 +841,7 @@ class Attribute(EventEmitter):
return self.encode_value(value)
def write_value(self, connection: Connection, value_bytes):
def write_value(self, connection: Connection, value_bytes: bytes) -> None:
if (
self.permissions & self.WRITE_REQUIRES_ENCRYPTION
) and not connection.encryption:
@@ -836,9 +862,9 @@ class Attribute(EventEmitter):
value = self.decode_value(value_bytes)
if write := getattr(self.value, 'write', None):
if hasattr(self.value, 'write'):
try:
write(connection, value) # pylint: disable=not-callable
self.value.write(connection, value) # pylint: disable=not-callable
except ATT_Error as error:
raise ATT_Error(
error_code=error.error_code, att_handle=self.handle

View File

@@ -15,6 +15,8 @@
# -----------------------------------------------------------------------------
# Imports
# -----------------------------------------------------------------------------
from __future__ import annotations
import logging
import asyncio
import itertools
@@ -58,8 +60,10 @@ from bumble.hci import (
HCI_Packet,
HCI_Role_Change_Event,
)
from typing import Optional, Union, Dict
from typing import Optional, Union, Dict, TYPE_CHECKING
if TYPE_CHECKING:
from bumble.transport.common import TransportSink, TransportSource
# -----------------------------------------------------------------------------
# Logging
@@ -104,7 +108,7 @@ class Controller:
self,
name,
host_source=None,
host_sink=None,
host_sink: Optional[TransportSink] = None,
link=None,
public_address: Optional[Union[bytes, str, Address]] = None,
):
@@ -188,6 +192,8 @@ class Controller:
if link:
link.add_controller(self)
self.terminated = asyncio.get_running_loop().create_future()
@property
def host(self):
return self.hci_sink
@@ -288,10 +294,9 @@ class Controller:
if self.host:
self.host.on_packet(packet.to_bytes())
# This method allow the controller to emulate the same API as a transport source
# This method allows the controller to emulate the same API as a transport source
async def wait_for_termination(self):
# For now, just wait forever
await asyncio.get_running_loop().create_future()
await self.terminated
############################################################
# Link connections
@@ -654,7 +659,7 @@ class Controller:
def on_hci_create_connection_command(self, command):
'''
See Bluetooth spec Vol 2, Part E - 7.1.5 Create Connection command
See Bluetooth spec Vol 4, Part E - 7.1.5 Create Connection command
'''
if self.link is None:
@@ -685,7 +690,7 @@ class Controller:
def on_hci_disconnect_command(self, command):
'''
See Bluetooth spec Vol 2, Part E - 7.1.6 Disconnect Command
See Bluetooth spec Vol 4, Part E - 7.1.6 Disconnect Command
'''
# First, say that the disconnection is pending
self.send_hci_packet(
@@ -719,7 +724,7 @@ class Controller:
def on_hci_accept_connection_request_command(self, command):
'''
See Bluetooth spec Vol 2, Part E - 7.1.8 Accept Connection Request command
See Bluetooth spec Vol 4, Part E - 7.1.8 Accept Connection Request command
'''
if self.link is None:
@@ -735,7 +740,7 @@ class Controller:
def on_hci_switch_role_command(self, command):
'''
See Bluetooth spec Vol 2, Part E - 7.2.8 Switch Role command
See Bluetooth spec Vol 4, Part E - 7.2.8 Switch Role command
'''
if self.link is None:
@@ -751,21 +756,21 @@ class Controller:
def on_hci_set_event_mask_command(self, command):
'''
See Bluetooth spec Vol 2, Part E - 7.3.1 Set Event Mask Command
See Bluetooth spec Vol 4, Part E - 7.3.1 Set Event Mask Command
'''
self.event_mask = command.event_mask
return bytes([HCI_SUCCESS])
def on_hci_reset_command(self, _command):
'''
See Bluetooth spec Vol 2, Part E - 7.3.2 Reset Command
See Bluetooth spec Vol 4, Part E - 7.3.2 Reset Command
'''
# TODO: cleanup what needs to be reset
return bytes([HCI_SUCCESS])
def on_hci_write_local_name_command(self, command):
'''
See Bluetooth spec Vol 2, Part E - 7.3.11 Write Local Name Command
See Bluetooth spec Vol 4, Part E - 7.3.11 Write Local Name Command
'''
local_name = command.local_name
if len(local_name):
@@ -780,7 +785,7 @@ class Controller:
def on_hci_read_local_name_command(self, _command):
'''
See Bluetooth spec Vol 2, Part E - 7.3.12 Read Local Name Command
See Bluetooth spec Vol 4, Part E - 7.3.12 Read Local Name Command
'''
local_name = bytes(self.local_name, 'utf-8')[:248]
if len(local_name) < 248:
@@ -790,19 +795,19 @@ class Controller:
def on_hci_read_class_of_device_command(self, _command):
'''
See Bluetooth spec Vol 2, Part E - 7.3.25 Read Class of Device Command
See Bluetooth spec Vol 4, Part E - 7.3.25 Read Class of Device Command
'''
return bytes([HCI_SUCCESS, 0, 0, 0])
def on_hci_write_class_of_device_command(self, _command):
'''
See Bluetooth spec Vol 2, Part E - 7.3.26 Write Class of Device Command
See Bluetooth spec Vol 4, Part E - 7.3.26 Write Class of Device Command
'''
return bytes([HCI_SUCCESS])
def on_hci_read_synchronous_flow_control_enable_command(self, _command):
'''
See Bluetooth spec Vol 2, Part E - 7.3.36 Read Synchronous Flow Control Enable
See Bluetooth spec Vol 4, Part E - 7.3.36 Read Synchronous Flow Control Enable
Command
'''
if self.sync_flow_control:
@@ -813,7 +818,7 @@ class Controller:
def on_hci_write_synchronous_flow_control_enable_command(self, command):
'''
See Bluetooth spec Vol 2, Part E - 7.3.37 Write Synchronous Flow Control Enable
See Bluetooth spec Vol 4, Part E - 7.3.37 Write Synchronous Flow Control Enable
Command
'''
ret = HCI_SUCCESS
@@ -825,41 +830,59 @@ class Controller:
ret = HCI_INVALID_HCI_COMMAND_PARAMETERS_ERROR
return bytes([ret])
def on_hci_set_controller_to_host_flow_control_command(self, _command):
'''
See Bluetooth spec Vol 4, Part E - 7.3.38 Set Controller To Host Flow Control
Command
'''
# For now we just accept the command but ignore the values.
# TODO: respect the passed in values.
return bytes([HCI_SUCCESS])
def on_hci_host_buffer_size_command(self, _command):
'''
See Bluetooth spec Vol 4, Part E - 7.3.39 Host Buffer Size Command
'''
# For now we just accept the command but ignore the values.
# TODO: respect the passed in values.
return bytes([HCI_SUCCESS])
def on_hci_write_extended_inquiry_response_command(self, _command):
'''
See Bluetooth spec Vol 2, Part E - 7.3.59 Write Simple Pairing Mode Command
See Bluetooth spec Vol 4, Part E - 7.3.56 Write Extended Inquiry Response
Command
'''
return bytes([HCI_SUCCESS])
def on_hci_write_simple_pairing_mode_command(self, _command):
'''
See Bluetooth spec Vol 2, Part E - 7.3.59 Write Simple Pairing Mode Command
See Bluetooth spec Vol 4, Part E - 7.3.59 Write Simple Pairing Mode Command
'''
return bytes([HCI_SUCCESS])
def on_hci_set_event_mask_page_2_command(self, command):
'''
See Bluetooth spec Vol 2, Part E - 7.3.69 Set Event Mask Page 2 Command
See Bluetooth spec Vol 4, Part E - 7.3.69 Set Event Mask Page 2 Command
'''
self.event_mask_page_2 = command.event_mask_page_2
return bytes([HCI_SUCCESS])
def on_hci_read_le_host_support_command(self, _command):
'''
See Bluetooth spec Vol 2, Part E - 7.3.78 Write LE Host Support Command
See Bluetooth spec Vol 4, Part E - 7.3.78 Write LE Host Support Command
'''
return bytes([HCI_SUCCESS, 1, 0])
def on_hci_write_le_host_support_command(self, _command):
'''
See Bluetooth spec Vol 2, Part E - 7.3.79 Write LE Host Support Command
See Bluetooth spec Vol 4, Part E - 7.3.79 Write LE Host Support Command
'''
# TODO / Just ignore for now
return bytes([HCI_SUCCESS])
def on_hci_write_authenticated_payload_timeout_command(self, command):
'''
See Bluetooth spec Vol 2, Part E - 7.3.94 Write Authenticated Payload Timeout
See Bluetooth spec Vol 4, Part E - 7.3.94 Write Authenticated Payload Timeout
Command
'''
# TODO
@@ -867,7 +890,7 @@ class Controller:
def on_hci_read_local_version_information_command(self, _command):
'''
See Bluetooth spec Vol 2, Part E - 7.4.1 Read Local Version Information Command
See Bluetooth spec Vol 4, Part E - 7.4.1 Read Local Version Information Command
'''
return struct.pack(
'<BBHBHH',
@@ -881,19 +904,19 @@ class Controller:
def on_hci_read_local_supported_commands_command(self, _command):
'''
See Bluetooth spec Vol 2, Part E - 7.4.2 Read Local Supported Commands Command
See Bluetooth spec Vol 4, Part E - 7.4.2 Read Local Supported Commands Command
'''
return bytes([HCI_SUCCESS]) + self.supported_commands
def on_hci_read_local_supported_features_command(self, _command):
'''
See Bluetooth spec Vol 2, Part E - 7.4.3 Read Local Supported Features Command
See Bluetooth spec Vol 4, Part E - 7.4.3 Read Local Supported Features Command
'''
return bytes([HCI_SUCCESS]) + self.lmp_features
def on_hci_read_bd_addr_command(self, _command):
'''
See Bluetooth spec Vol 2, Part E - 7.4.6 Read BD_ADDR Command
See Bluetooth spec Vol 4, Part E - 7.4.6 Read BD_ADDR Command
'''
bd_addr = (
self._public_address.to_bytes()
@@ -904,14 +927,14 @@ class Controller:
def on_hci_le_set_event_mask_command(self, command):
'''
See Bluetooth spec Vol 2, Part E - 7.8.1 LE Set Event Mask Command
See Bluetooth spec Vol 4, Part E - 7.8.1 LE Set Event Mask Command
'''
self.le_event_mask = command.le_event_mask
return bytes([HCI_SUCCESS])
def on_hci_le_read_buffer_size_command(self, _command):
'''
See Bluetooth spec Vol 2, Part E - 7.8.2 LE Read Buffer Size Command
See Bluetooth spec Vol 4, Part E - 7.8.2 LE Read Buffer Size Command
'''
return struct.pack(
'<BHB',
@@ -922,49 +945,49 @@ class Controller:
def on_hci_le_read_local_supported_features_command(self, _command):
'''
See Bluetooth spec Vol 2, Part E - 7.8.3 LE Read Local Supported Features
See Bluetooth spec Vol 4, Part E - 7.8.3 LE Read Local Supported Features
Command
'''
return bytes([HCI_SUCCESS]) + self.le_features
def on_hci_le_set_random_address_command(self, command):
'''
See Bluetooth spec Vol 2, Part E - 7.8.4 LE Set Random Address Command
See Bluetooth spec Vol 4, Part E - 7.8.4 LE Set Random Address Command
'''
self.random_address = command.random_address
return bytes([HCI_SUCCESS])
def on_hci_le_set_advertising_parameters_command(self, command):
'''
See Bluetooth spec Vol 2, Part E - 7.8.5 LE Set Advertising Parameters Command
See Bluetooth spec Vol 4, Part E - 7.8.5 LE Set Advertising Parameters Command
'''
self.advertising_parameters = command
return bytes([HCI_SUCCESS])
def on_hci_le_read_advertising_physical_channel_tx_power_command(self, _command):
'''
See Bluetooth spec Vol 2, Part E - 7.8.6 LE Read Advertising Physical Channel
See Bluetooth spec Vol 4, Part E - 7.8.6 LE Read Advertising Physical Channel
Tx Power Command
'''
return bytes([HCI_SUCCESS, self.advertising_channel_tx_power])
def on_hci_le_set_advertising_data_command(self, command):
'''
See Bluetooth spec Vol 2, Part E - 7.8.7 LE Set Advertising Data Command
See Bluetooth spec Vol 4, Part E - 7.8.7 LE Set Advertising Data Command
'''
self.advertising_data = command.advertising_data
return bytes([HCI_SUCCESS])
def on_hci_le_set_scan_response_data_command(self, command):
'''
See Bluetooth spec Vol 2, Part E - 7.8.8 LE Set Scan Response Data Command
See Bluetooth spec Vol 4, Part E - 7.8.8 LE Set Scan Response Data Command
'''
self.le_scan_response_data = command.scan_response_data
return bytes([HCI_SUCCESS])
def on_hci_le_set_advertising_enable_command(self, command):
'''
See Bluetooth spec Vol 2, Part E - 7.8.9 LE Set Advertising Enable Command
See Bluetooth spec Vol 4, Part E - 7.8.9 LE Set Advertising Enable Command
'''
if command.advertising_enable:
self.start_advertising()
@@ -975,7 +998,7 @@ class Controller:
def on_hci_le_set_scan_parameters_command(self, command):
'''
See Bluetooth spec Vol 2, 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
'''
self.le_scan_type = command.le_scan_type
self.le_scan_interval = command.le_scan_interval
@@ -986,7 +1009,7 @@ class Controller:
def on_hci_le_set_scan_enable_command(self, command):
'''
See Bluetooth spec Vol 2, Part E - 7.8.11 LE Set Scan Enable Command
See Bluetooth spec Vol 4, Part E - 7.8.11 LE Set Scan Enable Command
'''
self.le_scan_enable = command.le_scan_enable
self.filter_duplicates = command.filter_duplicates
@@ -994,7 +1017,7 @@ class Controller:
def on_hci_le_create_connection_command(self, command):
'''
See Bluetooth spec Vol 2, Part E - 7.8.12 LE Create Connection Command
See Bluetooth spec Vol 4, Part E - 7.8.12 LE Create Connection Command
'''
if not self.link:
@@ -1027,40 +1050,40 @@ class Controller:
def on_hci_le_create_connection_cancel_command(self, _command):
'''
See Bluetooth spec Vol 2, Part E - 7.8.13 LE Create Connection Cancel Command
See Bluetooth spec Vol 4, Part E - 7.8.13 LE Create Connection Cancel Command
'''
return bytes([HCI_SUCCESS])
def on_hci_le_read_filter_accept_list_size_command(self, _command):
'''
See Bluetooth spec Vol 2, Part E - 7.8.14 LE Read Filter Accept List Size
See Bluetooth spec Vol 4, Part E - 7.8.14 LE Read Filter Accept List Size
Command
'''
return bytes([HCI_SUCCESS, self.filter_accept_list_size])
def on_hci_le_clear_filter_accept_list_command(self, _command):
'''
See Bluetooth spec Vol 2, Part E - 7.8.15 LE Clear Filter Accept List Command
See Bluetooth spec Vol 4, Part E - 7.8.15 LE Clear Filter Accept List Command
'''
return bytes([HCI_SUCCESS])
def on_hci_le_add_device_to_filter_accept_list_command(self, _command):
'''
See Bluetooth spec Vol 2, Part E - 7.8.16 LE Add Device To Filter Accept List
See Bluetooth spec Vol 4, Part E - 7.8.16 LE Add Device To Filter Accept List
Command
'''
return bytes([HCI_SUCCESS])
def on_hci_le_remove_device_from_filter_accept_list_command(self, _command):
'''
See Bluetooth spec Vol 2, Part E - 7.8.17 LE Remove Device From Filter Accept
See Bluetooth spec Vol 4, Part E - 7.8.17 LE Remove Device From Filter Accept
List Command
'''
return bytes([HCI_SUCCESS])
def on_hci_le_read_remote_features_command(self, command):
'''
See Bluetooth spec Vol 2, Part E - 7.8.21 LE Read Remote Features Command
See Bluetooth spec Vol 4, Part E - 7.8.21 LE Read Remote Features Command
'''
# First, say that the command is pending
@@ -1083,13 +1106,13 @@ class Controller:
def on_hci_le_rand_command(self, _command):
'''
See Bluetooth spec Vol 2, Part E - 7.8.23 LE Rand Command
See Bluetooth spec Vol 4, Part E - 7.8.23 LE Rand Command
'''
return bytes([HCI_SUCCESS]) + struct.pack('Q', random.randint(0, 1 << 64))
def on_hci_le_enable_encryption_command(self, command):
'''
See Bluetooth spec Vol 2, Part E - 7.8.24 LE Enable Encryption Command
See Bluetooth spec Vol 4, Part E - 7.8.24 LE Enable Encryption Command
'''
# Check the parameters
@@ -1122,13 +1145,13 @@ class Controller:
def on_hci_le_read_supported_states_command(self, _command):
'''
See Bluetooth spec Vol 2, Part E - 7.8.27 LE Read Supported States Command
See Bluetooth spec Vol 4, Part E - 7.8.27 LE Read Supported States Command
'''
return bytes([HCI_SUCCESS]) + self.le_states
def on_hci_le_read_suggested_default_data_length_command(self, _command):
'''
See Bluetooth spec Vol 2, Part E - 7.8.34 LE Read Suggested Default Data Length
See Bluetooth spec Vol 4, Part E - 7.8.34 LE Read Suggested Default Data Length
Command
'''
return struct.pack(
@@ -1140,7 +1163,7 @@ class Controller:
def on_hci_le_write_suggested_default_data_length_command(self, command):
'''
See Bluetooth spec Vol 2, Part E - 7.8.35 LE Write Suggested Default Data Length
See Bluetooth spec Vol 4, Part E - 7.8.35 LE Write Suggested Default Data Length
Command
'''
self.suggested_max_tx_octets, self.suggested_max_tx_time = struct.unpack(
@@ -1150,33 +1173,33 @@ class Controller:
def on_hci_le_read_local_p_256_public_key_command(self, _command):
'''
See Bluetooth spec Vol 2, Part E - 7.8.36 LE Read P-256 Public Key Command
See Bluetooth spec Vol 4, Part E - 7.8.36 LE Read P-256 Public Key Command
'''
# TODO create key and send HCI_LE_Read_Local_P-256_Public_Key_Complete event
return bytes([HCI_SUCCESS])
def on_hci_le_add_device_to_resolving_list_command(self, _command):
'''
See Bluetooth spec Vol 2, Part E - 7.8.38 LE Add Device To Resolving List
See Bluetooth spec Vol 4, Part E - 7.8.38 LE Add Device To Resolving List
Command
'''
return bytes([HCI_SUCCESS])
def on_hci_le_clear_resolving_list_command(self, _command):
'''
See Bluetooth spec Vol 2, Part E - 7.8.40 LE Clear Resolving List Command
See Bluetooth spec Vol 4, Part E - 7.8.40 LE Clear Resolving List Command
'''
return bytes([HCI_SUCCESS])
def on_hci_le_read_resolving_list_size_command(self, _command):
'''
See Bluetooth spec Vol 2, Part E - 7.8.41 LE Read Resolving List Size Command
See Bluetooth spec Vol 4, Part E - 7.8.41 LE Read Resolving List Size Command
'''
return bytes([HCI_SUCCESS, self.resolving_list_size])
def on_hci_le_set_address_resolution_enable_command(self, command):
'''
See Bluetooth spec Vol 2, Part E - 7.8.44 LE Set Address Resolution Enable
See Bluetooth spec Vol 4, Part E - 7.8.44 LE Set Address Resolution Enable
Command
'''
ret = HCI_SUCCESS
@@ -1190,7 +1213,7 @@ class Controller:
def on_hci_le_set_resolvable_private_address_timeout_command(self, command):
'''
See Bluetooth spec Vol 2, Part E - 7.8.45 LE Set Resolvable Private Address
See Bluetooth spec Vol 4, Part E - 7.8.45 LE Set Resolvable Private Address
Timeout Command
'''
self.le_rpa_timeout = command.rpa_timeout
@@ -1198,7 +1221,7 @@ class Controller:
def on_hci_le_read_maximum_data_length_command(self, _command):
'''
See Bluetooth spec Vol 2, Part E - 7.8.46 LE Read Maximum Data Length Command
See Bluetooth spec Vol 4, Part E - 7.8.46 LE Read Maximum Data Length Command
'''
return struct.pack(
'<BHHHH',
@@ -1211,7 +1234,7 @@ class Controller:
def on_hci_le_read_phy_command(self, command):
'''
See Bluetooth spec Vol 2, Part E - 7.8.47 LE Read PHY Command
See Bluetooth spec Vol 4, Part E - 7.8.47 LE Read PHY Command
'''
return struct.pack(
'<BHBB',
@@ -1223,7 +1246,7 @@ class Controller:
def on_hci_le_set_default_phy_command(self, command):
'''
See Bluetooth spec Vol 2, Part E - 7.8.48 LE Set Default PHY Command
See Bluetooth spec Vol 4, Part E - 7.8.48 LE Set Default PHY Command
'''
self.default_phy = {
'all_phys': command.all_phys,
@@ -1234,6 +1257,6 @@ class Controller:
def on_hci_le_read_transmit_power_command(self, _command):
'''
See Bluetooth spec Vol 2, Part E - 7.8.74 LE Read Transmit Power Command
See Bluetooth spec Vol 4, Part E - 7.8.74 LE Read Transmit Power Command
'''
return struct.pack('<BBB', HCI_SUCCESS, 0, 0)

View File

@@ -17,7 +17,7 @@
# -----------------------------------------------------------------------------
from __future__ import annotations
import struct
from typing import List, Optional, Tuple, Union, cast
from typing import List, Optional, Tuple, Union, cast, Dict
from .company_ids import COMPANY_IDENTIFIERS
@@ -53,7 +53,7 @@ def bit_flags_to_strings(bits, bit_flag_names):
return names
def name_or_number(dictionary, number, width=2):
def name_or_number(dictionary: Dict[int, str], number: int, width: int = 2) -> str:
name = dictionary.get(number)
if name is not None:
return name
@@ -78,7 +78,13 @@ def get_dict_key_by_value(dictionary, value):
class BaseError(Exception):
"""Base class for errors with an error code, error name and namespace"""
def __init__(self, error_code, error_namespace='', error_name='', details=''):
def __init__(
self,
error_code: Optional[int],
error_namespace: str = '',
error_name: str = '',
details: str = '',
):
super().__init__()
self.error_code = error_code
self.error_namespace = error_namespace
@@ -90,12 +96,14 @@ class BaseError(Exception):
namespace = f'{self.error_namespace}/'
else:
namespace = ''
if self.error_name:
name = f'{self.error_name} [0x{self.error_code:X}]'
else:
name = f'0x{self.error_code:X}'
error_text = {
(True, True): f'{self.error_name} [0x{self.error_code:X}]',
(True, False): self.error_name,
(False, True): f'0x{self.error_code:X}',
(False, False): '',
}[(self.error_name != '', self.error_code is not None)]
return f'{type(self).__name__}({namespace}{name})'
return f'{type(self).__name__}({namespace}{error_text})'
class ProtocolError(BaseError):
@@ -134,6 +142,10 @@ class ConnectionError(BaseError): # pylint: disable=redefined-builtin
self.peer_address = peer_address
class ConnectionParameterUpdateError(BaseError):
"""Connection Parameter Update Error"""
# -----------------------------------------------------------------------------
# UUID
#
@@ -562,11 +574,82 @@ class DeviceClass:
PERIPHERAL_HANDHELD_GESTURAL_INPUT_DEVICE_MINOR_DEVICE_CLASS: 'Handheld gestural input device'
}
WEARABLE_UNCATEGORIZED_MINOR_DEVICE_CLASS = 0x00
WEARABLE_WRISTWATCH_MINOR_DEVICE_CLASS = 0x01
WEARABLE_PAGER_MINOR_DEVICE_CLASS = 0x02
WEARABLE_JACKET_MINOR_DEVICE_CLASS = 0x03
WEARABLE_HELMET_MINOR_DEVICE_CLASS = 0x04
WEARABLE_GLASSES_MINOR_DEVICE_CLASS = 0x05
WEARABLE_MINOR_DEVICE_CLASS_NAMES = {
WEARABLE_UNCATEGORIZED_MINOR_DEVICE_CLASS: 'Uncategorized',
WEARABLE_WRISTWATCH_MINOR_DEVICE_CLASS: 'Wristwatch',
WEARABLE_PAGER_MINOR_DEVICE_CLASS: 'Pager',
WEARABLE_JACKET_MINOR_DEVICE_CLASS: 'Jacket',
WEARABLE_HELMET_MINOR_DEVICE_CLASS: 'Helmet',
WEARABLE_GLASSES_MINOR_DEVICE_CLASS: 'Glasses',
}
TOY_UNCATEGORIZED_MINOR_DEVICE_CLASS = 0x00
TOY_ROBOT_MINOR_DEVICE_CLASS = 0x01
TOY_VEHICLE_MINOR_DEVICE_CLASS = 0x02
TOY_DOLL_ACTION_FIGURE_MINOR_DEVICE_CLASS = 0x03
TOY_CONTROLLER_MINOR_DEVICE_CLASS = 0x04
TOY_GAME_MINOR_DEVICE_CLASS = 0x05
TOY_MINOR_DEVICE_CLASS_NAMES = {
TOY_UNCATEGORIZED_MINOR_DEVICE_CLASS: 'Uncategorized',
TOY_ROBOT_MINOR_DEVICE_CLASS: 'Robot',
TOY_VEHICLE_MINOR_DEVICE_CLASS: 'Vehicle',
TOY_DOLL_ACTION_FIGURE_MINOR_DEVICE_CLASS: 'Doll/Action figure',
TOY_CONTROLLER_MINOR_DEVICE_CLASS: 'Controller',
TOY_GAME_MINOR_DEVICE_CLASS: 'Game',
}
HEALTH_UNDEFINED_MINOR_DEVICE_CLASS = 0x00
HEALTH_BLOOD_PRESSURE_MONITOR_MINOR_DEVICE_CLASS = 0x01
HEALTH_THERMOMETER_MINOR_DEVICE_CLASS = 0x02
HEALTH_WEIGHING_SCALE_MINOR_DEVICE_CLASS = 0x03
HEALTH_GLUCOSE_METER_MINOR_DEVICE_CLASS = 0x04
HEALTH_PULSE_OXIMETER_MINOR_DEVICE_CLASS = 0x05
HEALTH_HEART_PULSE_RATE_MONITOR_MINOR_DEVICE_CLASS = 0x06
HEALTH_HEALTH_DATA_DISPLAY_MINOR_DEVICE_CLASS = 0x07
HEALTH_STEP_COUNTER_MINOR_DEVICE_CLASS = 0x08
HEALTH_BODY_COMPOSITION_ANALYZER_MINOR_DEVICE_CLASS = 0x09
HEALTH_PEAK_FLOW_MONITOR_MINOR_DEVICE_CLASS = 0x0A
HEALTH_MEDICATION_MONITOR_MINOR_DEVICE_CLASS = 0x0B
HEALTH_KNEE_PROSTHESIS_MINOR_DEVICE_CLASS = 0x0C
HEALTH_ANKLE_PROSTHESIS_MINOR_DEVICE_CLASS = 0x0D
HEALTH_GENERIC_HEALTH_MANAGER_MINOR_DEVICE_CLASS = 0x0E
HEALTH_PERSONAL_MOBILITY_DEVICE_MINOR_DEVICE_CLASS = 0x0F
HEALTH_MINOR_DEVICE_CLASS_NAMES = {
HEALTH_UNDEFINED_MINOR_DEVICE_CLASS: 'Undefined',
HEALTH_BLOOD_PRESSURE_MONITOR_MINOR_DEVICE_CLASS: 'Blood Pressure Monitor',
HEALTH_THERMOMETER_MINOR_DEVICE_CLASS: 'Thermometer',
HEALTH_WEIGHING_SCALE_MINOR_DEVICE_CLASS: 'Weighing Scale',
HEALTH_GLUCOSE_METER_MINOR_DEVICE_CLASS: 'Glucose Meter',
HEALTH_PULSE_OXIMETER_MINOR_DEVICE_CLASS: 'Pulse Oximeter',
HEALTH_HEART_PULSE_RATE_MONITOR_MINOR_DEVICE_CLASS: 'Heart/Pulse Rate Monitor',
HEALTH_HEALTH_DATA_DISPLAY_MINOR_DEVICE_CLASS: 'Health Data Display',
HEALTH_STEP_COUNTER_MINOR_DEVICE_CLASS: 'Step Counter',
HEALTH_BODY_COMPOSITION_ANALYZER_MINOR_DEVICE_CLASS: 'Body Composition Analyzer',
HEALTH_PEAK_FLOW_MONITOR_MINOR_DEVICE_CLASS: 'Peak Flow Monitor',
HEALTH_MEDICATION_MONITOR_MINOR_DEVICE_CLASS: 'Medication Monitor',
HEALTH_KNEE_PROSTHESIS_MINOR_DEVICE_CLASS: 'Knee Prosthesis',
HEALTH_ANKLE_PROSTHESIS_MINOR_DEVICE_CLASS: 'Ankle Prosthesis',
HEALTH_GENERIC_HEALTH_MANAGER_MINOR_DEVICE_CLASS: 'Generic Health Manager',
HEALTH_PERSONAL_MOBILITY_DEVICE_MINOR_DEVICE_CLASS: 'Personal Mobility Device',
}
MINOR_DEVICE_CLASS_NAMES = {
COMPUTER_MAJOR_DEVICE_CLASS: COMPUTER_MINOR_DEVICE_CLASS_NAMES,
PHONE_MAJOR_DEVICE_CLASS: PHONE_MINOR_DEVICE_CLASS_NAMES,
AUDIO_VIDEO_MAJOR_DEVICE_CLASS: AUDIO_VIDEO_MINOR_DEVICE_CLASS_NAMES,
PERIPHERAL_MAJOR_DEVICE_CLASS: PERIPHERAL_MINOR_DEVICE_CLASS_NAMES
PERIPHERAL_MAJOR_DEVICE_CLASS: PERIPHERAL_MINOR_DEVICE_CLASS_NAMES,
WEARABLE_MAJOR_DEVICE_CLASS: WEARABLE_MINOR_DEVICE_CLASS_NAMES,
TOY_MAJOR_DEVICE_CLASS: TOY_MINOR_DEVICE_CLASS_NAMES,
HEALTH_MAJOR_DEVICE_CLASS: HEALTH_MINOR_DEVICE_CLASS_NAMES,
}
# fmt: on

View File

@@ -23,22 +23,18 @@
# -----------------------------------------------------------------------------
import logging
import operator
import platform
if platform.system() != 'Emscripten':
import secrets
from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
from cryptography.hazmat.primitives.asymmetric.ec import (
generate_private_key,
ECDH,
EllipticCurvePublicNumbers,
EllipticCurvePrivateNumbers,
SECP256R1,
)
from cryptography.hazmat.primitives import cmac
else:
# TODO: implement stubs
pass
import secrets
from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
from cryptography.hazmat.primitives.asymmetric.ec import (
generate_private_key,
ECDH,
EllipticCurvePublicNumbers,
EllipticCurvePrivateNumbers,
SECP256R1,
)
from cryptography.hazmat.primitives import cmac
# -----------------------------------------------------------------------------
# Logging

View File

@@ -23,7 +23,18 @@ import asyncio
import logging
from contextlib import asynccontextmanager, AsyncExitStack
from dataclasses import dataclass
from typing import Any, Callable, ClassVar, Dict, List, Optional, Tuple, Type, Union
from typing import (
Any,
Callable,
ClassVar,
Dict,
List,
Optional,
Tuple,
Type,
Union,
TYPE_CHECKING,
)
from .colors import color
from .att import ATT_CID, ATT_DEFAULT_MTU, ATT_PDU
@@ -86,6 +97,7 @@ from .hci import (
HCI_LE_Extended_Create_Connection_Command,
HCI_LE_Rand_Command,
HCI_LE_Read_PHY_Command,
HCI_LE_Set_Address_Resolution_Enable_Command,
HCI_LE_Set_Advertising_Data_Command,
HCI_LE_Set_Advertising_Enable_Command,
HCI_LE_Set_Advertising_Parameters_Command,
@@ -129,6 +141,7 @@ from .core import (
BT_LE_TRANSPORT,
BT_PERIPHERAL_ROLE,
AdvertisingData,
ConnectionParameterUpdateError,
CommandTimeoutError,
ConnectionPHY,
InvalidStateError,
@@ -151,6 +164,9 @@ from . import sdp
from . import l2cap
from . import core
if TYPE_CHECKING:
from .transport.common import TransportSource, TransportSink
# -----------------------------------------------------------------------------
# Logging
@@ -651,7 +667,7 @@ class Connection(CompositeEventEmitter):
def is_incomplete(self) -> bool:
return self.handle is None
def send_l2cap_pdu(self, cid, pdu):
def send_l2cap_pdu(self, cid: int, pdu: bytes) -> None:
self.device.send_l2cap_pdu(self.handle, cid, pdu)
def create_l2cap_connector(self, psm):
@@ -708,6 +724,7 @@ class Connection(CompositeEventEmitter):
connection_interval_max,
max_latency,
supervision_timeout,
use_l2cap=False,
):
return await self.device.update_connection_parameters(
self,
@@ -715,6 +732,7 @@ class Connection(CompositeEventEmitter):
connection_interval_max,
max_latency,
supervision_timeout,
use_l2cap=use_l2cap,
)
async def set_phy(self, tx_phys=None, rx_phys=None, phy_options=None):
@@ -778,6 +796,7 @@ class DeviceConfiguration:
self.irk = bytes(16) # This really must be changed for any level of security
self.keystore = None
self.gatt_services: List[Dict[str, Any]] = []
self.address_resolution_offload = False
def load_from_dict(self, config: Dict[str, Any]) -> None:
# Load simple properties
@@ -940,7 +959,13 @@ class Device(CompositeEventEmitter):
pass
@classmethod
def with_hci(cls, name, address, hci_source, hci_sink):
def with_hci(
cls,
name: str,
address: Address,
hci_source: TransportSource,
hci_sink: TransportSink,
) -> Device:
'''
Create a Device instance with a Host configured to communicate with a controller
through an HCI source/sink
@@ -949,18 +974,25 @@ class Device(CompositeEventEmitter):
return cls(name=name, address=address, host=host)
@classmethod
def from_config_file(cls, filename):
def from_config_file(cls, filename: str) -> Device:
config = DeviceConfiguration()
config.load_from_file(filename)
return cls(config=config)
@classmethod
def from_config_with_hci(cls, config, hci_source, hci_sink):
def from_config_with_hci(
cls,
config: DeviceConfiguration,
hci_source: TransportSource,
hci_sink: TransportSink,
) -> Device:
host = Host(controller_source=hci_source, controller_sink=hci_sink)
return cls(config=config, host=host)
@classmethod
def from_config_file_with_hci(cls, filename, hci_source, hci_sink):
def from_config_file_with_hci(
cls, filename: str, hci_source: TransportSource, hci_sink: TransportSink
) -> Device:
config = DeviceConfiguration()
config.load_from_file(filename)
return cls.from_config_with_hci(config, hci_source, hci_sink)
@@ -1029,6 +1061,7 @@ class Device(CompositeEventEmitter):
self.discoverable = config.discoverable
self.connectable = config.connectable
self.classic_accept_any = config.classic_accept_any
self.address_resolution_offload = config.address_resolution_offload
for service in config.gatt_services:
characteristics = []
@@ -1093,7 +1126,7 @@ class Device(CompositeEventEmitter):
return self._host
@host.setter
def host(self, host):
def host(self, host: Host) -> None:
# Unsubscribe from events from the current host
if self._host:
for event_name in device_host_event_handlers:
@@ -1153,8 +1186,8 @@ class Device(CompositeEventEmitter):
def create_l2cap_registrar(self, psm):
return lambda handler: self.register_l2cap_server(psm, handler)
def register_l2cap_server(self, psm, server):
self.l2cap_channel_manager.register_server(psm, server)
def register_l2cap_server(self, psm, server) -> int:
return self.l2cap_channel_manager.register_server(psm, server)
def register_l2cap_channel_server(
self,
@@ -1180,7 +1213,7 @@ class Device(CompositeEventEmitter):
connection, psm, max_credits, mtu, mps
)
def send_l2cap_pdu(self, connection_handle, cid, pdu):
def send_l2cap_pdu(self, connection_handle: int, cid: int, pdu: bytes) -> None:
self.host.send_l2cap_pdu(connection_handle, cid, pdu)
async def send_command(self, command, check_result=False):
@@ -1256,31 +1289,16 @@ class Device(CompositeEventEmitter):
)
# Load the address resolving list
if self.keystore and self.host.supports_command(
HCI_LE_CLEAR_RESOLVING_LIST_COMMAND
):
await self.send_command(HCI_LE_Clear_Resolving_List_Command()) # type: ignore[call-arg]
if self.keystore:
await self.refresh_resolving_list()
resolving_keys = await self.keystore.get_resolving_keys()
for irk, address in resolving_keys:
await self.send_command(
HCI_LE_Add_Device_To_Resolving_List_Command(
peer_identity_address_type=address.address_type,
peer_identity_address=address,
peer_irk=irk,
local_irk=self.irk,
) # type: ignore[call-arg]
)
# Enable address resolution
# await self.send_command(
# HCI_LE_Set_Address_Resolution_Enable_Command(
# address_resolution_enable=1)
# )
# )
# Create a host-side address resolver
self.address_resolver = smp.AddressResolver(resolving_keys)
# Enable address resolution
if self.address_resolution_offload:
await self.send_command(
HCI_LE_Set_Address_Resolution_Enable_Command(
address_resolution_enable=1
) # type: ignore[call-arg]
)
if self.classic_enabled:
await self.send_command(
@@ -1310,6 +1328,26 @@ class Device(CompositeEventEmitter):
await self.host.flush()
self.powered_on = False
async def refresh_resolving_list(self) -> None:
assert self.keystore is not None
resolving_keys = await self.keystore.get_resolving_keys()
# Create a host-side address resolver
self.address_resolver = smp.AddressResolver(resolving_keys)
if self.address_resolution_offload:
await self.send_command(HCI_LE_Clear_Resolving_List_Command()) # type: ignore[call-arg]
for irk, address in resolving_keys:
await self.send_command(
HCI_LE_Add_Device_To_Resolving_List_Command(
peer_identity_address_type=address.address_type,
peer_identity_address=address,
peer_irk=irk,
local_irk=self.irk,
) # type: ignore[call-arg]
)
def supports_le_feature(self, feature):
return self.host.supports_le_feature(feature)
@@ -2075,11 +2113,30 @@ class Device(CompositeEventEmitter):
supervision_timeout,
min_ce_length=0,
max_ce_length=0,
):
use_l2cap=False,
) -> None:
'''
NOTE: the name of the parameters may look odd, but it just follows the names
used in the Bluetooth spec.
'''
if use_l2cap:
if connection.role != BT_PERIPHERAL_ROLE:
raise InvalidStateError(
'only peripheral can update connection parameters with l2cap'
)
l2cap_result = (
await self.l2cap_channel_manager.update_connection_parameters(
connection,
connection_interval_min,
connection_interval_max,
max_latency,
supervision_timeout,
)
)
if l2cap_result != l2cap.L2CAP_CONNECTION_PARAMETERS_ACCEPTED_RESULT:
raise ConnectionParameterUpdateError(l2cap_result)
result = await self.send_command(
HCI_LE_Connection_Update_Command(
connection_handle=connection.handle,
@@ -2089,7 +2146,7 @@ class Device(CompositeEventEmitter):
supervision_timeout=supervision_timeout,
min_ce_length=min_ce_length,
max_ce_length=max_ce_length,
)
) # type: ignore[call-arg]
)
if result.status != HCI_Command_Status_Event.PENDING:
raise HCI_StatusError(result)
@@ -2230,9 +2287,11 @@ class Device(CompositeEventEmitter):
def request_pairing(self, connection):
return self.smp_manager.request_pairing(connection)
async def get_long_term_key(self, connection_handle, rand, ediv):
async def get_long_term_key(
self, connection_handle: int, rand: bytes, ediv: int
) -> Optional[bytes]:
if (connection := self.lookup_connection(connection_handle)) is None:
return
return None
# Start by looking for the key in an SMP session
ltk = self.smp_manager.get_long_term_key(connection, rand, ediv)
@@ -2252,19 +2311,24 @@ class Device(CompositeEventEmitter):
if connection.role == BT_PERIPHERAL_ROLE and keys.ltk_peripheral:
return keys.ltk_peripheral.value
return None
async def get_link_key(self, address: Address) -> Optional[bytes]:
# Look for the key in the keystore
if self.keystore is not None:
keys = await self.keystore.get(str(address))
if keys is not None:
logger.debug('found keys in the key store')
if keys.link_key is None:
logger.warning('no link key')
return None
if self.keystore is None:
return None
return keys.link_key.value
return None
# Look for the key in the keystore
keys = await self.keystore.get(str(address))
if keys is None:
logger.debug(f'no keys found for {address}')
return None
logger.debug('found keys in the key store')
if keys.link_key is None:
logger.warning('no link key')
return None
return keys.link_key.value
# [Classic only]
async def authenticate(self, connection):
@@ -2383,6 +2447,18 @@ class Device(CompositeEventEmitter):
'connection_encryption_failure', on_encryption_failure
)
async def update_keys(self, address: str, keys: PairingKeys) -> None:
if self.keystore is None:
return
try:
await self.keystore.update(address, keys)
await self.refresh_resolving_list()
except Exception as error:
logger.warning(f'!!! error while storing keys: {error}')
else:
self.emit('key_store_update')
# [Classic only]
async def switch_role(self, connection: Connection, role: int):
pending_role_change = asyncio.get_running_loop().create_future()
@@ -2477,13 +2553,7 @@ class Device(CompositeEventEmitter):
value=link_key, authenticated=authenticated
)
async def store_keys():
try:
await self.keystore.update(str(bd_addr), pairing_keys)
except Exception as error:
logger.warning(f'!!! error while storing keys: {error}')
self.abort_on('flush', store_keys())
self.abort_on('flush', self.update_keys(str(bd_addr), pairing_keys))
if connection := self.find_connection_by_bd_addr(
bd_addr, transport=BT_BR_EDR_TRANSPORT
@@ -2688,7 +2758,9 @@ class Device(CompositeEventEmitter):
self.abort_on(
'flush',
self.start_advertising(
advertising_type=self.advertising_type, auto_restart=True
advertising_type=self.advertising_type,
own_address_type=self.advertising_own_address_type,
auto_restart=True,
),
)
@@ -2735,20 +2807,6 @@ class Device(CompositeEventEmitter):
)
connection.emit('connection_authentication_failure', error)
@host_event_handler
@with_connection_from_address
def on_ssp_complete(self, connection):
# On Secure Simple Pairing complete, in case:
# - Connection isn't already authenticated
# - AND we are not the initiator of the authentication
# We must trigger authentication to know if we are truly authenticated
if not connection.authenticating and not connection.authenticated:
logger.debug(
f'*** Trigger Connection Authentication: [0x{connection.handle:04X}] '
f'{connection.peer_address}'
)
asyncio.create_task(connection.authenticate())
# [Classic only]
@host_event_handler
@with_connection_from_address
@@ -2851,18 +2909,22 @@ class Device(CompositeEventEmitter):
method = methods[peer_io_capability][io_capability]
async def reply() -> None:
if await connection.abort_on('disconnection', method()):
await self.host.send_command(
HCI_User_Confirmation_Request_Reply_Command( # type: ignore[call-arg]
bd_addr=connection.peer_address
)
)
else:
await self.host.send_command(
HCI_User_Confirmation_Request_Negative_Reply_Command( # type: ignore[call-arg]
bd_addr=connection.peer_address
try:
if await connection.abort_on('disconnection', method()):
await self.host.send_command(
HCI_User_Confirmation_Request_Reply_Command( # type: ignore[call-arg]
bd_addr=connection.peer_address
)
)
return
except Exception as error:
logger.warning(f'exception while confirming: {error}')
await self.host.send_command(
HCI_User_Confirmation_Request_Negative_Reply_Command( # type: ignore[call-arg]
bd_addr=connection.peer_address
)
)
AsyncRunner.spawn(reply())
@@ -2874,21 +2936,25 @@ class Device(CompositeEventEmitter):
pairing_config = self.pairing_config_factory(connection)
async def reply() -> None:
number = await connection.abort_on(
'disconnection', pairing_config.delegate.get_number()
try:
number = await connection.abort_on(
'disconnection', pairing_config.delegate.get_number()
)
if number is not None:
await self.host.send_command(
HCI_User_Passkey_Request_Reply_Command( # type: ignore[call-arg]
bd_addr=connection.peer_address, numeric_value=number
)
)
return
except Exception as error:
logger.warning(f'exception while asking for pass-key: {error}')
await self.host.send_command(
HCI_User_Passkey_Request_Negative_Reply_Command( # type: ignore[call-arg]
bd_addr=connection.peer_address
)
)
if number is not None:
await self.host.send_command(
HCI_User_Passkey_Request_Reply_Command( # type: ignore[call-arg]
bd_addr=connection.peer_address, numeric_value=number
)
)
else:
await self.host.send_command(
HCI_User_Passkey_Request_Negative_Reply_Command( # type: ignore[call-arg]
bd_addr=connection.peer_address
)
)
AsyncRunner.spawn(reply())
@@ -3095,6 +3161,18 @@ class Device(CompositeEventEmitter):
connection.emit('role_change_failure', error)
self.emit('role_change_failure', address, error)
# [Classic only]
@host_event_handler
@with_connection_from_address
def on_classic_pairing(self, connection: Connection) -> None:
connection.emit('classic_pairing')
# [Classic only]
@host_event_handler
@with_connection_from_address
def on_classic_pairing_failure(self, connection: Connection, status) -> None:
connection.emit('classic_pairing_failure', status)
def on_pairing_start(self, connection: Connection) -> None:
connection.emit('pairing_start')
@@ -3143,7 +3221,7 @@ class Device(CompositeEventEmitter):
@host_event_handler
@with_connection_from_handle
def on_l2cap_pdu(self, connection, cid, pdu):
def on_l2cap_pdu(self, connection: Connection, cid: int, pdu: bytes):
self.l2cap_channel_manager.on_pdu(connection, cid, pdu)
def __str__(self):

View File

@@ -0,0 +1,91 @@
# 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.
"""
Drivers that can be used to customize the interaction between a host and a controller,
like loading firmware after a cold start.
"""
# -----------------------------------------------------------------------------
# Imports
# -----------------------------------------------------------------------------
import abc
import logging
import pathlib
import platform
from . import rtk
# -----------------------------------------------------------------------------
# Logging
# -----------------------------------------------------------------------------
logger = logging.getLogger(__name__)
# -----------------------------------------------------------------------------
# Classes
# -----------------------------------------------------------------------------
class Driver(abc.ABC):
"""Base class for drivers."""
@staticmethod
async def for_host(_host):
"""Return a driver instance for a host.
Args:
host: Host object for which a driver should be created.
Returns:
A Driver instance if a driver should be instantiated for this host, or
None if no driver instance of this class is needed.
"""
return None
@abc.abstractmethod
async def init_controller(self):
"""Initialize the controller."""
# -----------------------------------------------------------------------------
# Functions
# -----------------------------------------------------------------------------
async def get_driver_for_host(host):
"""Probe all known diver classes until one returns a valid instance for a host,
or none is found.
"""
if driver := await rtk.Driver.for_host(host):
logger.debug("Instantiated RTK driver")
return driver
return None
def project_data_dir() -> pathlib.Path:
"""
Returns:
A path to an OS-specific directory for bumble data. The directory is created if
it doesn't exist.
"""
import platformdirs
if platform.system() == 'Darwin':
# platformdirs doesn't handle macOS right: it doesn't assemble a bundle id
# out of author & project
return platformdirs.user_data_path(
appname='com.google.bumble', ensure_exists=True
)
else:
# windows and linux don't use the com qualifier
return platformdirs.user_data_path(
appname='bumble', appauthor='google', ensure_exists=True
)

659
bumble/drivers/rtk.py Normal file
View File

@@ -0,0 +1,659 @@
# 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.
"""
Support for Realtek USB dongles.
Based on various online bits of information, including the Linux kernel.
(see `drivers/bluetooth/btrtl.c`)
"""
# -----------------------------------------------------------------------------
# Imports
# -----------------------------------------------------------------------------
from dataclasses import dataclass
import asyncio
import enum
import logging
import math
import os
import pathlib
import platform
import struct
from typing import Tuple
import weakref
from bumble.hci import (
hci_vendor_command_op_code,
STATUS_SPEC,
HCI_SUCCESS,
HCI_Command,
HCI_Reset_Command,
HCI_Read_Local_Version_Information_Command,
)
# -----------------------------------------------------------------------------
# Logging
# -----------------------------------------------------------------------------
logger = logging.getLogger(__name__)
# -----------------------------------------------------------------------------
# Constants
# -----------------------------------------------------------------------------
RTK_ROM_LMP_8723A = 0x1200
RTK_ROM_LMP_8723B = 0x8723
RTK_ROM_LMP_8821A = 0x8821
RTK_ROM_LMP_8761A = 0x8761
RTK_ROM_LMP_8822B = 0x8822
RTK_ROM_LMP_8852A = 0x8852
RTK_CONFIG_MAGIC = 0x8723AB55
RTK_EPATCH_SIGNATURE = b"Realtech"
RTK_FRAGMENT_LENGTH = 252
RTK_FIRMWARE_DIR_ENV = "BUMBLE_RTK_FIRMWARE_DIR"
RTK_LINUX_FIRMWARE_DIR = "/lib/firmware/rtl_bt"
class RtlProjectId(enum.IntEnum):
PROJECT_ID_8723A = 0
PROJECT_ID_8723B = 1
PROJECT_ID_8821A = 2
PROJECT_ID_8761A = 3
PROJECT_ID_8822B = 8
PROJECT_ID_8723D = 9
PROJECT_ID_8821C = 10
PROJECT_ID_8822C = 13
PROJECT_ID_8761B = 14
PROJECT_ID_8852A = 18
PROJECT_ID_8852B = 20
PROJECT_ID_8852C = 25
RTK_PROJECT_ID_TO_ROM = {
0: RTK_ROM_LMP_8723A,
1: RTK_ROM_LMP_8723B,
2: RTK_ROM_LMP_8821A,
3: RTK_ROM_LMP_8761A,
8: RTK_ROM_LMP_8822B,
9: RTK_ROM_LMP_8723B,
10: RTK_ROM_LMP_8821A,
13: RTK_ROM_LMP_8822B,
14: RTK_ROM_LMP_8761A,
18: RTK_ROM_LMP_8852A,
20: RTK_ROM_LMP_8852A,
25: RTK_ROM_LMP_8852A,
}
# List of USB (VendorID, ProductID) for Realtek-based devices.
RTK_USB_PRODUCTS = {
# Realtek 8723AE
(0x0930, 0x021D),
(0x13D3, 0x3394),
# Realtek 8723BE
(0x0489, 0xE085),
(0x0489, 0xE08B),
(0x04F2, 0xB49F),
(0x13D3, 0x3410),
(0x13D3, 0x3416),
(0x13D3, 0x3459),
(0x13D3, 0x3494),
# Realtek 8723BU
(0x7392, 0xA611),
# Realtek 8723DE
(0x0BDA, 0xB009),
(0x2FF8, 0xB011),
# Realtek 8761BUV
(0x0B05, 0x190E),
(0x0BDA, 0x8771),
(0x2230, 0x0016),
(0x2357, 0x0604),
(0x2550, 0x8761),
(0x2B89, 0x8761),
(0x7392, 0xC611),
(0x0BDA, 0x877B),
# Realtek 8821AE
(0x0B05, 0x17DC),
(0x13D3, 0x3414),
(0x13D3, 0x3458),
(0x13D3, 0x3461),
(0x13D3, 0x3462),
# Realtek 8821CE
(0x0BDA, 0xB00C),
(0x0BDA, 0xC822),
(0x13D3, 0x3529),
# Realtek 8822BE
(0x0B05, 0x185C),
(0x13D3, 0x3526),
# Realtek 8822CE
(0x04C5, 0x161F),
(0x04CA, 0x4005),
(0x0B05, 0x18EF),
(0x0BDA, 0xB00C),
(0x0BDA, 0xC123),
(0x0BDA, 0xC822),
(0x0CB5, 0xC547),
(0x1358, 0xC123),
(0x13D3, 0x3548),
(0x13D3, 0x3549),
(0x13D3, 0x3553),
(0x13D3, 0x3555),
(0x2FF8, 0x3051),
# Realtek 8822CU
(0x13D3, 0x3549),
# Realtek 8852AE
(0x04C5, 0x165C),
(0x04CA, 0x4006),
(0x0BDA, 0x2852),
(0x0BDA, 0x385A),
(0x0BDA, 0x4852),
(0x0BDA, 0xC852),
(0x0CB8, 0xC549),
# Realtek 8852BE
(0x0BDA, 0x887B),
(0x0CB8, 0xC559),
(0x13D3, 0x3571),
# Realtek 8852CE
(0x04C5, 0x1675),
(0x04CA, 0x4007),
(0x0CB8, 0xC558),
(0x13D3, 0x3586),
(0x13D3, 0x3587),
(0x13D3, 0x3592),
}
# -----------------------------------------------------------------------------
# HCI Commands
# -----------------------------------------------------------------------------
HCI_RTK_READ_ROM_VERSION_COMMAND = hci_vendor_command_op_code(0x6D)
HCI_RTK_DOWNLOAD_COMMAND = hci_vendor_command_op_code(0x20)
HCI_RTK_DROP_FIRMWARE_COMMAND = hci_vendor_command_op_code(0x66)
HCI_Command.register_commands(globals())
@HCI_Command.command(return_parameters_fields=[("status", STATUS_SPEC), ("version", 1)])
class HCI_RTK_Read_ROM_Version_Command(HCI_Command):
pass
@HCI_Command.command(
fields=[("index", 1), ("payload", RTK_FRAGMENT_LENGTH)],
return_parameters_fields=[("status", STATUS_SPEC), ("index", 1)],
)
class HCI_RTK_Download_Command(HCI_Command):
pass
@HCI_Command.command()
class HCI_RTK_Drop_Firmware_Command(HCI_Command):
pass
# -----------------------------------------------------------------------------
class Firmware:
def __init__(self, firmware):
extension_sig = bytes([0x51, 0x04, 0xFD, 0x77])
if not firmware.startswith(RTK_EPATCH_SIGNATURE):
raise ValueError("Firmware does not start with epatch signature")
if not firmware.endswith(extension_sig):
raise ValueError("Firmware does not end with extension sig")
# The firmware should start with a 14 byte header.
epatch_header_size = 14
if len(firmware) < epatch_header_size:
raise ValueError("Firmware too short")
# Look for the "project ID", starting from the end.
offset = len(firmware) - len(extension_sig)
project_id = -1
while offset >= epatch_header_size:
length, opcode = firmware[offset - 2 : offset]
offset -= 2
if opcode == 0xFF:
# End
break
if length == 0:
raise ValueError("Invalid 0-length instruction")
if opcode == 0 and length == 1:
project_id = firmware[offset - 1]
break
offset -= length
if project_id < 0:
raise ValueError("Project ID not found")
self.project_id = project_id
# Read the patch tables info.
self.version, num_patches = struct.unpack("<IH", firmware[8:14])
self.patches = []
# The patches tables are laid out as:
# <ChipID_1><ChipID_2>...<ChipID_N> (16 bits each)
# <PatchLength_1><PatchLength_2>...<PatchLength_N> (16 bits each)
# <PatchOffset_1><PatchOffset_2>...<PatchOffset_N> (32 bits each)
if epatch_header_size + 8 * num_patches > len(firmware):
raise ValueError("Firmware too short")
chip_id_table_offset = epatch_header_size
patch_length_table_offset = chip_id_table_offset + 2 * num_patches
patch_offset_table_offset = chip_id_table_offset + 4 * num_patches
for patch_index in range(num_patches):
chip_id_offset = chip_id_table_offset + 2 * patch_index
(chip_id,) = struct.unpack_from("<H", firmware, chip_id_offset)
(patch_length,) = struct.unpack_from(
"<H", firmware, patch_length_table_offset + 2 * patch_index
)
(patch_offset,) = struct.unpack_from(
"<I", firmware, patch_offset_table_offset + 4 * patch_index
)
if patch_offset + patch_length > len(firmware):
raise ValueError("Firmware too short")
# Get the SVN version for the patch
(svn_version,) = struct.unpack_from(
"<I", firmware, patch_offset + patch_length - 8
)
# Create a payload with the patch, replacing the last 4 bytes with
# the firmware version.
self.patches.append(
(
chip_id,
firmware[patch_offset : patch_offset + patch_length - 4]
+ struct.pack("<I", self.version),
svn_version,
)
)
class Driver:
@dataclass
class DriverInfo:
rom: int
hci: Tuple[int, int]
config_needed: bool
has_rom_version: bool
has_msft_ext: bool = False
fw_name: str = ""
config_name: str = ""
DRIVER_INFOS = [
# 8723A
DriverInfo(
rom=RTK_ROM_LMP_8723A,
hci=(0x0B, 0x06),
config_needed=False,
has_rom_version=False,
fw_name="rtl8723a_fw.bin",
config_name="",
),
# 8723B
DriverInfo(
rom=RTK_ROM_LMP_8723B,
hci=(0x0B, 0x06),
config_needed=False,
has_rom_version=True,
fw_name="rtl8723b_fw.bin",
config_name="rtl8723b_config.bin",
),
# 8723D
DriverInfo(
rom=RTK_ROM_LMP_8723B,
hci=(0x0D, 0x08),
config_needed=True,
has_rom_version=True,
fw_name="rtl8723d_fw.bin",
config_name="rtl8723d_config.bin",
),
# 8821A
DriverInfo(
rom=RTK_ROM_LMP_8821A,
hci=(0x0A, 0x06),
config_needed=False,
has_rom_version=True,
fw_name="rtl8821a_fw.bin",
config_name="rtl8821a_config.bin",
),
# 8821C
DriverInfo(
rom=RTK_ROM_LMP_8821A,
hci=(0x0C, 0x08),
config_needed=False,
has_rom_version=True,
has_msft_ext=True,
fw_name="rtl8821c_fw.bin",
config_name="rtl8821c_config.bin",
),
# 8761A
DriverInfo(
rom=RTK_ROM_LMP_8761A,
hci=(0x0A, 0x06),
config_needed=False,
has_rom_version=True,
fw_name="rtl8761a_fw.bin",
config_name="rtl8761a_config.bin",
),
# 8761BU
DriverInfo(
rom=RTK_ROM_LMP_8761A,
hci=(0x0B, 0x0A),
config_needed=False,
has_rom_version=True,
fw_name="rtl8761bu_fw.bin",
config_name="rtl8761bu_config.bin",
),
# 8822C
DriverInfo(
rom=RTK_ROM_LMP_8822B,
hci=(0x0C, 0x0A),
config_needed=False,
has_rom_version=True,
has_msft_ext=True,
fw_name="rtl8822cu_fw.bin",
config_name="rtl8822cu_config.bin",
),
# 8822B
DriverInfo(
rom=RTK_ROM_LMP_8822B,
hci=(0x0B, 0x07),
config_needed=True,
has_rom_version=True,
has_msft_ext=True,
fw_name="rtl8822b_fw.bin",
config_name="rtl8822b_config.bin",
),
# 8852A
DriverInfo(
rom=RTK_ROM_LMP_8852A,
hci=(0x0A, 0x0B),
config_needed=False,
has_rom_version=True,
has_msft_ext=True,
fw_name="rtl8852au_fw.bin",
config_name="rtl8852au_config.bin",
),
# 8852B
DriverInfo(
rom=RTK_ROM_LMP_8852A,
hci=(0xB, 0xB),
config_needed=False,
has_rom_version=True,
has_msft_ext=True,
fw_name="rtl8852bu_fw.bin",
config_name="rtl8852bu_config.bin",
),
# 8852C
DriverInfo(
rom=RTK_ROM_LMP_8852A,
hci=(0x0C, 0x0C),
config_needed=False,
has_rom_version=True,
has_msft_ext=True,
fw_name="rtl8852cu_fw.bin",
config_name="rtl8852cu_config.bin",
),
]
POST_DROP_DELAY = 0.2
@staticmethod
def find_driver_info(hci_version, hci_subversion, lmp_subversion):
for driver_info in Driver.DRIVER_INFOS:
if driver_info.rom == lmp_subversion and driver_info.hci == (
hci_subversion,
hci_version,
):
return driver_info
return None
@staticmethod
def find_binary_path(file_name):
# First check if an environment variable is set
if RTK_FIRMWARE_DIR_ENV in os.environ:
if (
path := pathlib.Path(os.environ[RTK_FIRMWARE_DIR_ENV]) / file_name
).is_file():
logger.debug(f"{file_name} found in env dir")
return path
# When the environment variable is set, don't look elsewhere
return None
# Then, look where the firmware download tool writes by default
if (path := rtk_firmware_dir() / file_name).is_file():
logger.debug(f"{file_name} found in project data dir")
return path
# Then, look in the package's driver directory
if (path := pathlib.Path(__file__).parent / "rtk_fw" / file_name).is_file():
logger.debug(f"{file_name} found in package dir")
return path
# On Linux, check the system's FW directory
if (
platform.system() == "Linux"
and (path := pathlib.Path(RTK_LINUX_FIRMWARE_DIR) / file_name).is_file()
):
logger.debug(f"{file_name} found in Linux system FW dir")
return path
# Finally look in the current directory
if (path := pathlib.Path.cwd() / file_name).is_file():
logger.debug(f"{file_name} found in CWD")
return path
return None
@staticmethod
def check(host):
if not host.hci_metadata:
logger.debug("USB metadata not found")
return False
vendor_id = host.hci_metadata.get("vendor_id", None)
product_id = host.hci_metadata.get("product_id", None)
if vendor_id is None or product_id is None:
logger.debug("USB metadata not sufficient")
return False
if (vendor_id, product_id) not in RTK_USB_PRODUCTS:
logger.debug(
f"USB device ({vendor_id:04X}, {product_id:04X}) " "not in known list"
)
return False
return True
@classmethod
async def driver_info_for_host(cls, host):
response = await host.send_command(
HCI_Read_Local_Version_Information_Command(), check_result=True
)
local_version = response.return_parameters
logger.debug(
f"looking for a driver: 0x{local_version.lmp_subversion:04X} "
f"(0x{local_version.hci_version:02X}, "
f"0x{local_version.hci_subversion:04X})"
)
driver_info = cls.find_driver_info(
local_version.hci_version,
local_version.hci_subversion,
local_version.lmp_subversion,
)
if driver_info is None:
# TODO: it seems that the Linux driver will send command (0x3f, 0x66)
# in this case and then re-read the local version, then re-match.
logger.debug("firmware already loaded or no known driver for this device")
return driver_info
@classmethod
async def for_host(cls, host, force=False):
# Check that a driver is needed for this host
if not force and not cls.check(host):
return None
# Get the driver info
driver_info = await cls.driver_info_for_host(host)
if driver_info is None:
return None
# Load the firmware
firmware_path = cls.find_binary_path(driver_info.fw_name)
if not firmware_path:
logger.warning(f"Firmware file {driver_info.fw_name} not found")
logger.warning("See https://google.github.io/bumble/drivers/realtek.html")
return None
with open(firmware_path, "rb") as firmware_file:
firmware = firmware_file.read()
# Load the config
config = None
if driver_info.config_name:
config_path = cls.find_binary_path(driver_info.config_name)
if config_path:
with open(config_path, "rb") as config_file:
config = config_file.read()
if driver_info.config_needed and not config:
logger.warning("Config needed, but no config file available")
return None
return cls(host, driver_info, firmware, config)
def __init__(self, host, driver_info, firmware, config):
self.host = weakref.proxy(host)
self.driver_info = driver_info
self.firmware = firmware
self.config = config
@staticmethod
async def drop_firmware(host):
host.send_hci_packet(HCI_RTK_Drop_Firmware_Command())
# Wait for the command to be effective (no response is sent)
await asyncio.sleep(Driver.POST_DROP_DELAY)
async def download_for_rtl8723a(self):
# Check that the firmware image does not include an epatch signature.
if RTK_EPATCH_SIGNATURE in self.firmware:
logger.warning(
"epatch signature found in firmware, it is probably the wrong firmware"
)
return
# TODO: load the firmware
async def download_for_rtl8723b(self):
if self.driver_info.has_rom_version:
response = await self.host.send_command(
HCI_RTK_Read_ROM_Version_Command(), check_result=True
)
if response.return_parameters.status != HCI_SUCCESS:
logger.warning("can't get ROM version")
return
rom_version = response.return_parameters.version
logger.debug(f"ROM version before download: {rom_version:04X}")
else:
rom_version = 0
firmware = Firmware(self.firmware)
logger.debug(f"firmware: project_id=0x{firmware.project_id:04X}")
for patch in firmware.patches:
if patch[0] == rom_version + 1:
logger.debug(f"using patch {patch[0]}")
break
else:
logger.warning("no valid patch found for rom version {rom_version}")
return
# Append the config if there is one.
if self.config:
payload = patch[1] + self.config
else:
payload = patch[1]
# Download the payload, one fragment at a time.
fragment_count = math.ceil(len(payload) / RTK_FRAGMENT_LENGTH)
for fragment_index in range(fragment_count):
# NOTE: the Linux driver somehow adds 1 to the index after it wraps around.
# That's odd, but we"ll do the same here.
download_index = fragment_index & 0x7F
if download_index >= 0x80:
download_index += 1
if fragment_index == fragment_count - 1:
download_index |= 0x80 # End marker.
fragment_offset = fragment_index * RTK_FRAGMENT_LENGTH
fragment = payload[fragment_offset : fragment_offset + RTK_FRAGMENT_LENGTH]
logger.debug(f"downloading fragment {fragment_index}")
await self.host.send_command(
HCI_RTK_Download_Command(
index=download_index, payload=fragment, check_result=True
)
)
logger.debug("download complete!")
# Read the version again
response = await self.host.send_command(
HCI_RTK_Read_ROM_Version_Command(), check_result=True
)
if response.return_parameters.status != HCI_SUCCESS:
logger.warning("can't get ROM version")
else:
rom_version = response.return_parameters.version
logger.debug(f"ROM version after download: {rom_version:04X}")
async def download_firmware(self):
if self.driver_info.rom == RTK_ROM_LMP_8723A:
return await self.download_for_rtl8723a()
if self.driver_info.rom in (
RTK_ROM_LMP_8723B,
RTK_ROM_LMP_8821A,
RTK_ROM_LMP_8761A,
RTK_ROM_LMP_8822B,
RTK_ROM_LMP_8852A,
):
return await self.download_for_rtl8723b()
raise ValueError("ROM not supported")
async def init_controller(self):
await self.download_firmware()
await self.host.send_command(HCI_Reset_Command(), check_result=True)
logger.info(f"loaded FW image {self.driver_info.fw_name}")
def rtk_firmware_dir() -> pathlib.Path:
"""
Returns:
A path to a subdir of the project data dir for Realtek firmware.
The directory is created if it doesn't exist.
"""
from bumble.drivers import project_data_dir
p = project_data_dir() / "firmware" / "realtek"
p.mkdir(parents=True, exist_ok=True)
return p

View File

@@ -28,7 +28,7 @@ import enum
import functools
import logging
import struct
from typing import Optional, Sequence, List
from typing import Optional, Sequence, Iterable, List, Union
from .colors import color
from .core import UUID, get_dict_key_by_value
@@ -187,7 +187,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:
print(color(str(service), 'cyan'))
@@ -210,11 +210,11 @@ class Service(Attribute):
def __init__(
self,
uuid,
uuid: Union[str, UUID],
characteristics: List[Characteristic],
primary=True,
included_services: List[Service] = [],
):
) -> None:
# Convert the uuid to a UUID object if it isn't already
if isinstance(uuid, str):
uuid = UUID(uuid)
@@ -239,7 +239,7 @@ class Service(Attribute):
"""
return None
def __str__(self):
def __str__(self) -> str:
return (
f'Service(handle=0x{self.handle:04X}, '
f'end=0x{self.end_group_handle:04X}, '
@@ -255,9 +255,11 @@ class TemplateService(Service):
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)
@@ -269,7 +271,7 @@ class IncludedServiceDeclaration(Attribute):
service: Service
def __init__(self, service):
def __init__(self, service: Service) -> None:
declaration_bytes = struct.pack(
'<HH2s', service.handle, service.end_group_handle, service.uuid.to_bytes()
)
@@ -278,13 +280,12 @@ class IncludedServiceDeclaration(Attribute):
)
self.service = service
def __str__(self):
def __str__(self) -> str:
return (
f'IncludedServiceDefinition(handle=0x{self.handle:04X}, '
f'group_starting_handle=0x{self.service.handle:04X}, '
f'group_ending_handle=0x{self.service.end_group_handle:04X}, '
f'uuid={self.service.uuid}, '
f'{self.service.properties!s})'
f'uuid={self.service.uuid})'
)
@@ -309,31 +310,33 @@ class Characteristic(Attribute):
AUTHENTICATED_SIGNED_WRITES = 0x40
EXTENDED_PROPERTIES = 0x80
@staticmethod
def from_string(properties_str: str) -> Characteristic.Properties:
property_names: List[str] = []
for property in Characteristic.Properties:
if property.name is None:
raise TypeError()
property_names.append(property.name)
def string_to_property(property_string) -> Characteristic.Properties:
for property in zip(Characteristic.Properties, property_names):
if property_string == property[1]:
return property[0]
raise TypeError(f"Unable to convert {property_string} to Property")
@classmethod
def from_string(cls, properties_str: str) -> Characteristic.Properties:
try:
return functools.reduce(
lambda x, y: x | string_to_property(y),
properties_str.split(","),
lambda x, y: x | cls[y],
properties_str.replace("|", ",").split(","),
Characteristic.Properties(0),
)
except TypeError:
except (TypeError, KeyError):
# 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"Characteristic.Properties::from_string() error:\nExpected a string containing any of the keys, separated by commas: {','.join(property_names)}\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) -> str:
# NOTE: we override this method to offer a consistent result between python
# versions: the value returned by IntFlag.__str__() changed in version 11.
return '|'.join(
flag.name
for flag in Characteristic.Properties
if self.value & flag.value and flag.name is not None
)
# For backwards compatibility these are defined here
# For new code, please use Characteristic.Properties.X
BROADCAST = Properties.BROADCAST
@@ -347,10 +350,10 @@ class Characteristic(Attribute):
def __init__(
self,
uuid,
uuid: Union[str, bytes, UUID],
properties: Characteristic.Properties,
permissions,
value=b'',
permissions: Union[str, Attribute.Permissions],
value: Union[str, bytes, CharacteristicValue] = b'',
descriptors: Sequence[Descriptor] = (),
):
super().__init__(uuid, permissions, value)
@@ -368,12 +371,12 @@ class Characteristic(Attribute):
def has_properties(self, properties: Characteristic.Properties) -> bool:
return self.properties & properties == properties
def __str__(self):
def __str__(self) -> str:
return (
f'Characteristic(handle=0x{self.handle:04X}, '
f'end=0x{self.end_group_handle:04X}, '
f'uuid={self.uuid}, '
f'{self.properties!s})'
f'{self.properties})'
)
@@ -385,7 +388,7 @@ class CharacteristicDeclaration(Attribute):
characteristic: Characteristic
def __init__(self, characteristic, value_handle):
def __init__(self, characteristic: Characteristic, value_handle: int) -> None:
declaration_bytes = (
struct.pack('<BH', characteristic.properties, value_handle)
+ characteristic.uuid.to_pdu_bytes()
@@ -396,12 +399,12 @@ class CharacteristicDeclaration(Attribute):
self.value_handle = value_handle
self.characteristic = characteristic
def __str__(self):
def __str__(self) -> str:
return (
f'CharacteristicDeclaration(handle=0x{self.handle:04X}, '
f'value_handle=0x{self.value_handle:04X}, '
f'uuid={self.characteristic.uuid}, '
f'{self.characteristic.properties!s})'
f'{self.characteristic.properties})'
)
@@ -519,7 +522,7 @@ class CharacteristicAdapter:
return self.wrapped_characteristic.unsubscribe(subscriber)
def __str__(self):
def __str__(self) -> str:
wrapped = str(self.wrapped_characteristic)
return f'{self.__class__.__name__}({wrapped})'
@@ -599,10 +602,10 @@ class UTF8CharacteristicAdapter(CharacteristicAdapter):
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')
def decode_value(self, value):
def decode_value(self, value: bytes) -> str:
return value.decode('utf-8')
@@ -612,7 +615,7 @@ class Descriptor(Attribute):
See Vol 3, Part G - 3.3.3 Characteristic Descriptor Declarations
'''
def __str__(self):
def __str__(self) -> str:
return (
f'Descriptor(handle=0x{self.handle:04X}, '
f'type={self.type}, '

View File

@@ -28,7 +28,18 @@ import asyncio
import logging
import struct
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,
TYPE_CHECKING,
)
from pyee import EventEmitter
@@ -66,8 +77,12 @@ from .gatt import (
GATT_INCLUDE_ATTRIBUTE_TYPE,
Characteristic,
ClientCharacteristicConfigurationBits,
TemplateService,
)
if TYPE_CHECKING:
from bumble.device import Connection
# -----------------------------------------------------------------------------
# Logging
# -----------------------------------------------------------------------------
@@ -78,16 +93,16 @@ logger = logging.getLogger(__name__)
# Proxies
# -----------------------------------------------------------------------------
class AttributeProxy(EventEmitter):
client: Client
def __init__(self, client, handle, end_group_handle, attribute_type):
def __init__(
self, client: Client, handle: int, end_group_handle: int, attribute_type: UUID
) -> None:
EventEmitter.__init__(self)
self.client = client
self.handle = handle
self.end_group_handle = end_group_handle
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(
await self.client.read_value(self.handle, no_long_read)
)
@@ -97,13 +112,13 @@ class AttributeProxy(EventEmitter):
self.handle, self.encode_value(value), with_response
)
def encode_value(self, value):
def encode_value(self, value: Any) -> bytes:
return value
def decode_value(self, value_bytes):
def decode_value(self, value_bytes: bytes) -> Any:
return value_bytes
def __str__(self):
def __str__(self) -> str:
return f'Attribute(handle=0x{self.handle:04X}, type={self.type})'
@@ -136,14 +151,14 @@ class ServiceProxy(AttributeProxy):
def get_characteristics_by_uuid(self, uuid):
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})'
class CharacteristicProxy(AttributeProxy):
properties: Characteristic.Properties
descriptors: List[DescriptorProxy]
subscribers: Dict[Any, Callable]
subscribers: Dict[Any, Callable[[bytes], Any]]
def __init__(
self,
@@ -171,7 +186,9 @@ class CharacteristicProxy(AttributeProxy):
return await self.client.discover_descriptors(self)
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 in self.subscribers:
@@ -195,7 +212,7 @@ class CharacteristicProxy(AttributeProxy):
return await self.client.unsubscribe(self, subscriber)
def __str__(self):
def __str__(self) -> str:
return (
f'Characteristic(handle=0x{self.handle:04X}, '
f'uuid={self.uuid}, '
@@ -207,7 +224,7 @@ class DescriptorProxy(AttributeProxy):
def __init__(self, client, handle, 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})'
@@ -216,8 +233,10 @@ class ProfileServiceProxy:
Base class for profile-specific service proxies
'''
SERVICE_CLASS: Type[TemplateService]
@classmethod
def from_client(cls, client):
def from_client(cls, client: Client) -> ProfileServiceProxy:
return ServiceProxy.from_client(cls, client, cls.SERVICE_CLASS.UUID)
@@ -227,8 +246,12 @@ class ProfileServiceProxy:
class Client:
services: List[ServiceProxy]
cached_values: Dict[int, Tuple[datetime, bytes]]
notification_subscribers: Dict[int, Callable[[bytes], Any]]
indication_subscribers: Dict[int, 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.mtu_exchange_done = False
self.request_semaphore = asyncio.Semaphore(1)
@@ -241,16 +264,16 @@ class Client:
self.services = []
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)
async def send_command(self, command):
async def send_command(self, command: ATT_PDU) -> None:
logger.debug(
f'GATT Command from client: [0x{self.connection.handle:04X}] {command}'
)
self.send_gatt_pdu(command.to_bytes())
async def send_request(self, request):
async def send_request(self, request: ATT_PDU):
logger.debug(
f'GATT Request from client: [0x{self.connection.handle:04X}] {request}'
)
@@ -279,14 +302,14 @@ class Client:
return response
def send_confirmation(self, confirmation):
def send_confirmation(self, confirmation: ATT_Handle_Value_Confirmation) -> None:
logger.debug(
f'GATT Confirmation from client: [0x{self.connection.handle:04X}] '
f'{confirmation}'
)
self.send_gatt_pdu(confirmation.to_bytes())
async def request_mtu(self, mtu):
async def request_mtu(self, mtu: int) -> int:
# Check the range
if mtu < ATT_DEFAULT_MTU:
raise ValueError(f'MTU must be >= {ATT_DEFAULT_MTU}')
@@ -313,10 +336,12 @@ class Client:
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]
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
return [
c
@@ -363,7 +388,7 @@ class Client:
if not already_known:
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
'''
@@ -435,7 +460,7 @@ class Client:
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
'''
@@ -468,7 +493,7 @@ class Client:
f'{HCI_Constant.error_name(response.error_code)}'
)
# TODO raise appropriate exception
return
return []
break
for attribute_handle, end_group_handle in response.handles_information:
@@ -480,7 +505,7 @@ class Client:
logger.warning(
f'bogus handle values: {attribute_handle} {end_group_handle}'
)
return
return []
# Create a service proxy for this service
service = ServiceProxy(
@@ -721,7 +746,7 @@ class Client:
return descriptors
async def discover_attributes(self):
async def discover_attributes(self) -> List[AttributeProxy]:
'''
Discover all attributes, regardless of type
'''
@@ -844,7 +869,9 @@ class Client:
# No more subscribers left
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
) -> Any:
'''
See Vol 3, Part G - 4.8.1 Read Characteristic Value
@@ -905,7 +932,9 @@ class Client:
# Return the value as bytes
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
'''
@@ -960,7 +989,12 @@ class Client:
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
Value
@@ -990,7 +1024,7 @@ class Client:
)
)
def on_gatt_pdu(self, att_pdu):
def on_gatt_pdu(self, att_pdu: ATT_PDU) -> None:
logger.debug(
f'GATT Response to client: [0x{self.connection.handle:04X}] {att_pdu}'
)
@@ -1013,6 +1047,7 @@ class Client:
return
# 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)
else:
handler_name = f'on_{att_pdu.name.lower()}'
@@ -1060,7 +1095,7 @@ class Client:
# Confirm that we received the indication
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] = (
datetime.now(),
value,

View File

@@ -23,11 +23,12 @@
# -----------------------------------------------------------------------------
# Imports
# -----------------------------------------------------------------------------
from __future__ import annotations
import asyncio
import logging
from collections import defaultdict
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 .colors import color
@@ -42,6 +43,7 @@ from .att import (
ATT_INVALID_OFFSET_ERROR,
ATT_REQUEST_NOT_SUPPORTED_ERROR,
ATT_REQUESTS,
ATT_PDU,
ATT_UNLIKELY_ERROR_ERROR,
ATT_UNSUPPORTED_GROUP_TYPE_ERROR,
ATT_Error,
@@ -73,6 +75,8 @@ from .gatt import (
Service,
)
if TYPE_CHECKING:
from bumble.device import Device, Connection
# -----------------------------------------------------------------------------
# Logging
@@ -91,8 +95,13 @@ GATT_SERVER_DEFAULT_MAX_MTU = 517
# -----------------------------------------------------------------------------
class Server(EventEmitter):
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__()
self.device = device
self.services = []
@@ -107,16 +116,16 @@ class Server(EventEmitter):
self.indication_semaphores = defaultdict(lambda: asyncio.Semaphore(1))
self.pending_confirmations = defaultdict(lambda: None)
def __str__(self):
def __str__(self) -> str:
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)
def next_handle(self):
def next_handle(self) -> int:
return 1 + len(self.attributes)
def get_advertising_service_data(self):
def get_advertising_service_data(self) -> Dict[Attribute, bytes]:
return {
attribute: data
for attribute in self.attributes
@@ -124,7 +133,7 @@ class Server(EventEmitter):
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)
if attribute:
return attribute
@@ -173,12 +182,17 @@ class Server(EventEmitter):
return next(
(
(attribute, self.get_attribute(attribute.characteristic.handle))
(
attribute,
self.get_attribute(attribute.characteristic.handle),
) # type: ignore
for attribute in map(
self.get_attribute,
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
),
None,
@@ -197,7 +211,7 @@ class Server(EventEmitter):
return next(
(
attribute
attribute # type: ignore
for attribute in map(
self.get_attribute,
range(
@@ -205,12 +219,12 @@ class Server(EventEmitter):
characteristic_value.end_group_handle + 1,
),
)
if attribute.type == descriptor_uuid
if attribute is not None and attribute.type == descriptor_uuid
),
None,
)
def add_attribute(self, attribute):
def add_attribute(self, attribute: Attribute) -> None:
# Assign a handle to this attribute
attribute.handle = self.next_handle()
attribute.end_group_handle = (
@@ -220,7 +234,7 @@ class Server(EventEmitter):
# Add this attribute to the list
self.attributes.append(attribute)
def add_service(self, service: Service):
def add_service(self, service: Service) -> None:
# Add the service attribute to the DB
self.add_attribute(service)
@@ -285,11 +299,13 @@ class Server(EventEmitter):
service.end_group_handle = self.attributes[-1].handle
self.services.append(service)
def add_services(self, services):
def add_services(self, services: Iterable[Service]) -> None:
for service in services:
self.add_service(service)
def read_cccd(self, connection, characteristic):
def read_cccd(
self, connection: Optional[Connection], characteristic: Characteristic
) -> bytes:
if connection is None:
return bytes([0, 0])
@@ -300,7 +316,12 @@ class Server(EventEmitter):
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(
f'Subscription update for connection=0x{connection.handle:04X}, '
f'handle=0x{characteristic.handle:04X}: {value.hex()}'
@@ -327,13 +348,19 @@ class Server(EventEmitter):
indicate_enabled,
)
def send_response(self, connection, response):
def send_response(self, connection: Connection, response: ATT_PDU) -> None:
logger.debug(
f'GATT Response from server: [0x{connection.handle:04X}] {response}'
)
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
if not force:
subscribers = self.subscribers.get(connection.handle)
@@ -370,7 +397,13 @@ class Server(EventEmitter):
)
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
if not force:
subscribers = self.subscribers.get(connection.handle)
@@ -411,15 +444,13 @@ class Server(EventEmitter):
assert self.pending_confirmations[connection.handle] is None
# Create a future value to hold the eventual response
self.pending_confirmations[
pending_confirmation = self.pending_confirmations[
connection.handle
] = asyncio.get_running_loop().create_future()
try:
self.send_gatt_pdu(connection.handle, indication.to_bytes())
await asyncio.wait_for(
self.pending_confirmations[connection.handle], GATT_REQUEST_TIMEOUT
)
await asyncio.wait_for(pending_confirmation, GATT_REQUEST_TIMEOUT)
except asyncio.TimeoutError as error:
logger.warning(color('!!! GATT Indicate timeout', 'red'))
raise TimeoutError(f'GATT timeout for {indication.name}') from error
@@ -427,8 +458,12 @@ class Server(EventEmitter):
self.pending_confirmations[connection.handle] = None
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
connections = [
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)
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)
def on_disconnection(self, connection):
def on_disconnection(self, connection: Connection) -> None:
if connection.handle in self.subscribers:
del self.subscribers[connection.handle]
if connection.handle in self.indication_semaphores:
@@ -464,7 +509,7 @@ class Server(EventEmitter):
if connection.handle in self.pending_confirmations:
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}')
handler_name = f'on_{att_pdu.name.lower()}'
handler = getattr(self, handler_name, None)
@@ -506,7 +551,7 @@ class Server(EventEmitter):
#######################################################
# 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
'''
@@ -679,7 +724,6 @@ class Server(EventEmitter):
and attribute.handle <= request.ending_handle
and pdu_space_available
):
try:
attribute_value = attribute.read_value(connection)
except ATT_Error as error:

View File

@@ -16,11 +16,11 @@
# Imports
# -----------------------------------------------------------------------------
from __future__ import annotations
import struct
import collections
import logging
import functools
from typing import Dict, Type, Union
import logging
import struct
from typing import Any, Dict, Callable, Optional, Type, Union
from .colors import color
from .core import (
@@ -47,6 +47,10 @@ def hci_command_op_code(ogf, ocf):
return ogf << 10 | ocf
def hci_vendor_command_op_code(ocf):
return hci_command_op_code(HCI_VENDOR_OGF, ocf)
def key_with_value(dictionary, target_value):
for key, value in dictionary.items():
if value == target_value:
@@ -101,6 +105,8 @@ def phy_list_to_bits(phys):
# fmt: off
# pylint: disable=line-too-long
HCI_VENDOR_OGF = 0x3F
# HCI Version
HCI_VERSION_BLUETOOTH_CORE_1_0B = 0
HCI_VERSION_BLUETOOTH_CORE_1_1 = 1
@@ -206,10 +212,8 @@ HCI_INQUIRY_RESPONSE_NOTIFICATION_EVENT = 0X56
HCI_AUTHENTICATED_PAYLOAD_TIMEOUT_EXPIRED_EVENT = 0X57
HCI_SAM_STATUS_CHANGE_EVENT = 0X58
HCI_EVENT_NAMES = {
event_code: event_name for (event_name, event_code) in globals().items()
if event_name.startswith('HCI_') and event_name.endswith('_EVENT')
}
HCI_VENDOR_EVENT = 0xFF
# HCI Subevent Codes
HCI_LE_CONNECTION_COMPLETE_EVENT = 0x01
@@ -248,10 +252,6 @@ HCI_LE_TRANSMIT_POWER_REPORTING_EVENT = 0X21
HCI_LE_BIGINFO_ADVERTISING_REPORT_EVENT = 0X22
HCI_LE_SUBRATE_CHANGE_EVENT = 0X23
HCI_SUBEVENT_NAMES = {
event_code: event_name for (event_name, event_code) in globals().items()
if event_name.startswith('HCI_LE_') and event_name.endswith('_EVENT') and event_code != HCI_LE_META_EVENT
}
# HCI Command
HCI_INQUIRY_COMMAND = hci_command_op_code(0x01, 0x0001)
@@ -557,10 +557,6 @@ HCI_LE_SET_DATA_RELATED_ADDRESS_CHANGES_COMMAND = hci_c
HCI_LE_SET_DEFAULT_SUBRATE_COMMAND = hci_command_op_code(0x08, 0x007D)
HCI_LE_SUBRATE_REQUEST_COMMAND = hci_command_op_code(0x08, 0x007E)
HCI_COMMAND_NAMES = {
command_code: command_name for (command_name, command_code) in globals().items()
if command_name.startswith('HCI_') and command_name.endswith('_COMMAND')
}
# HCI Error Codes
# See Bluetooth spec Vol 2, Part D - 1.3 LIST OF ERROR CODES
@@ -1445,8 +1441,14 @@ class HCI_Object:
@staticmethod
def init_from_fields(hci_object, fields, values):
if isinstance(values, dict):
for field_name, _ in fields:
setattr(hci_object, field_name, values[field_name])
for field in fields:
if isinstance(field, list):
# The field is an array, up-level the array field names
for sub_field_name, _ in field:
setattr(hci_object, sub_field_name, values[sub_field_name])
else:
field_name = field[0]
setattr(hci_object, field_name, values[field_name])
else:
for field_name, field_value in zip(fields, values):
setattr(hci_object, field_name, field_value)
@@ -1456,133 +1458,161 @@ class HCI_Object:
parsed = HCI_Object.dict_from_bytes(data, offset, fields)
HCI_Object.init_from_fields(hci_object, parsed.keys(), parsed.values())
@staticmethod
def parse_field(data, offset, field_type):
# The field_type may be a dictionary with a mapper, parser, and/or size
if isinstance(field_type, dict):
if 'size' in field_type:
field_type = field_type['size']
elif 'parser' in field_type:
field_type = field_type['parser']
# Parse the field
if field_type == '*':
# The rest of the bytes
field_value = data[offset:]
return (field_value, len(field_value))
if field_type == 1:
# 8-bit unsigned
return (data[offset], 1)
if field_type == -1:
# 8-bit signed
return (struct.unpack_from('b', data, offset)[0], 1)
if field_type == 2:
# 16-bit unsigned
return (struct.unpack_from('<H', data, offset)[0], 2)
if field_type == '>2':
# 16-bit unsigned big-endian
return (struct.unpack_from('>H', data, offset)[0], 2)
if field_type == -2:
# 16-bit signed
return (struct.unpack_from('<h', data, offset)[0], 2)
if field_type == 3:
# 24-bit unsigned
padded = data[offset : offset + 3] + bytes([0])
return (struct.unpack('<I', padded)[0], 3)
if field_type == 4:
# 32-bit unsigned
return (struct.unpack_from('<I', data, offset)[0], 4)
if field_type == '>4':
# 32-bit unsigned big-endian
return (struct.unpack_from('>I', data, offset)[0], 4)
if isinstance(field_type, int) and 4 < field_type <= 256:
# Byte array (from 5 up to 256 bytes)
return (data[offset : offset + field_type], field_type)
if callable(field_type):
new_offset, field_value = field_type(data, offset)
return (field_value, new_offset - offset)
raise ValueError(f'unknown field type {field_type}')
@staticmethod
def dict_from_bytes(data, offset, fields):
result = collections.OrderedDict()
for (field_name, field_type) in fields:
# The field_type may be a dictionary with a mapper, parser, and/or size
if isinstance(field_type, dict):
if 'size' in field_type:
field_type = field_type['size']
elif 'parser' in field_type:
field_type = field_type['parser']
# Parse the field
if field_type == '*':
# The rest of the bytes
field_value = data[offset:]
offset += len(field_value)
elif field_type == 1:
# 8-bit unsigned
field_value = data[offset]
for field in fields:
if isinstance(field, list):
# This is an array field, starting with a 1-byte item count.
item_count = data[offset]
offset += 1
elif field_type == -1:
# 8-bit signed
field_value = struct.unpack_from('b', data, offset)[0]
offset += 1
elif field_type == 2:
# 16-bit unsigned
field_value = struct.unpack_from('<H', data, offset)[0]
offset += 2
elif field_type == '>2':
# 16-bit unsigned big-endian
field_value = struct.unpack_from('>H', data, offset)[0]
offset += 2
elif field_type == -2:
# 16-bit signed
field_value = struct.unpack_from('<h', data, offset)[0]
offset += 2
elif field_type == 3:
# 24-bit unsigned
padded = data[offset : offset + 3] + bytes([0])
field_value = struct.unpack('<I', padded)[0]
offset += 3
elif field_type == 4:
# 32-bit unsigned
field_value = struct.unpack_from('<I', data, offset)[0]
offset += 4
elif field_type == '>4':
# 32-bit unsigned big-endian
field_value = struct.unpack_from('>I', data, offset)[0]
offset += 4
elif isinstance(field_type, int) and 4 < field_type <= 256:
# Byte array (from 5 up to 256 bytes)
field_value = data[offset : offset + field_type]
offset += field_type
elif callable(field_type):
offset, field_value = field_type(data, offset)
else:
raise ValueError(f'unknown field type {field_type}')
for _ in range(item_count):
for sub_field_name, sub_field_type in field:
value, size = HCI_Object.parse_field(
data, offset, sub_field_type
)
result.setdefault(sub_field_name, []).append(value)
offset += size
continue
field_name, field_type = field
field_value, field_size = HCI_Object.parse_field(data, offset, field_type)
result[field_name] = field_value
offset += field_size
return result
@staticmethod
def serialize_field(field_value, field_type):
# The field_type may be a dictionary with a mapper, parser, serializer,
# and/or size
serializer = None
if isinstance(field_type, dict):
if 'serializer' in field_type:
serializer = field_type['serializer']
if 'size' in field_type:
field_type = field_type['size']
# Serialize the field
if serializer:
field_bytes = serializer(field_value)
elif field_type == 1:
# 8-bit unsigned
field_bytes = bytes([field_value])
elif field_type == -1:
# 8-bit signed
field_bytes = struct.pack('b', field_value)
elif field_type == 2:
# 16-bit unsigned
field_bytes = struct.pack('<H', field_value)
elif field_type == '>2':
# 16-bit unsigned big-endian
field_bytes = struct.pack('>H', field_value)
elif field_type == -2:
# 16-bit signed
field_bytes = struct.pack('<h', field_value)
elif field_type == 3:
# 24-bit unsigned
field_bytes = struct.pack('<I', field_value)[0:3]
elif field_type == 4:
# 32-bit unsigned
field_bytes = struct.pack('<I', field_value)
elif field_type == '>4':
# 32-bit unsigned big-endian
field_bytes = struct.pack('>I', field_value)
elif field_type == '*':
if isinstance(field_value, int):
if 0 <= field_value <= 255:
field_bytes = bytes([field_value])
else:
raise ValueError('value too large for *-typed field')
else:
field_bytes = bytes(field_value)
elif isinstance(field_value, (bytes, bytearray)) or hasattr(
field_value, 'to_bytes'
):
field_bytes = bytes(field_value)
if isinstance(field_type, int) and 4 < field_type <= 256:
# Truncate or pad with zeros if the field is too long or too short
if len(field_bytes) < field_type:
field_bytes += bytes(field_type - len(field_bytes))
elif len(field_bytes) > field_type:
field_bytes = field_bytes[:field_type]
else:
raise ValueError(f"don't know how to serialize type {type(field_value)}")
return field_bytes
@staticmethod
def dict_to_bytes(hci_object, fields):
result = bytearray()
for (field_name, field_type) in fields:
# The field_type may be a dictionary with a mapper, parser, serializer,
# and/or size
serializer = None
if isinstance(field_type, dict):
if 'serializer' in field_type:
serializer = field_type['serializer']
if 'size' in field_type:
field_type = field_type['size']
# Serialize the field
field_value = hci_object[field_name]
if serializer:
field_bytes = serializer(field_value)
elif field_type == 1:
# 8-bit unsigned
field_bytes = bytes([field_value])
elif field_type == -1:
# 8-bit signed
field_bytes = struct.pack('b', field_value)
elif field_type == 2:
# 16-bit unsigned
field_bytes = struct.pack('<H', field_value)
elif field_type == '>2':
# 16-bit unsigned big-endian
field_bytes = struct.pack('>H', field_value)
elif field_type == -2:
# 16-bit signed
field_bytes = struct.pack('<h', field_value)
elif field_type == 3:
# 24-bit unsigned
field_bytes = struct.pack('<I', field_value)[0:3]
elif field_type == 4:
# 32-bit unsigned
field_bytes = struct.pack('<I', field_value)
elif field_type == '>4':
# 32-bit unsigned big-endian
field_bytes = struct.pack('>I', field_value)
elif field_type == '*':
if isinstance(field_value, int):
if 0 <= field_value <= 255:
field_bytes = bytes([field_value])
else:
raise ValueError('value too large for *-typed field')
else:
field_bytes = bytes(field_value)
elif isinstance(field_value, (bytes, bytearray)) or hasattr(
field_value, 'to_bytes'
):
field_bytes = bytes(field_value)
if isinstance(field_type, int) and 4 < field_type <= 256:
# Truncate or Pad with zeros if the field is too long or too short
if len(field_bytes) < field_type:
field_bytes += bytes(field_type - len(field_bytes))
elif len(field_bytes) > field_type:
field_bytes = field_bytes[:field_type]
else:
raise ValueError(
f"don't know how to serialize type {type(field_value)}"
for field in fields:
if isinstance(field, list):
# The field is an array. The serialized form starts with a 1-byte
# item count. We use the length of the first array field as the
# array count, since all array fields have the same number of items.
item_count = len(hci_object[field[0][0]])
result += bytes([item_count]) + b''.join(
b''.join(
HCI_Object.serialize_field(
hci_object[sub_field_name][i], sub_field_type
)
for sub_field_name, sub_field_type in field
)
for i in range(item_count)
)
continue
result += field_bytes
(field_name, field_type) = field
result += HCI_Object.serialize_field(hci_object[field_name], field_type)
return bytes(result)
@@ -1617,46 +1647,73 @@ class HCI_Object:
return str(value)
@staticmethod
def format_fields(hci_object, keys, indentation='', value_mappers=None):
if not keys:
return ''
def stringify_field(
field_name, field_type, field_value, indentation, value_mappers
):
value_mapper = None
if isinstance(field_type, dict):
# Get the value mapper from the specifier
value_mapper = field_type.get('mapper')
# Measure the widest field name
max_field_name_length = max(
(len(key[0] if isinstance(key, tuple) else key) for key in keys)
# Check if there's a matching mapper passed
if value_mappers:
value_mapper = value_mappers.get(field_name, value_mapper)
# Map the value if we have a mapper
if value_mapper is not None:
field_value = value_mapper(field_value)
# Get the string representation of the value
return HCI_Object.format_field_value(
field_value, indentation=indentation + ' '
)
@staticmethod
def format_fields(hci_object, fields, indentation='', value_mappers=None):
if not fields:
return ''
# Build array of formatted key:value pairs
fields = []
for key in keys:
value_mapper = None
if isinstance(key, tuple):
# The key has an associated specifier
key, specifier = key
field_strings = []
for field in fields:
if isinstance(field, list):
for sub_field in field:
sub_field_name, sub_field_type = sub_field
item_count = len(hci_object[sub_field_name])
for i in range(item_count):
field_strings.append(
(
f'{sub_field_name}[{i}]',
HCI_Object.stringify_field(
sub_field_name,
sub_field_type,
hci_object[sub_field_name][i],
indentation,
value_mappers,
),
),
)
continue
# Get the value mapper from the specifier
if isinstance(specifier, dict):
value_mapper = specifier.get('mapper')
# Get the value for the field
value = hci_object[key]
# Map the value if needed
if value_mappers:
value_mapper = value_mappers.get(key, value_mapper)
if value_mapper is not None:
value = value_mapper(value)
# Get the string representation of the value
value_str = HCI_Object.format_field_value(
value, indentation=indentation + ' '
field_name, field_type = field
field_value = hci_object[field_name]
field_strings.append(
(
field_name,
HCI_Object.stringify_field(
field_name, field_type, field_value, indentation, value_mappers
),
),
)
# Add the field to the formatted result
key_str = color(f'{key + ":":{1 + max_field_name_length}}', 'cyan')
fields.append(f'{indentation}{key_str} {value_str}')
return '\n'.join(fields)
# Measure the widest field name
max_field_name_length = max(len(s[0]) for s in field_strings)
sep = ':'
return '\n'.join(
f'{indentation}'
f'{color(f"{field_name + sep:{1 + max_field_name_length}}", "cyan")} {field_value}'
for field_name, field_value in field_strings
)
def __bytes__(self):
return self.to_bytes()
@@ -1857,7 +1914,7 @@ class HCI_Packet:
hci_packet_type: int
@staticmethod
def from_bytes(packet):
def from_bytes(packet: bytes) -> HCI_Packet:
packet_type = packet[0]
if packet_type == HCI_COMMAND_PACKET:
@@ -1899,6 +1956,7 @@ class HCI_Command(HCI_Packet):
'''
hci_packet_type = HCI_COMMAND_PACKET
command_names: Dict[int, str] = {}
command_classes: Dict[int, Type[HCI_Command]] = {}
@staticmethod
@@ -1909,9 +1967,9 @@ class HCI_Command(HCI_Packet):
def inner(cls):
cls.name = cls.__name__.upper()
cls.op_code = key_with_value(HCI_COMMAND_NAMES, cls.name)
cls.op_code = key_with_value(cls.command_names, cls.name)
if cls.op_code is None:
raise KeyError(f'command {cls.name} not found in HCI_COMMAND_NAMES')
raise KeyError(f'command {cls.name} not found in command_names')
cls.fields = fields
cls.return_parameters_fields = return_parameters_fields
@@ -1931,7 +1989,19 @@ class HCI_Command(HCI_Packet):
return inner
@staticmethod
def from_bytes(packet):
def command_map(symbols: Dict[str, Any]) -> Dict[int, str]:
return {
command_code: command_name
for (command_name, command_code) in symbols.items()
if command_name.startswith('HCI_') and command_name.endswith('_COMMAND')
}
@classmethod
def register_commands(cls, symbols: Dict[str, Any]) -> None:
cls.command_names.update(cls.command_map(symbols))
@staticmethod
def from_bytes(packet: bytes) -> HCI_Command:
op_code, length = struct.unpack_from('<HB', packet, 1)
parameters = packet[4:]
if len(parameters) != length:
@@ -1950,11 +2020,11 @@ class HCI_Command(HCI_Packet):
HCI_Object.init_from_bytes(self, parameters, 0, fields)
return self
return cls.from_parameters(parameters)
return cls.from_parameters(parameters) # type: ignore
@staticmethod
def command_name(op_code):
name = HCI_COMMAND_NAMES.get(op_code)
name = HCI_Command.command_names.get(op_code)
if name is not None:
return name
return f'[OGF=0x{op_code >> 10:02x}, OCF=0x{op_code & 0x3FF:04x}]'
@@ -1963,6 +2033,16 @@ class HCI_Command(HCI_Packet):
def create_return_parameters(cls, **kwargs):
return HCI_Object(cls.return_parameters_fields, **kwargs)
@classmethod
def parse_return_parameters(cls, parameters):
if not cls.return_parameters_fields:
return None
return_parameters = HCI_Object.from_bytes(
parameters, 0, cls.return_parameters_fields
)
return_parameters.fields = cls.return_parameters_fields
return return_parameters
def __init__(self, op_code, parameters=None, **kwargs):
super().__init__(HCI_Command.command_name(op_code))
if (fields := getattr(self, 'fields', None)) and kwargs:
@@ -1992,6 +2072,9 @@ class HCI_Command(HCI_Packet):
return result
HCI_Command.register_commands(globals())
# -----------------------------------------------------------------------------
@HCI_Command.command(
[
@@ -3767,9 +3850,7 @@ class HCI_LE_Set_Extended_Advertising_Parameters_Command(HCI_Command):
'advertising_data',
{
'parser': HCI_Object.parse_length_prefixed_bytes,
'serializer': functools.partial(
HCI_Object.serialize_length_prefixed_bytes
),
'serializer': HCI_Object.serialize_length_prefixed_bytes,
},
),
]
@@ -3817,9 +3898,7 @@ class HCI_LE_Set_Extended_Advertising_Data_Command(HCI_Command):
'scan_response_data',
{
'parser': HCI_Object.parse_length_prefixed_bytes,
'serializer': functools.partial(
HCI_Object.serialize_length_prefixed_bytes
),
'serializer': HCI_Object.serialize_length_prefixed_bytes,
},
),
]
@@ -3847,73 +3926,21 @@ class HCI_LE_Set_Extended_Scan_Response_Data_Command(HCI_Command):
# -----------------------------------------------------------------------------
@HCI_Command.command(fields=None)
@HCI_Command.command(
[
('enable', 1),
[
('advertising_handles', 1),
('durations', 2),
('max_extended_advertising_events', 1),
],
]
)
class HCI_LE_Set_Extended_Advertising_Enable_Command(HCI_Command):
'''
See Bluetooth spec @ 7.8.56 LE Set Extended Advertising Enable Command
'''
@classmethod
def from_parameters(cls, parameters):
enable = parameters[0]
num_sets = parameters[1]
advertising_handles = []
durations = []
max_extended_advertising_events = []
offset = 2
for _ in range(num_sets):
advertising_handles.append(parameters[offset])
durations.append(struct.unpack_from('<H', parameters, offset + 1)[0])
max_extended_advertising_events.append(parameters[offset + 3])
offset += 4
return cls(
enable, advertising_handles, durations, max_extended_advertising_events
)
def __init__(
self, enable, advertising_handles, durations, max_extended_advertising_events
):
super().__init__(HCI_LE_SET_EXTENDED_ADVERTISING_ENABLE_COMMAND)
self.enable = enable
self.advertising_handles = advertising_handles
self.durations = durations
self.max_extended_advertising_events = max_extended_advertising_events
self.parameters = bytes([enable, len(advertising_handles)]) + b''.join(
[
struct.pack(
'<BHB',
advertising_handles[i],
durations[i],
max_extended_advertising_events[i],
)
for i in range(len(advertising_handles))
]
)
def __str__(self):
fields = [('enable:', self.enable)]
for i, advertising_handle in enumerate(self.advertising_handles):
fields.append(
(f'advertising_handle[{i}]: ', advertising_handle)
)
fields.append((f'duration[{i}]: ', self.durations[i]))
fields.append(
(
f'max_extended_advertising_events[{i}]:',
self.max_extended_advertising_events[i],
)
)
return (
color(self.name, 'green')
+ ':\n'
+ '\n'.join(
[color(field[0], 'cyan') + ' ' + str(field[1]) for field in fields]
)
)
# -----------------------------------------------------------------------------
@HCI_Command.command(
@@ -4064,7 +4091,10 @@ class HCI_LE_Set_Extended_Scan_Parameters_Command(HCI_Command):
color(self.name, 'green')
+ ':\n'
+ '\n'.join(
[color(field[0], 'cyan') + ' ' + str(field[1]) for field in fields]
[
color(' ' + field[0], 'cyan') + ' ' + str(field[1])
for field in fields
]
)
)
@@ -4240,7 +4270,10 @@ class HCI_LE_Extended_Create_Connection_Command(HCI_Command):
color(self.name, 'green')
+ ':\n'
+ '\n'.join(
[color(field[0], 'cyan') + ' ' + str(field[1]) for field in fields]
[
color(' ' + field[0], 'cyan') + ' ' + str(field[1])
for field in fields
]
)
)
@@ -4297,8 +4330,8 @@ class HCI_Event(HCI_Packet):
'''
hci_packet_type = HCI_EVENT_PACKET
event_names: Dict[int, str] = {}
event_classes: Dict[int, Type[HCI_Event]] = {}
meta_event_classes: Dict[int, Type[HCI_LE_Meta_Event]] = {}
@staticmethod
def event(fields=()):
@@ -4308,9 +4341,9 @@ class HCI_Event(HCI_Packet):
def inner(cls):
cls.name = cls.__name__.upper()
cls.event_code = key_with_value(HCI_EVENT_NAMES, cls.name)
cls.event_code = key_with_value(cls.event_names, cls.name)
if cls.event_code is None:
raise KeyError('event not found in HCI_EVENT_NAMES')
raise KeyError(f'event {cls.name} not found in event_names')
cls.fields = fields
# Patch the __init__ method to fix the event_code
@@ -4326,12 +4359,30 @@ class HCI_Event(HCI_Packet):
return inner
@staticmethod
def event_map(symbols: Dict[str, Any]) -> Dict[int, str]:
return {
event_code: event_name
for (event_name, event_code) in symbols.items()
if event_name.startswith('HCI_')
and not event_name.startswith('HCI_LE_')
and event_name.endswith('_EVENT')
}
@staticmethod
def event_name(event_code):
return name_or_number(HCI_Event.event_names, event_code)
@staticmethod
def register_events(symbols: Dict[str, Any]) -> None:
HCI_Event.event_names.update(HCI_Event.event_map(symbols))
@staticmethod
def registered(event_class):
event_class.name = event_class.__name__.upper()
event_class.event_code = key_with_value(HCI_EVENT_NAMES, event_class.name)
event_class.event_code = key_with_value(HCI_Event.event_names, event_class.name)
if event_class.event_code is None:
raise KeyError('event not found in HCI_EVENT_NAMES')
raise KeyError(f'event {event_class.name} not found in event_names')
# Register a factory for this class
HCI_Event.event_classes[event_class.event_code] = event_class
@@ -4339,22 +4390,28 @@ class HCI_Event(HCI_Packet):
return event_class
@staticmethod
def from_bytes(packet):
def from_bytes(packet: bytes) -> HCI_Event:
event_code = packet[1]
length = packet[2]
parameters = packet[3:]
if len(parameters) != length:
raise ValueError('invalid packet length')
cls: Any
if event_code == HCI_LE_META_EVENT:
# We do this dispatch here and not in the subclass in order to avoid call
# loops
subevent_code = parameters[0]
cls = HCI_Event.meta_event_classes.get(subevent_code)
cls = HCI_LE_Meta_Event.subevent_classes.get(subevent_code)
if cls is None:
# No class registered, just use a generic class instance
return HCI_LE_Meta_Event(subevent_code, parameters)
elif event_code == HCI_VENDOR_EVENT:
subevent_code = parameters[0]
cls = HCI_Vendor_Event.subevent_classes.get(subevent_code)
if cls is None:
# No class registered, just use a generic class instance
return HCI_Vendor_Event(subevent_code, parameters)
else:
cls = HCI_Event.event_classes.get(event_code)
if cls is None:
@@ -4362,7 +4419,7 @@ class HCI_Event(HCI_Packet):
return HCI_Event(event_code, parameters)
# Invoke the factory to create a new instance
return cls.from_parameters(parameters)
return cls.from_parameters(parameters) # type: ignore
@classmethod
def from_parameters(cls, parameters):
@@ -4372,10 +4429,6 @@ class HCI_Event(HCI_Packet):
HCI_Object.init_from_bytes(self, parameters, 0, fields)
return self
@staticmethod
def event_name(event_code):
return name_or_number(HCI_EVENT_NAMES, event_code)
def __init__(self, event_code, parameters=None, **kwargs):
super().__init__(HCI_Event.event_name(event_code))
if (fields := getattr(self, 'fields', None)) and kwargs:
@@ -4402,71 +4455,111 @@ class HCI_Event(HCI_Packet):
return result
HCI_Event.register_events(globals())
# -----------------------------------------------------------------------------
class HCI_LE_Meta_Event(HCI_Event):
class HCI_Extended_Event(HCI_Event):
'''
See Bluetooth spec @ 7.7.65 LE Meta Event
HCI_Event subclass for events that has a subevent code.
'''
@staticmethod
def event(fields=()):
subevent_names: Dict[int, str] = {}
subevent_classes: Dict[int, Type[HCI_Extended_Event]]
@classmethod
def event(cls, fields=()):
'''
Decorator used to declare and register subclasses
'''
def inner(cls):
cls.name = cls.__name__.upper()
cls.subevent_code = key_with_value(HCI_SUBEVENT_NAMES, cls.name)
cls.subevent_code = key_with_value(cls.subevent_names, cls.name)
if cls.subevent_code is None:
raise KeyError('subevent not found in HCI_SUBEVENT_NAMES')
raise KeyError(f'subevent {cls.name} not found in subevent_names')
cls.fields = fields
# Patch the __init__ method to fix the subevent_code
original_init = cls.__init__
def init(self, parameters=None, **kwargs):
return HCI_LE_Meta_Event.__init__(
self, cls.subevent_code, parameters, **kwargs
)
return original_init(self, cls.subevent_code, parameters, **kwargs)
cls.__init__ = init
# Register a factory for this class
HCI_Event.meta_event_classes[cls.subevent_code] = cls
cls.subevent_classes[cls.subevent_code] = cls
return cls
return inner
@classmethod
def subevent_name(cls, subevent_code):
subevent_name = cls.subevent_names.get(subevent_code)
if subevent_name is not None:
return subevent_name
return f'{cls.__name__.upper()}[0x{subevent_code:02X}]'
@staticmethod
def subevent_map(symbols: Dict[str, Any]) -> Dict[int, str]:
return {
subevent_code: subevent_name
for (subevent_name, subevent_code) in symbols.items()
if subevent_name.startswith('HCI_') and subevent_name.endswith('_EVENT')
}
@classmethod
def register_subevents(cls, symbols: Dict[str, Any]) -> None:
cls.subevent_names.update(cls.subevent_map(symbols))
@classmethod
def from_parameters(cls, parameters):
self = cls.__new__(cls)
HCI_LE_Meta_Event.__init__(self, self.subevent_code, parameters)
HCI_Extended_Event.__init__(self, self.subevent_code, parameters)
if fields := getattr(self, 'fields', None):
HCI_Object.init_from_bytes(self, parameters, 1, fields)
return self
@staticmethod
def subevent_name(subevent_code):
return name_or_number(HCI_SUBEVENT_NAMES, subevent_code)
def __init__(self, subevent_code, parameters, **kwargs):
self.subevent_code = subevent_code
if parameters is None and (fields := getattr(self, 'fields', None)) and kwargs:
parameters = bytes([subevent_code]) + HCI_Object.dict_to_bytes(
kwargs, fields
)
super().__init__(HCI_LE_META_EVENT, parameters, **kwargs)
super().__init__(self.event_code, parameters, **kwargs)
# Override the name in order to adopt the subevent name instead
self.name = self.subevent_name(subevent_code)
def __str__(self):
result = color(self.subevent_name(self.subevent_code), 'magenta')
if fields := getattr(self, 'fields', None):
result += ':\n' + HCI_Object.format_fields(self.__dict__, fields, ' ')
else:
if self.parameters:
result += f': {self.parameters.hex()}'
return result
# -----------------------------------------------------------------------------
class HCI_LE_Meta_Event(HCI_Extended_Event):
'''
See Bluetooth spec @ 7.7.65 LE Meta Event
'''
event_code: int = HCI_LE_META_EVENT
subevent_classes = {}
@staticmethod
def subevent_map(symbols: Dict[str, Any]) -> Dict[int, str]:
return {
subevent_code: subevent_name
for (subevent_name, subevent_code) in symbols.items()
if subevent_name.startswith('HCI_LE_') and subevent_name.endswith('_EVENT')
}
HCI_LE_Meta_Event.register_subevents(globals())
# -----------------------------------------------------------------------------
class HCI_Vendor_Event(HCI_Extended_Event):
event_code: int = HCI_VENDOR_EVENT
subevent_classes = {}
# -----------------------------------------------------------------------------
@@ -4580,7 +4673,7 @@ class HCI_LE_Advertising_Report_Event(HCI_LE_Meta_Event):
return f'{color(self.subevent_name(self.subevent_code), "magenta")}:\n{reports}'
HCI_Event.meta_event_classes[
HCI_LE_Meta_Event.subevent_classes[
HCI_LE_ADVERTISING_REPORT_EVENT
] = HCI_LE_Advertising_Report_Event
@@ -4834,7 +4927,7 @@ class HCI_LE_Extended_Advertising_Report_Event(HCI_LE_Meta_Event):
return f'{color(self.subevent_name(self.subevent_code), "magenta")}:\n{reports}'
HCI_Event.meta_event_classes[
HCI_LE_Meta_Event.subevent_classes[
HCI_LE_EXTENDED_ADVERTISING_REPORT_EVENT
] = HCI_LE_Extended_Advertising_Report_Event
@@ -5075,6 +5168,7 @@ class HCI_Command_Complete_Event(HCI_Event):
'''
return_parameters = b''
command_opcode: int
def map_return_parameters(self, return_parameters):
'''Map simple 'status' return parameters to their named constant form'''
@@ -5107,11 +5201,11 @@ class HCI_Command_Complete_Event(HCI_Event):
self.return_parameters = self.return_parameters[0]
else:
cls = HCI_Command.command_classes.get(self.command_opcode)
if cls and cls.return_parameters_fields:
self.return_parameters = HCI_Object.from_bytes(
self.return_parameters, 0, cls.return_parameters_fields
)
self.return_parameters.fields = cls.return_parameters_fields
if cls:
# Try to parse the return parameters bytes into an object.
return_parameters = cls.parse_return_parameters(self.return_parameters)
if return_parameters is not None:
self.return_parameters = return_parameters
return self
@@ -5203,7 +5297,7 @@ class HCI_Number_Of_Completed_Packets_Event(HCI_Event):
def __str__(self):
lines = [
color(self.name, 'magenta') + ':',
color(' number_of_handles: ', 'cyan')
color(' number_of_handles: ', 'cyan')
+ f'{len(self.connection_handles)}',
]
for i, connection_handle in enumerate(self.connection_handles):
@@ -5594,7 +5688,7 @@ class HCI_Remote_Host_Supported_Features_Notification_Event(HCI_Event):
# -----------------------------------------------------------------------------
class HCI_AclDataPacket:
class HCI_AclDataPacket(HCI_Packet):
'''
See Bluetooth spec @ 5.4.2 HCI ACL Data Packets
'''
@@ -5602,7 +5696,7 @@ class HCI_AclDataPacket:
hci_packet_type = HCI_ACL_DATA_PACKET
@staticmethod
def from_bytes(packet):
def from_bytes(packet: bytes) -> HCI_AclDataPacket:
# Read the header
h, data_total_length = struct.unpack_from('<HH', packet, 1)
connection_handle = h & 0xFFF
@@ -5644,12 +5738,14 @@ class HCI_AclDataPacket:
# -----------------------------------------------------------------------------
class HCI_AclDataPacketAssembler:
def __init__(self, callback):
current_data: Optional[bytes]
def __init__(self, callback: Callable[[bytes], Any]) -> None:
self.callback = callback
self.current_data = None
self.l2cap_pdu_length = 0
def feed_packet(self, packet):
def feed_packet(self, packet: HCI_AclDataPacket) -> None:
if packet.pb_flag in (
HCI_ACL_PB_FIRST_NON_FLUSHABLE,
HCI_ACL_PB_FIRST_FLUSHABLE,
@@ -5663,6 +5759,7 @@ class HCI_AclDataPacketAssembler:
return
self.current_data += packet.data
assert self.current_data is not None
if len(self.current_data) == self.l2cap_pdu_length + 4:
# The packet is complete, invoke the callback
logger.debug(f'<<< ACL PDU: {self.current_data.hex()}')

View File

@@ -1,4 +1,4 @@
# Copyright 2021-2022 Google LLC
# 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.
@@ -15,19 +15,51 @@
# -----------------------------------------------------------------------------
# Imports
# -----------------------------------------------------------------------------
import collections.abc
import logging
import asyncio
import collections
from typing import Union
import dataclasses
import enum
import traceback
import warnings
from typing import Dict, List, Union, Set, TYPE_CHECKING
from . import at
from . import rfcomm
from .colors import color
from bumble.colors import color
from bumble.core import (
ProtocolError,
BT_GENERIC_AUDIO_SERVICE,
BT_HANDSFREE_SERVICE,
BT_L2CAP_PROTOCOL_ID,
BT_RFCOMM_PROTOCOL_ID,
)
from bumble.sdp import (
DataElement,
ServiceAttribute,
SDP_SERVICE_RECORD_HANDLE_ATTRIBUTE_ID,
SDP_SERVICE_CLASS_ID_LIST_ATTRIBUTE_ID,
SDP_PROTOCOL_DESCRIPTOR_LIST_ATTRIBUTE_ID,
SDP_BLUETOOTH_PROFILE_DESCRIPTOR_LIST_ATTRIBUTE_ID,
SDP_SUPPORTED_FEATURES_ATTRIBUTE_ID,
)
# -----------------------------------------------------------------------------
# Logging
# -----------------------------------------------------------------------------
logger = logging.getLogger(__name__)
# -----------------------------------------------------------------------------
# Error
# -----------------------------------------------------------------------------
class HfpProtocolError(ProtocolError):
def __init__(self, error_name: str = '', details: str = ''):
super().__init__(None, 'hfp', error_name, details)
# -----------------------------------------------------------------------------
# Protocol Support
@@ -41,6 +73,7 @@ class HfpProtocol:
lines_available: asyncio.Event
def __init__(self, dlc: rfcomm.DLC) -> None:
warnings.warn("See HfProtocol", DeprecationWarning)
self.dlc = dlc
self.buffer = ''
self.lines = collections.deque()
@@ -83,19 +116,706 @@ class HfpProtocol:
logger.debug(color(f'<<< {line}', 'green'))
return line
async def initialize_service(self) -> None:
# Perform Service Level Connection Initialization
self.send_command_line('AT+BRSF=2072') # Retrieve Supported Features
await (self.next_line())
await (self.next_line())
self.send_command_line('AT+CIND=?')
await (self.next_line())
await (self.next_line())
# -----------------------------------------------------------------------------
# Normative protocol definitions
# -----------------------------------------------------------------------------
self.send_command_line('AT+CIND?')
await (self.next_line())
await (self.next_line())
self.send_command_line('AT+CMER=3,0,0,1')
await (self.next_line())
# HF supported features (AT+BRSF=) (normative).
# Hands-Free Profile v1.8, 4.34.2, AT Capabilities Re-Used from GSM 07.07
# and 3GPP 27.007
class HfFeature(enum.IntFlag):
EC_NR = 0x001 # Echo Cancel & Noise reduction
THREE_WAY_CALLING = 0x002
CLI_PRESENTATION_CAPABILITY = 0x004
VOICE_RECOGNITION_ACTIVATION = 0x008
REMOTE_VOLUME_CONTROL = 0x010
ENHANCED_CALL_STATUS = 0x020
ENHANCED_CALL_CONTROL = 0x040
CODEC_NEGOTIATION = 0x080
HF_INDICATORS = 0x100
ESCO_S4_SETTINGS_SUPPORTED = 0x200
ENHANCED_VOICE_RECOGNITION_STATUS = 0x400
VOICE_RECOGNITION_TEST = 0x800
# AG supported features (+BRSF:) (normative).
# Hands-Free Profile v1.8, 4.34.2, AT Capabilities Re-Used from GSM 07.07
# and 3GPP 27.007
class AgFeature(enum.IntFlag):
THREE_WAY_CALLING = 0x001
EC_NR = 0x002 # Echo Cancel & Noise reduction
VOICE_RECOGNITION_FUNCTION = 0x004
IN_BAND_RING_TONE_CAPABILITY = 0x008
VOICE_TAG = 0x010 # Attach a number to voice tag
REJECT_CALL = 0x020 # Ability to reject a call
ENHANCED_CALL_STATUS = 0x040
ENHANCED_CALL_CONTROL = 0x080
EXTENDED_ERROR_RESULT_CODES = 0x100
CODEC_NEGOTIATION = 0x200
HF_INDICATORS = 0x400
ESCO_S4_SETTINGS_SUPPORTED = 0x800
ENHANCED_VOICE_RECOGNITION_STATUS = 0x1000
VOICE_RECOGNITION_TEST = 0x2000
# Audio Codec IDs (normative).
# Hands-Free Profile v1.8, 10 Appendix B
class AudioCodec(enum.IntEnum):
CVSD = 0x01 # Support for CVSD audio codec
MSBC = 0x02 # Support for mSBC audio codec
# HF Indicators (normative).
# Bluetooth Assigned Numbers, 6.10.1 HF Indicators
class HfIndicator(enum.IntEnum):
ENHANCED_SAFETY = 0x01 # Enhanced safety feature
BATTERY_LEVEL = 0x02 # Battery level feature
# Call Hold supported operations (normative).
# AT Commands Reference Guide, 3.5.2.3.12 +CHLD - Call Holding Services
class CallHoldOperation(enum.IntEnum):
RELEASE_ALL_HELD_CALLS = 0 # Release all held calls
RELEASE_ALL_ACTIVE_CALLS = 1 # Release all active calls, accept other
HOLD_ALL_ACTIVE_CALLS = 2 # Place all active calls on hold, accept other
ADD_HELD_CALL = 3 # Adds a held call to conversation
# Response Hold status (normative).
# Hands-Free Profile v1.8, 4.34.2, AT Capabilities Re-Used from GSM 07.07
# and 3GPP 27.007
class ResponseHoldStatus(enum.IntEnum):
INC_CALL_HELD = 0 # Put incoming call on hold
HELD_CALL_ACC = 1 # Accept a held incoming call
HELD_CALL_REJ = 2 # Reject a held incoming call
# Values for the Call Setup AG indicator (normative).
# Hands-Free Profile v1.8, 4.34.2, AT Capabilities Re-Used from GSM 07.07
# and 3GPP 27.007
class CallSetupAgIndicator(enum.IntEnum):
NOT_IN_CALL_SETUP = 0
INCOMING_CALL_PROCESS = 1
OUTGOING_CALL_SETUP = 2
REMOTE_ALERTED = 3 # Remote party alerted in an outgoing call
# Values for the Call Held AG indicator (normative).
# Hands-Free Profile v1.8, 4.34.2, AT Capabilities Re-Used from GSM 07.07
# and 3GPP 27.007
class CallHeldAgIndicator(enum.IntEnum):
NO_CALLS_HELD = 0
# Call is placed on hold or active/held calls swapped
# (The AG has both an active AND a held call)
CALL_ON_HOLD_AND_ACTIVE_CALL = 1
CALL_ON_HOLD_NO_ACTIVE_CALL = 2 # Call on hold, no active call
# Call Info direction (normative).
# AT Commands Reference Guide, 3.5.2.3.15 +CLCC - List Current Calls
class CallInfoDirection(enum.IntEnum):
MOBILE_ORIGINATED_CALL = 0
MOBILE_TERMINATED_CALL = 1
# Call Info status (normative).
# AT Commands Reference Guide, 3.5.2.3.15 +CLCC - List Current Calls
class CallInfoStatus(enum.IntEnum):
ACTIVE = 0
HELD = 1
DIALING = 2
ALERTING = 3
INCOMING = 4
WAITING = 5
# Call Info mode (normative).
# AT Commands Reference Guide, 3.5.2.3.15 +CLCC - List Current Calls
class CallInfoMode(enum.IntEnum):
VOICE = 0
DATA = 1
FAX = 2
UNKNOWN = 9
# -----------------------------------------------------------------------------
# Hands-Free Control Interoperability Requirements
# -----------------------------------------------------------------------------
# Response codes.
RESPONSE_CODES = [
"+APLSIRI",
"+BAC",
"+BCC",
"+BCS",
"+BIA",
"+BIEV",
"+BIND",
"+BINP",
"+BLDN",
"+BRSF",
"+BTRH",
"+BVRA",
"+CCWA",
"+CHLD",
"+CHUP",
"+CIND",
"+CLCC",
"+CLIP",
"+CMEE",
"+CMER",
"+CNUM",
"+COPS",
"+IPHONEACCEV",
"+NREC",
"+VGM",
"+VGS",
"+VTS",
"+XAPL",
"A",
"D",
]
# Unsolicited responses and statuses.
UNSOLICITED_CODES = [
"+APLSIRI",
"+BCS",
"+BIND",
"+BSIR",
"+BTRH",
"+BVRA",
"+CCWA",
"+CIEV",
"+CLIP",
"+VGM",
"+VGS",
"BLACKLISTED",
"BUSY",
"DELAYED",
"NO ANSWER",
"NO CARRIER",
"RING",
]
# Status codes
STATUS_CODES = [
"+CME ERROR",
"BLACKLISTED",
"BUSY",
"DELAYED",
"ERROR",
"NO ANSWER",
"NO CARRIER",
"OK",
]
@dataclasses.dataclass
class Configuration:
supported_hf_features: List[HfFeature]
supported_hf_indicators: List[HfIndicator]
supported_audio_codecs: List[AudioCodec]
class AtResponseType(enum.Enum):
"""Indicate if a response is expected from an AT command, and if multiple
responses are accepted."""
NONE = 0
SINGLE = 1
MULTIPLE = 2
class AtResponse:
code: str
parameters: list
def __init__(self, response: bytearray):
code_and_parameters = response.split(b':')
parameters = (
code_and_parameters[1] if len(code_and_parameters) > 1 else bytearray()
)
self.code = code_and_parameters[0].decode()
self.parameters = at.parse_parameters(parameters)
@dataclasses.dataclass
class AgIndicatorState:
description: str
index: int
supported_values: Set[int]
current_status: int
@dataclasses.dataclass
class HfIndicatorState:
supported: bool = False
enabled: bool = False
class HfProtocol:
"""Implementation for the Hands-Free side of the Hands-Free profile.
Reference specification Hands-Free Profile v1.8"""
supported_hf_features: int
supported_audio_codecs: List[AudioCodec]
supported_ag_features: int
supported_ag_call_hold_operations: List[CallHoldOperation]
ag_indicators: List[AgIndicatorState]
hf_indicators: Dict[HfIndicator, HfIndicatorState]
dlc: rfcomm.DLC
command_lock: asyncio.Lock
if TYPE_CHECKING:
response_queue: asyncio.Queue[AtResponse]
unsolicited_queue: asyncio.Queue[AtResponse]
else:
response_queue: asyncio.Queue
unsolicited_queue: asyncio.Queue
read_buffer: bytearray
def __init__(self, dlc: rfcomm.DLC, configuration: Configuration):
# Configure internal state.
self.dlc = dlc
self.command_lock = asyncio.Lock()
self.response_queue = asyncio.Queue()
self.unsolicited_queue = asyncio.Queue()
self.read_buffer = bytearray()
# Build local features.
self.supported_hf_features = sum(configuration.supported_hf_features)
self.supported_audio_codecs = configuration.supported_audio_codecs
self.hf_indicators = {
indicator: HfIndicatorState()
for indicator in configuration.supported_hf_indicators
}
# Clear remote features.
self.supported_ag_features = 0
self.supported_ag_call_hold_operations = []
self.ag_indicators = []
# Bind the AT reader to the RFCOMM channel.
self.dlc.sink = self._read_at
def supports_hf_feature(self, feature: HfFeature) -> bool:
return (self.supported_hf_features & feature) != 0
def supports_ag_feature(self, feature: AgFeature) -> bool:
return (self.supported_ag_features & feature) != 0
# Read AT messages from the RFCOMM channel.
# Enqueue AT commands, responses, unsolicited responses to their
# respective queues, and set the corresponding event.
def _read_at(self, data: bytes):
# Append to the read buffer.
self.read_buffer.extend(data)
# Locate header and trailer.
header = self.read_buffer.find(b'\r\n')
trailer = self.read_buffer.find(b'\r\n', header + 2)
if header == -1 or trailer == -1:
return
# Isolate the AT response code and parameters.
raw_response = self.read_buffer[header + 2 : trailer]
response = AtResponse(raw_response)
logger.debug(f"<<< {raw_response.decode()}")
# Consume the response bytes.
self.read_buffer = self.read_buffer[trailer + 2 :]
# Forward the received code to the correct queue.
if self.command_lock.locked() and (
response.code in STATUS_CODES or response.code in RESPONSE_CODES
):
self.response_queue.put_nowait(response)
elif response.code in UNSOLICITED_CODES:
self.unsolicited_queue.put_nowait(response)
else:
logger.warning(f"dropping unexpected response with code '{response.code}'")
# Send an AT command and wait for the peer response.
# Wait for the AT responses sent by the peer, to the status code.
# Raises asyncio.TimeoutError if the status is not received
# after a timeout (default 1 second).
# Raises ProtocolError if the status is not OK.
async def execute_command(
self,
cmd: str,
timeout: float = 1.0,
response_type: AtResponseType = AtResponseType.NONE,
) -> Union[None, AtResponse, List[AtResponse]]:
async with self.command_lock:
logger.debug(f">>> {cmd}")
self.dlc.write(cmd + '\r')
responses: List[AtResponse] = []
while True:
result = await asyncio.wait_for(
self.response_queue.get(), timeout=timeout
)
if result.code == 'OK':
if response_type == AtResponseType.SINGLE and len(responses) != 1:
raise HfpProtocolError("NO ANSWER")
if response_type == AtResponseType.MULTIPLE:
return responses
if response_type == AtResponseType.SINGLE:
return responses[0]
return None
if result.code in STATUS_CODES:
raise HfpProtocolError(result.code)
responses.append(result)
# 4.2.1 Service Level Connection Initialization.
async def initiate_slc(self):
# 4.2.1.1 Supported features exchange
# First, in the initialization procedure, the HF shall send the
# AT+BRSF=<HF supported features> command to the AG to both notify
# the AG of the supported features in the HF, as well as to retrieve the
# supported features in the AG using the +BRSF result code.
response = await self.execute_command(
f"AT+BRSF={self.supported_hf_features}", response_type=AtResponseType.SINGLE
)
self.supported_ag_features = int(response.parameters[0])
logger.info(f"supported AG features: {self.supported_ag_features}")
for feature in AgFeature:
if self.supports_ag_feature(feature):
logger.info(f" - {feature.name}")
# 4.2.1.2 Codec Negotiation
# Secondly, in the initialization procedure, if the HF supports the
# Codec Negotiation feature, it shall check if the AT+BRSF command
# response from the AG has indicated that it supports the Codec
# Negotiation feature.
if self.supports_hf_feature(
HfFeature.CODEC_NEGOTIATION
) and self.supports_ag_feature(AgFeature.CODEC_NEGOTIATION):
# If both the HF and AG do support the Codec Negotiation feature
# then the HF shall send the AT+BAC=<HF available codecs> command to
# the AG to notify the AG of the available codecs in the HF.
codecs = [str(c) for c in self.supported_audio_codecs]
await self.execute_command(f"AT+BAC={','.join(codecs)}")
# 4.2.1.3 AG Indicators
# After having retrieved the supported features in the AG, the HF shall
# determine which indicators are supported by the AG, as well as the
# ordering of the supported indicators. This is because, according to
# the 3GPP 27.007 specification [2], the AG may support additional
# indicators not provided for by the Hands-Free Profile, and because the
# ordering of the indicators is implementation specific. The HF uses
# the AT+CIND=? Test command to retrieve information about the supported
# indicators and their ordering.
response = await self.execute_command(
"AT+CIND=?", response_type=AtResponseType.SINGLE
)
self.ag_indicators = []
for index, indicator in enumerate(response.parameters):
description = indicator[0].decode()
supported_values = []
for value in indicator[1]:
value = value.split(b'-')
value = [int(v) for v in value]
value_min = value[0]
value_max = value[1] if len(value) > 1 else value[0]
supported_values.extend([v for v in range(value_min, value_max + 1)])
self.ag_indicators.append(
AgIndicatorState(description, index, set(supported_values), 0)
)
# Once the HF has the necessary supported indicator and ordering
# information, it shall retrieve the current status of the indicators
# in the AG using the AT+CIND? Read command.
response = await self.execute_command(
"AT+CIND?", response_type=AtResponseType.SINGLE
)
for index, indicator in enumerate(response.parameters):
self.ag_indicators[index].current_status = int(indicator)
# After having retrieved the status of the indicators in the AG, the HF
# shall then enable the "Indicators status update" function in the AG by
# issuing the AT+CMER command, to which the AG shall respond with OK.
await self.execute_command("AT+CMER=3,,,1")
if self.supports_hf_feature(
HfFeature.THREE_WAY_CALLING
) and self.supports_ag_feature(HfFeature.THREE_WAY_CALLING):
# After the HF has enabled the “Indicators status update” function in
# the AG, and if the “Call waiting and 3-way calling” bit was set in the
# supported features bitmap by both the HF and the AG, the HF shall
# issue the AT+CHLD=? test command to retrieve the information about how
# the call hold and multiparty services are supported in the AG. The HF
# shall not issue the AT+CHLD=? test command in case either the HF or
# the AG does not support the "Three-way calling" feature.
response = await self.execute_command(
"AT+CHLD=?", response_type=AtResponseType.SINGLE
)
self.supported_ag_call_hold_operations = [
CallHoldOperation(int(operation))
for operation in response.parameters[0]
if not b'x' in operation
]
# 4.2.1.4 HF Indicators
# If the HF supports the HF indicator feature, it shall check the +BRSF
# response to see if the AG also supports the HF Indicator feature.
if self.supports_hf_feature(
HfFeature.HF_INDICATORS
) and self.supports_ag_feature(AgFeature.HF_INDICATORS):
# If both the HF and AG support the HF Indicator feature, then the HF
# shall send the AT+BIND=<HF supported HF indicators> command to the AG
# to notify the AG of the supported indicators assigned numbers in the
# HF. The AG shall respond with OK
indicators = [str(i) for i in self.hf_indicators.keys()]
await self.execute_command(f"AT+BIND={','.join(indicators)}")
# After having provided the AG with the HF indicators it supports,
# the HF shall send the AT+BIND=? to request HF indicators supported
# by the AG. The AG shall reply with the +BIND response listing all
# HF indicators that it supports followed by an OK.
response = await self.execute_command(
"AT+BIND=?", response_type=AtResponseType.SINGLE
)
logger.info("supported HF indicators:")
for indicator in response.parameters[0]:
indicator = HfIndicator(int(indicator))
logger.info(f" - {indicator.name}")
if indicator in self.hf_indicators:
self.hf_indicators[indicator].supported = True
# Once the HF receives the supported HF indicators list from the AG,
# the HF shall send the AT+BIND? command to determine which HF
# indicators are enabled. The AG shall respond with one or more
# +BIND responses. The AG shall terminate the list with OK.
# (See Section 4.36.1.3).
responses = await self.execute_command(
"AT+BIND?", response_type=AtResponseType.MULTIPLE
)
logger.info("enabled HF indicators:")
for response in responses:
indicator = HfIndicator(int(response.parameters[0]))
enabled = int(response.parameters[1]) != 0
logger.info(f" - {indicator.name}: {enabled}")
if indicator in self.hf_indicators:
self.hf_indicators[indicator].enabled = True
logger.info("SLC setup completed")
# 4.11.2 Audio Connection Setup by HF
async def setup_audio_connection(self):
# When the HF triggers the establishment of the Codec Connection it
# shall send the AT command AT+BCC to the AG. The AG shall respond with
# OK if it will start the Codec Connection procedure, and with ERROR
# if it cannot start the Codec Connection procedure.
await self.execute_command("AT+BCC")
# 4.11.3 Codec Connection Setup
async def setup_codec_connection(self, codec_id: int):
# The AG shall send a +BCS=<Codec ID> unsolicited response to the HF.
# The HF shall then respond to the incoming unsolicited response with
# the AT command AT+BCS=<Codec ID>. The ID shall be the same as in the
# unsolicited response code as long as the ID is supported.
# If the received ID is not available, the HF shall respond with
# AT+BAC with its available codecs.
if codec_id not in self.supported_audio_codecs:
codecs = [str(c) for c in self.supported_audio_codecs]
await self.execute_command(f"AT+BAC={','.join(codecs)}")
return
await self.execute_command(f"AT+BCS={codec_id}")
# After sending the OK response, the AG shall open the
# Synchronous Connection with the settings that are determined by the
# ID. The HF shall be ready to accept the synchronous connection
# establishment as soon as it has sent the AT commands AT+BCS=<Codec ID>.
logger.info("codec connection setup completed")
# 4.13.1 Answer Incoming Call from the HF In-Band Ringing
async def answer_incoming_call(self):
# The user accepts the incoming voice call by using the proper means
# provided by the HF. The HF shall then send the ATA command
# (see Section 4.34) to the AG. The AG shall then begin the procedure for
# accepting the incoming call.
await self.execute_command("ATA")
# 4.14.1 Reject an Incoming Call from the HF
async def reject_incoming_call(self):
# The user rejects the incoming call by using the User Interface on the
# Hands-Free unit. The HF shall then send the AT+CHUP command
# (see Section 4.34) to the AG. This may happen at any time during the
# procedures described in Sections 4.13.1 and 4.13.2.
await self.execute_command("AT+CHUP")
# 4.15.1 Terminate a Call Process from the HF
async def terminate_call(self):
# The user may abort the ongoing call process using whatever means
# provided by the Hands-Free unit. The HF shall send AT+CHUP command
# (see Section 4.34) to the AG, and the AG shall then start the
# procedure to terminate or interrupt the current call procedure.
# The AG shall then send the OK indication followed by the +CIEV result
# code, with the value indicating (call=0).
await self.execute_command("AT+CHUP")
async def update_ag_indicator(self, index: int, value: int):
self.ag_indicators[index].current_status = value
logger.info(
f"AG indicator updated: {self.ag_indicators[index].description}, {value}"
)
async def handle_unsolicited(self):
"""Handle unsolicited result codes sent by the audio gateway."""
result = await self.unsolicited_queue.get()
if result.code == "+BCS":
await self.setup_codec_connection(int(result.parameters[0]))
elif result.code == "+CIEV":
await self.update_ag_indicator(
int(result.parameters[0]), int(result.parameters[1])
)
else:
logging.info(f"unhandled unsolicited response {result.code}")
async def run(self):
"""Main rountine for the Hands-Free side of the HFP protocol.
Initiates the service level connection then loops handling
unsolicited AG responses."""
try:
await self.initiate_slc()
while True:
await self.handle_unsolicited()
except Exception:
logger.error("HFP-HF protocol failed with the following error:")
logger.error(traceback.format_exc())
# -----------------------------------------------------------------------------
# Normative SDP definitions
# -----------------------------------------------------------------------------
# Profile version (normative).
# Hands-Free Profile v1.8, 5.3 SDP Interoperability Requirements
class ProfileVersion(enum.IntEnum):
V1_5 = 0x0105
V1_6 = 0x0106
V1_7 = 0x0107
V1_8 = 0x0108
V1_9 = 0x0109
# HF supported features (normative).
# Hands-Free Profile v1.8, 5.3 SDP Interoperability Requirements
class HfSdpFeature(enum.IntFlag):
EC_NR = 0x01 # Echo Cancel & Noise reduction
THREE_WAY_CALLING = 0x02
CLI_PRESENTATION_CAPABILITY = 0x04
VOICE_RECOGNITION_ACTIVATION = 0x08
REMOTE_VOLUME_CONTROL = 0x10
WIDE_BAND = 0x20 # Wide band speech
ENHANCED_VOICE_RECOGNITION_STATUS = 0x40
VOICE_RECOGNITION_TEST = 0x80
# AG supported features (normative).
# Hands-Free Profile v1.8, 5.3 SDP Interoperability Requirements
class AgSdpFeature(enum.IntFlag):
THREE_WAY_CALLING = 0x01
EC_NR = 0x02 # Echo Cancel & Noise reduction
VOICE_RECOGNITION_FUNCTION = 0x04
IN_BAND_RING_TONE_CAPABILITY = 0x08
VOICE_TAG = 0x10 # Attach a number to voice tag
WIDE_BAND = 0x20 # Wide band speech
ENHANCED_VOICE_RECOGNITION_STATUS = 0x40
VOICE_RECOGNITION_TEST = 0x80
def sdp_records(
service_record_handle: int, rfcomm_channel: int, configuration: Configuration
) -> List[ServiceAttribute]:
"""Generate the SDP record for HFP Hands-Free support.
The record exposes the features supported in the input configuration,
and the allocated RFCOMM channel."""
hf_supported_features = 0
if HfFeature.EC_NR in configuration.supported_hf_features:
hf_supported_features |= HfSdpFeature.EC_NR
if HfFeature.THREE_WAY_CALLING in configuration.supported_hf_features:
hf_supported_features |= HfSdpFeature.THREE_WAY_CALLING
if HfFeature.CLI_PRESENTATION_CAPABILITY in configuration.supported_hf_features:
hf_supported_features |= HfSdpFeature.CLI_PRESENTATION_CAPABILITY
if HfFeature.VOICE_RECOGNITION_ACTIVATION in configuration.supported_hf_features:
hf_supported_features |= HfSdpFeature.VOICE_RECOGNITION_ACTIVATION
if HfFeature.REMOTE_VOLUME_CONTROL in configuration.supported_hf_features:
hf_supported_features |= HfSdpFeature.REMOTE_VOLUME_CONTROL
if (
HfFeature.ENHANCED_VOICE_RECOGNITION_STATUS
in configuration.supported_hf_features
):
hf_supported_features |= HfSdpFeature.ENHANCED_VOICE_RECOGNITION_STATUS
if HfFeature.VOICE_RECOGNITION_TEST in configuration.supported_hf_features:
hf_supported_features |= HfSdpFeature.VOICE_RECOGNITION_TEST
if AudioCodec.MSBC in configuration.supported_audio_codecs:
hf_supported_features |= HfSdpFeature.WIDE_BAND
return [
ServiceAttribute(
SDP_SERVICE_RECORD_HANDLE_ATTRIBUTE_ID,
DataElement.unsigned_integer_32(service_record_handle),
),
ServiceAttribute(
SDP_SERVICE_CLASS_ID_LIST_ATTRIBUTE_ID,
DataElement.sequence(
[
DataElement.uuid(BT_HANDSFREE_SERVICE),
DataElement.uuid(BT_GENERIC_AUDIO_SERVICE),
]
),
),
ServiceAttribute(
SDP_PROTOCOL_DESCRIPTOR_LIST_ATTRIBUTE_ID,
DataElement.sequence(
[
DataElement.sequence([DataElement.uuid(BT_L2CAP_PROTOCOL_ID)]),
DataElement.sequence(
[
DataElement.uuid(BT_RFCOMM_PROTOCOL_ID),
DataElement.unsigned_integer_8(rfcomm_channel),
]
),
]
),
),
ServiceAttribute(
SDP_BLUETOOTH_PROFILE_DESCRIPTOR_LIST_ATTRIBUTE_ID,
DataElement.sequence(
[
DataElement.sequence(
[
DataElement.uuid(BT_HANDSFREE_SERVICE),
DataElement.unsigned_integer_16(ProfileVersion.V1_8),
]
)
]
),
),
ServiceAttribute(
SDP_SUPPORTED_FEATURES_ATTRIBUTE_ID,
DataElement.unsigned_integer_16(hf_supported_features),
),
]

View File

@@ -15,22 +15,24 @@
# -----------------------------------------------------------------------------
# Imports
# -----------------------------------------------------------------------------
from __future__ import annotations
import asyncio
import collections
import logging
import struct
from typing import Optional, TYPE_CHECKING, Dict, Callable, Awaitable
from bumble.colors import color
from bumble.l2cap import L2CAP_PDU
from bumble.snoop import Snooper
from typing import Optional
from bumble import drivers
from .hci import (
Address,
HCI_ACL_DATA_PACKET,
HCI_COMMAND_COMPLETE_EVENT,
HCI_COMMAND_PACKET,
HCI_COMMAND_COMPLETE_EVENT,
HCI_EVENT_PACKET,
HCI_LE_READ_BUFFER_SIZE_COMMAND,
HCI_LE_READ_LOCAL_SUPPORTED_FEATURES_COMMAND,
@@ -44,8 +46,11 @@ from .hci import (
HCI_VERSION_BLUETOOTH_CORE_4_0,
HCI_AclDataPacket,
HCI_AclDataPacketAssembler,
HCI_Command,
HCI_Command_Complete_Event,
HCI_Constant,
HCI_Error,
HCI_Event,
HCI_LE_Long_Term_Key_Request_Negative_Reply_Command,
HCI_LE_Long_Term_Key_Request_Reply_Command,
HCI_LE_Read_Buffer_Size_Command,
@@ -62,16 +67,19 @@ from .hci import (
HCI_Read_Local_Version_Information_Command,
HCI_Reset_Command,
HCI_Set_Event_Mask_Command,
map_null_terminated_utf8_string,
)
from .core import (
BT_BR_EDR_TRANSPORT,
BT_CENTRAL_ROLE,
BT_LE_TRANSPORT,
ConnectionPHY,
ConnectionParameters,
InvalidStateError,
)
from .utils import AbortableEventEmitter
from .transport.common import TransportLostError
if TYPE_CHECKING:
from .transport.common import TransportSink, TransportSource
# -----------------------------------------------------------------------------
@@ -95,27 +103,39 @@ HOST_HC_TOTAL_NUM_ACL_DATA_PACKETS = 1
# -----------------------------------------------------------------------------
class Connection:
def __init__(self, host, handle, peer_address, transport):
def __init__(self, host: Host, handle: int, peer_address: Address, transport: int):
self.host = host
self.handle = handle
self.peer_address = peer_address
self.assembler = HCI_AclDataPacketAssembler(self.on_acl_pdu)
self.transport = transport
def on_hci_acl_data_packet(self, packet):
def on_hci_acl_data_packet(self, packet: HCI_AclDataPacket) -> None:
self.assembler.feed_packet(packet)
def on_acl_pdu(self, pdu):
def on_acl_pdu(self, pdu: bytes) -> None:
l2cap_pdu = L2CAP_PDU.from_bytes(pdu)
self.host.on_l2cap_pdu(self, l2cap_pdu.cid, l2cap_pdu.payload)
# -----------------------------------------------------------------------------
class Host(AbortableEventEmitter):
def __init__(self, controller_source=None, controller_sink=None):
connections: Dict[int, Connection]
acl_packet_queue: collections.deque[HCI_AclDataPacket]
hci_sink: TransportSink
long_term_key_provider: Optional[
Callable[[int, bytes, int], Awaitable[Optional[bytes]]]
]
link_key_provider: Optional[Callable[[Address], Awaitable[Optional[bytes]]]]
def __init__(
self,
controller_source: Optional[TransportSource] = None,
controller_sink: Optional[TransportSink] = None,
) -> None:
super().__init__()
self.hci_sink = None
self.hci_metadata = None
self.ready = False # True when we can accept incoming packets
self.reset_done = False
self.connections = {} # Connections, by connection handle
@@ -141,6 +161,9 @@ class Host(AbortableEventEmitter):
# Connect to the source and sink if specified
if controller_source:
controller_source.set_packet_sink(self)
self.hci_metadata = getattr(
controller_source, 'metadata', self.hci_metadata
)
if controller_sink:
self.set_packet_sink(controller_sink)
@@ -170,7 +193,7 @@ class Host(AbortableEventEmitter):
self.emit('flush')
self.command_semaphore.release()
async def reset(self):
async def reset(self, driver_factory=drivers.get_driver_for_host):
if self.ready:
self.ready = False
await self.flush()
@@ -178,6 +201,15 @@ class Host(AbortableEventEmitter):
await self.send_command(HCI_Reset_Command(), check_result=True)
self.ready = True
# Instantiate and init a driver for the host if needed.
# NOTE: we don't keep a reference to the driver here, because we don't
# currently have a need for the driver later on. But if the driver interface
# evolves, it may be required, then, to store a reference to the driver in
# an object property.
if driver_factory is not None:
if driver := await driver_factory(self):
await driver.init_controller()
response = await self.send_command(
HCI_Read_Local_Supported_Commands_Command(), check_result=True
)
@@ -282,7 +314,7 @@ class Host(AbortableEventEmitter):
self.reset_done = True
@property
def controller(self):
def controller(self) -> TransportSink:
return self.hci_sink
@controller.setter
@@ -291,14 +323,13 @@ class Host(AbortableEventEmitter):
if controller:
controller.set_packet_sink(self)
def set_packet_sink(self, sink):
def set_packet_sink(self, sink: TransportSink) -> None:
self.hci_sink = sink
def send_hci_packet(self, packet):
def send_hci_packet(self, packet: HCI_Packet) -> None:
if self.snooper:
self.snooper.snoop(bytes(packet), Snooper.Direction.HOST_TO_CONTROLLER)
self.hci_sink.on_packet(packet.to_bytes())
self.hci_sink.on_packet(bytes(packet))
async def send_command(self, command, check_result=False):
logger.debug(f'{color("### HOST -> CONTROLLER", "blue")}: {command}')
@@ -335,7 +366,7 @@ class Host(AbortableEventEmitter):
return response
except Exception as error:
logger.warning(
f'{color("!!! Exception while sending HCI packet:", "red")} {error}'
f'{color("!!! Exception while sending command:", "red")} {error}'
)
raise error
finally:
@@ -343,14 +374,14 @@ class Host(AbortableEventEmitter):
self.pending_response = None
# Use this method to send a command from a task
def send_command_sync(self, command):
async def send_command(command):
def send_command_sync(self, command: HCI_Command) -> None:
async def send_command(command: HCI_Command) -> None:
await self.send_command(command)
asyncio.create_task(send_command(command))
def send_l2cap_pdu(self, connection_handle, cid, pdu):
l2cap_pdu = L2CAP_PDU(cid, pdu).to_bytes()
def send_l2cap_pdu(self, connection_handle: int, cid: int, pdu: bytes) -> None:
l2cap_pdu = bytes(L2CAP_PDU(cid, pdu))
# Send the data to the controller via ACL packets
bytes_remaining = len(l2cap_pdu)
@@ -374,7 +405,7 @@ class Host(AbortableEventEmitter):
offset += data_total_length
bytes_remaining -= data_total_length
def queue_acl_packet(self, acl_packet):
def queue_acl_packet(self, acl_packet: HCI_AclDataPacket) -> None:
self.acl_packet_queue.appendleft(acl_packet)
self.check_acl_packet_queue()
@@ -384,7 +415,7 @@ class Host(AbortableEventEmitter):
f'{len(self.acl_packet_queue)} in queue'
)
def check_acl_packet_queue(self):
def check_acl_packet_queue(self) -> None:
# Send all we can (TODO: support different LE/Classic limits)
while (
len(self.acl_packet_queue) > 0
@@ -430,47 +461,53 @@ class Host(AbortableEventEmitter):
]
# Packet Sink protocol (packets coming from the controller via HCI)
def on_packet(self, packet):
def on_packet(self, packet: bytes) -> None:
hci_packet = HCI_Packet.from_bytes(packet)
if self.ready or (
hci_packet.hci_packet_type == HCI_EVENT_PACKET
and hci_packet.event_code == HCI_COMMAND_COMPLETE_EVENT
isinstance(hci_packet, HCI_Command_Complete_Event)
and hci_packet.command_opcode == HCI_RESET_COMMAND
):
self.on_hci_packet(hci_packet)
else:
logger.debug('reset not done, ignoring packet from controller')
def on_hci_packet(self, packet):
def on_transport_lost(self):
# Called by the source when the transport has been lost.
if self.pending_response:
self.pending_response.set_exception(TransportLostError('transport lost'))
self.emit('flush')
def on_hci_packet(self, packet: HCI_Packet) -> None:
logger.debug(f'{color("### CONTROLLER -> HOST", "green")}: {packet}')
if self.snooper:
self.snooper.snoop(bytes(packet), Snooper.Direction.CONTROLLER_TO_HOST)
# If the packet is a command, invoke the handler for this packet
if packet.hci_packet_type == HCI_COMMAND_PACKET:
if isinstance(packet, HCI_Command):
self.on_hci_command_packet(packet)
elif packet.hci_packet_type == HCI_EVENT_PACKET:
elif isinstance(packet, HCI_Event):
self.on_hci_event_packet(packet)
elif packet.hci_packet_type == HCI_ACL_DATA_PACKET:
elif isinstance(packet, HCI_AclDataPacket):
self.on_hci_acl_data_packet(packet)
else:
logger.warning(f'!!! unknown packet type {packet.hci_packet_type}')
def on_hci_command_packet(self, command):
def on_hci_command_packet(self, command: HCI_Command) -> None:
logger.warning(f'!!! unexpected command packet: {command}')
def on_hci_event_packet(self, event):
def on_hci_event_packet(self, event: HCI_Event) -> None:
handler_name = f'on_{event.name.lower()}'
handler = getattr(self, handler_name, self.on_hci_event)
handler(event)
def on_hci_acl_data_packet(self, packet):
def on_hci_acl_data_packet(self, packet: HCI_AclDataPacket) -> None:
# Look for the connection to which this data belongs
if connection := self.connections.get(packet.connection_handle):
connection.on_hci_acl_data_packet(packet)
def on_l2cap_pdu(self, connection, cid, pdu):
def on_l2cap_pdu(self, connection: Connection, cid: int, pdu: bytes) -> None:
self.emit('l2cap_pdu', connection.handle, cid, pdu)
def on_command_processed(self, event):
@@ -808,6 +845,10 @@ class Host(AbortableEventEmitter):
f'simple pairing complete for {event.bd_addr}: '
f'status={HCI_Constant.status_name(event.status)}'
)
if event.status == HCI_SUCCESS:
self.emit('classic_pairing', event.bd_addr)
else:
self.emit('classic_pairing_failure', event.bd_addr, event.status)
def on_hci_pin_code_request_event(self, event):
self.emit('pin_code_request', event.bd_addr)

File diff suppressed because it is too large Load Diff

View File

@@ -19,6 +19,7 @@ import enum
from typing import Optional, Tuple
from .hci import (
Address,
HCI_NO_INPUT_NO_OUTPUT_IO_CAPABILITY,
HCI_DISPLAY_ONLY_IO_CAPABILITY,
HCI_DISPLAY_YES_NO_IO_CAPABILITY,
@@ -168,21 +169,28 @@ class PairingDelegate:
class PairingConfig:
"""Configuration for the Pairing protocol."""
class AddressType(enum.IntEnum):
PUBLIC = Address.PUBLIC_DEVICE_ADDRESS
RANDOM = Address.RANDOM_DEVICE_ADDRESS
def __init__(
self,
sc: bool = True,
mitm: bool = True,
bonding: bool = True,
delegate: Optional[PairingDelegate] = None,
identity_address_type: Optional[AddressType] = None,
) -> None:
self.sc = sc
self.mitm = mitm
self.bonding = bonding
self.delegate = delegate or PairingDelegate()
self.identity_address_type = identity_address_type
def __str__(self) -> str:
return (
f'PairingConfig(sc={self.sc}, '
f'mitm={self.mitm}, bonding={self.bonding}, '
f'identity_address_type={self.identity_address_type}, '
f'delegate[{self.delegate.io_capability}])'
)

View File

@@ -12,7 +12,7 @@
# See the License for the specific language governing permissions and
# limitations under the License.
from bumble.pairing import PairingDelegate
from bumble.pairing import PairingConfig, PairingDelegate
from dataclasses import dataclass
from typing import Any, Dict
@@ -20,6 +20,7 @@ from typing import Any, Dict
@dataclass
class Config:
io_capability: PairingDelegate.IoCapability = PairingDelegate.NO_OUTPUT_NO_INPUT
identity_address_type: PairingConfig.AddressType = PairingConfig.AddressType.RANDOM
pairing_sc_enable: bool = True
pairing_mitm_enable: bool = True
pairing_bonding_enable: bool = True
@@ -35,6 +36,12 @@ class Config:
'io_capability', 'no_output_no_input'
).upper()
self.io_capability = getattr(PairingDelegate, io_capability_name)
identity_address_type_name: str = config.get(
'identity_address_type', 'random'
).upper()
self.identity_address_type = getattr(
PairingConfig.AddressType, identity_address_type_name
)
self.pairing_sc_enable = config.get('pairing_sc_enable', True)
self.pairing_mitm_enable = config.get('pairing_mitm_enable', True)
self.pairing_bonding_enable = config.get('pairing_bonding_enable', True)

View File

@@ -34,6 +34,10 @@ from bumble.sdp import (
from typing import Any, Dict, List, Optional
# Default rootcanal HCI TCP address
ROOTCANAL_HCI_ADDRESS = "localhost:6402"
class PandoraDevice:
"""
Small wrapper around a Bumble device and it's HCI transport.
@@ -53,7 +57,9 @@ class PandoraDevice:
def __init__(self, config: Dict[str, Any]) -> None:
self.config = config
self.device = _make_device(config)
self._hci_name = config.get('transport', '')
self._hci_name = config.get(
'transport', f"tcp-client:{config.get('tcp', ROOTCANAL_HCI_ADDRESS)}"
)
self._hci = None
@property

View File

@@ -112,7 +112,7 @@ class HostService(HostServicer):
async def FactoryReset(
self, request: empty_pb2.Empty, context: grpc.ServicerContext
) -> empty_pb2.Empty:
self.log.info('FactoryReset')
self.log.debug('FactoryReset')
# delete all bonds
if self.device.keystore is not None:
@@ -126,7 +126,7 @@ class HostService(HostServicer):
async def Reset(
self, request: empty_pb2.Empty, context: grpc.ServicerContext
) -> empty_pb2.Empty:
self.log.info('Reset')
self.log.debug('Reset')
# clear service.
self.waited_connections.clear()
@@ -139,7 +139,7 @@ class HostService(HostServicer):
async def ReadLocalAddress(
self, request: empty_pb2.Empty, context: grpc.ServicerContext
) -> ReadLocalAddressResponse:
self.log.info('ReadLocalAddress')
self.log.debug('ReadLocalAddress')
return ReadLocalAddressResponse(
address=bytes(reversed(bytes(self.device.public_address)))
)
@@ -152,7 +152,7 @@ class HostService(HostServicer):
address = Address(
bytes(reversed(request.address)), address_type=Address.PUBLIC_DEVICE_ADDRESS
)
self.log.info(f"Connect to {address}")
self.log.debug(f"Connect to {address}")
try:
connection = await self.device.connect(
@@ -167,7 +167,7 @@ class HostService(HostServicer):
return ConnectResponse(connection_already_exists=empty_pb2.Empty())
raise e
self.log.info(f"Connect to {address} done (handle={connection.handle})")
self.log.debug(f"Connect to {address} done (handle={connection.handle})")
cookie = any_pb2.Any(value=connection.handle.to_bytes(4, 'big'))
return ConnectResponse(connection=Connection(cookie=cookie))
@@ -186,7 +186,7 @@ class HostService(HostServicer):
if address in (Address.NIL, Address.ANY):
raise ValueError('Invalid address')
self.log.info(f"WaitConnection from {address}...")
self.log.debug(f"WaitConnection from {address}...")
connection = self.device.find_connection_by_bd_addr(
address, transport=BT_BR_EDR_TRANSPORT
@@ -201,7 +201,7 @@ class HostService(HostServicer):
# save connection has waited and respond.
self.waited_connections.add(id(connection))
self.log.info(
self.log.debug(
f"WaitConnection from {address} done (handle={connection.handle})"
)
@@ -216,7 +216,7 @@ class HostService(HostServicer):
if address in (Address.NIL, Address.ANY):
raise ValueError('Invalid address')
self.log.info(f"ConnectLE to {address}...")
self.log.debug(f"ConnectLE to {address}...")
try:
connection = await self.device.connect(
@@ -233,7 +233,7 @@ class HostService(HostServicer):
return ConnectLEResponse(connection_already_exists=empty_pb2.Empty())
raise e
self.log.info(f"ConnectLE to {address} done (handle={connection.handle})")
self.log.debug(f"ConnectLE to {address} done (handle={connection.handle})")
cookie = any_pb2.Any(value=connection.handle.to_bytes(4, 'big'))
return ConnectLEResponse(connection=Connection(cookie=cookie))
@@ -243,12 +243,12 @@ class HostService(HostServicer):
self, request: DisconnectRequest, context: grpc.ServicerContext
) -> empty_pb2.Empty:
connection_handle = int.from_bytes(request.connection.cookie.value, 'big')
self.log.info(f"Disconnect: {connection_handle}")
self.log.debug(f"Disconnect: {connection_handle}")
self.log.info("Disconnecting...")
self.log.debug("Disconnecting...")
if connection := self.device.lookup_connection(connection_handle):
await connection.disconnect(HCI_REMOTE_USER_TERMINATED_CONNECTION_ERROR)
self.log.info("Disconnected")
self.log.debug("Disconnected")
return empty_pb2.Empty()
@@ -257,7 +257,7 @@ class HostService(HostServicer):
self, request: WaitDisconnectionRequest, context: grpc.ServicerContext
) -> empty_pb2.Empty:
connection_handle = int.from_bytes(request.connection.cookie.value, 'big')
self.log.info(f"WaitDisconnection: {connection_handle}")
self.log.debug(f"WaitDisconnection: {connection_handle}")
if connection := self.device.lookup_connection(connection_handle):
disconnection_future: asyncio.Future[
@@ -270,7 +270,7 @@ class HostService(HostServicer):
connection.on('disconnection', on_disconnection)
try:
await disconnection_future
self.log.info("Disconnected")
self.log.debug("Disconnected")
finally:
connection.remove_listener('disconnection', on_disconnection) # type: ignore
@@ -378,7 +378,7 @@ class HostService(HostServicer):
try:
while True:
if not self.device.is_advertising:
self.log.info('Advertise')
self.log.debug('Advertise')
await self.device.start_advertising(
target=target,
advertising_type=advertising_type,
@@ -393,10 +393,10 @@ class HostService(HostServicer):
bumble.device.Connection
] = asyncio.get_running_loop().create_future()
self.log.info('Wait for LE connection...')
self.log.debug('Wait for LE connection...')
connection = await pending_connection
self.log.info(
self.log.debug(
f"Advertise: Connected to {connection.peer_address} (handle={connection.handle})"
)
@@ -410,7 +410,7 @@ class HostService(HostServicer):
self.device.remove_listener('connection', on_connection) # type: ignore
try:
self.log.info('Stop advertising')
self.log.debug('Stop advertising')
await self.device.abort_on('flush', self.device.stop_advertising())
except:
pass
@@ -423,7 +423,7 @@ class HostService(HostServicer):
if request.phys:
raise NotImplementedError("TODO: add support for `request.phys`")
self.log.info('Scan')
self.log.debug('Scan')
scan_queue: asyncio.Queue[Advertisement] = asyncio.Queue()
handler = self.device.on('advertisement', scan_queue.put_nowait)
@@ -470,7 +470,7 @@ class HostService(HostServicer):
finally:
self.device.remove_listener('advertisement', handler) # type: ignore
try:
self.log.info('Stop scanning')
self.log.debug('Stop scanning')
await self.device.abort_on('flush', self.device.stop_scanning())
except:
pass
@@ -479,7 +479,7 @@ class HostService(HostServicer):
async def Inquiry(
self, request: empty_pb2.Empty, context: grpc.ServicerContext
) -> AsyncGenerator[InquiryResponse, None]:
self.log.info('Inquiry')
self.log.debug('Inquiry')
inquiry_queue: asyncio.Queue[
Optional[Tuple[Address, int, AdvertisingData, int]]
@@ -510,7 +510,7 @@ class HostService(HostServicer):
self.device.remove_listener('inquiry_complete', complete_handler) # type: ignore
self.device.remove_listener('inquiry_result', result_handler) # type: ignore
try:
self.log.info('Stop inquiry')
self.log.debug('Stop inquiry')
await self.device.abort_on('flush', self.device.stop_discovery())
except:
pass
@@ -519,7 +519,7 @@ class HostService(HostServicer):
async def SetDiscoverabilityMode(
self, request: SetDiscoverabilityModeRequest, context: grpc.ServicerContext
) -> empty_pb2.Empty:
self.log.info("SetDiscoverabilityMode")
self.log.debug("SetDiscoverabilityMode")
await self.device.set_discoverable(request.mode != NOT_DISCOVERABLE)
return empty_pb2.Empty()
@@ -527,7 +527,7 @@ class HostService(HostServicer):
async def SetConnectabilityMode(
self, request: SetConnectabilityModeRequest, context: grpc.ServicerContext
) -> empty_pb2.Empty:
self.log.info("SetConnectabilityMode")
self.log.debug("SetConnectabilityMode")
await self.device.set_connectable(request.mode != NOT_CONNECTABLE)
return empty_pb2.Empty()

View File

@@ -13,6 +13,7 @@
# limitations under the License.
import asyncio
import contextlib
import grpc
import logging
@@ -27,8 +28,8 @@ from bumble.core import (
)
from bumble.device import Connection as BumbleConnection, Device
from bumble.hci import HCI_Error
from bumble.utils import EventWatcher
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 empty_pb2 # pytype: disable=pyi-error
from google.protobuf import wrappers_pb2 # pytype: disable=pyi-error
@@ -99,7 +100,7 @@ class PairingDelegate(BasePairingDelegate):
return ev
async def confirm(self, auto: bool = False) -> bool:
self.log.info(
self.log.debug(
f"Pairing event: `just_works` (io_capability: {self.io_capability})"
)
@@ -114,7 +115,7 @@ class PairingDelegate(BasePairingDelegate):
return answer.confirm
async def compare_numbers(self, number: int, digits: int = 6) -> bool:
self.log.info(
self.log.debug(
f"Pairing event: `numeric_comparison` (io_capability: {self.io_capability})"
)
@@ -129,7 +130,7 @@ class PairingDelegate(BasePairingDelegate):
return answer.confirm
async def get_number(self) -> Optional[int]:
self.log.info(
self.log.debug(
f"Pairing event: `passkey_entry_request` (io_capability: {self.io_capability})"
)
@@ -146,7 +147,7 @@ class PairingDelegate(BasePairingDelegate):
return answer.passkey
async def get_string(self, max_length: int) -> Optional[str]:
self.log.info(
self.log.debug(
f"Pairing event: `pin_code_request` (io_capability: {self.io_capability})"
)
@@ -177,7 +178,7 @@ class PairingDelegate(BasePairingDelegate):
):
return
self.log.info(
self.log.debug(
f"Pairing event: `passkey_entry_notification` (io_capability: {self.io_capability})"
)
@@ -232,6 +233,11 @@ class SecurityService(SecurityServicer):
sc=config.pairing_sc_enable,
mitm=config.pairing_mitm_enable,
bonding=config.pairing_bonding_enable,
identity_address_type=(
PairingConfig.AddressType.PUBLIC
if connection.self_address.is_public
else config.identity_address_type
),
delegate=PairingDelegate(
connection,
self,
@@ -247,7 +253,7 @@ class SecurityService(SecurityServicer):
async def OnPairing(
self, request: AsyncIterator[PairingEventAnswer], context: grpc.ServicerContext
) -> AsyncGenerator[PairingEvent, None]:
self.log.info('OnPairing')
self.log.debug('OnPairing')
if self.event_queue is not None:
raise RuntimeError('already streaming pairing events')
@@ -273,7 +279,7 @@ class SecurityService(SecurityServicer):
self, request: SecureRequest, context: grpc.ServicerContext
) -> SecureResponse:
connection_handle = int.from_bytes(request.connection.cookie.value, 'big')
self.log.info(f"Secure: {connection_handle}")
self.log.debug(f"Secure: {connection_handle}")
connection = self.device.lookup_connection(connection_handle)
assert connection
@@ -291,25 +297,37 @@ class SecurityService(SecurityServicer):
# trigger pairing if needed
if self.need_pairing(connection, level):
try:
self.log.info('Pair...')
self.log.debug('Pair...')
if (
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)
security_result = asyncio.get_running_loop().create_future()
connection.request_pairing()
with contextlib.closing(EventWatcher()) as watcher:
await wait_for_security
else:
await connection.pair()
@watcher.on(connection, 'pairing')
def on_pairing(*_: Any) -> None:
security_result.set_result('success')
self.log.info('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:
self.log.warning("Connection died during encryption")
return SecureResponse(connection_died=empty_pb2.Empty())
@@ -320,9 +338,9 @@ class SecurityService(SecurityServicer):
# trigger authentication if needed
if self.need_authentication(connection, level):
try:
self.log.info('Authenticate...')
self.log.debug('Authenticate...')
await connection.authenticate()
self.log.info('Authenticated')
self.log.debug('Authenticated')
except asyncio.CancelledError:
self.log.warning("Connection died during authentication")
return SecureResponse(connection_died=empty_pb2.Empty())
@@ -333,9 +351,9 @@ class SecurityService(SecurityServicer):
# trigger encryption if needed
if self.need_encryption(connection, level):
try:
self.log.info('Encrypt...')
self.log.debug('Encrypt...')
await connection.encrypt()
self.log.info('Encrypted')
self.log.debug('Encrypted')
except asyncio.CancelledError:
self.log.warning("Connection died during encryption")
return SecureResponse(connection_died=empty_pb2.Empty())
@@ -353,7 +371,7 @@ class SecurityService(SecurityServicer):
self, request: WaitSecurityRequest, context: grpc.ServicerContext
) -> WaitSecurityResponse:
connection_handle = int.from_bytes(request.connection.cookie.value, 'big')
self.log.info(f"WaitSecurity: {connection_handle}")
self.log.debug(f"WaitSecurity: {connection_handle}")
connection = self.device.lookup_connection(connection_handle)
assert connection
@@ -368,6 +386,7 @@ class SecurityService(SecurityServicer):
str
] = asyncio.get_running_loop().create_future()
authenticate_task: Optional[asyncio.Future[None]] = None
pair_task: Optional[asyncio.Future[None]] = None
async def authenticate() -> None:
assert connection
@@ -390,7 +409,7 @@ class SecurityService(SecurityServicer):
def set_failure(name: str) -> Callable[..., None]:
def wrapper(*args: Any) -> None:
self.log.info(f'Wait for security: error `{name}`: {args}')
self.log.debug(f'Wait for security: error `{name}`: {args}')
wait_for_security.set_result(name)
return wrapper
@@ -398,13 +417,13 @@ class SecurityService(SecurityServicer):
def try_set_success(*_: Any) -> None:
assert connection
if self.reached_security_level(connection, level):
self.log.info('Wait for security: done')
self.log.debug('Wait for security: done')
wait_for_security.set_result('success')
def on_encryption_change(*_: Any) -> None:
assert connection
if self.reached_security_level(connection, level):
self.log.info('Wait for security: done')
self.log.debug('Wait for security: done')
wait_for_security.set_result('success')
elif (
connection.transport == BT_BR_EDR_TRANSPORT
@@ -414,6 +433,10 @@ class SecurityService(SecurityServicer):
if authenticate_task is None:
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]] = {
'disconnection': set_failure('connection_died'),
'pairing_failure': set_failure('pairing_failure'),
@@ -422,6 +445,9 @@ class SecurityService(SecurityServicer):
'pairing': try_set_success,
'connection_authentication': try_set_success,
'connection_encryption_change': on_encryption_change,
'classic_pairing': try_set_success,
'classic_pairing_failure': set_failure('pairing_failure'),
'security_request': pair,
}
# register event handlers
@@ -432,7 +458,7 @@ class SecurityService(SecurityServicer):
if self.reached_security_level(connection, level):
return WaitSecurityResponse(success=empty_pb2.Empty())
self.log.info('Wait for security...')
self.log.debug('Wait for security...')
kwargs = {}
kwargs[await wait_for_security] = empty_pb2.Empty()
@@ -442,12 +468,21 @@ class SecurityService(SecurityServicer):
# wait for `authenticate` to finish if any
if authenticate_task is not None:
self.log.info('Wait for authentication...')
self.log.debug('Wait for authentication...')
try:
await authenticate_task # type: ignore
except:
pass
self.log.info('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)
@@ -503,7 +538,7 @@ class SecurityStorageService(SecurityStorageServicer):
self, request: IsBondedRequest, context: grpc.ServicerContext
) -> wrappers_pb2.BoolValue:
address = utils.address_from_request(request, request.WhichOneof("address"))
self.log.info(f"IsBonded: {address}")
self.log.debug(f"IsBonded: {address}")
if self.device.keystore is not None:
is_bonded = await self.device.keystore.get(str(address)) is not None
@@ -517,10 +552,10 @@ class SecurityStorageService(SecurityStorageServicer):
self, request: DeleteBondRequest, context: grpc.ServicerContext
) -> empty_pb2.Empty:
address = utils.address_from_request(request, request.WhichOneof("address"))
self.log.info(f"DeleteBond: {address}")
self.log.debug(f"DeleteBond: {address}")
if self.device.keystore is not None:
with suppress(KeyError):
with contextlib.suppress(KeyError):
await self.device.keystore.delete(str(address))
return empty_pb2.Empty()

View File

@@ -15,15 +15,37 @@
# -----------------------------------------------------------------------------
# Imports
# -----------------------------------------------------------------------------
from __future__ import annotations
import logging
import asyncio
import enum
from typing import Callable, Dict, List, Optional, Tuple, Union, TYPE_CHECKING
from pyee import EventEmitter
from typing import Optional, Tuple, Callable, Dict, Union
from . import core, l2cap
from .colors import color
from .core import BT_BR_EDR_TRANSPORT, InvalidStateError, ProtocolError
from .core import (
UUID,
BT_RFCOMM_PROTOCOL_ID,
BT_BR_EDR_TRANSPORT,
BT_L2CAP_PROTOCOL_ID,
InvalidStateError,
ProtocolError,
)
from .sdp import (
SDP_SERVICE_RECORD_HANDLE_ATTRIBUTE_ID,
SDP_BROWSE_GROUP_LIST_ATTRIBUTE_ID,
SDP_SERVICE_CLASS_ID_LIST_ATTRIBUTE_ID,
SDP_PROTOCOL_DESCRIPTOR_LIST_ATTRIBUTE_ID,
SDP_PUBLIC_BROWSE_ROOT,
DataElement,
ServiceAttribute,
)
if TYPE_CHECKING:
from bumble.device import Device, Connection
# -----------------------------------------------------------------------------
# Logging
@@ -105,6 +127,50 @@ RFCOMM_DYNAMIC_CHANNEL_NUMBER_END = 30
# fmt: on
# -----------------------------------------------------------------------------
def make_service_sdp_records(
service_record_handle: int, channel: int, uuid: Optional[UUID] = None
) -> List[ServiceAttribute]:
"""
Create SDP records for an RFComm service given a channel number and an
optional UUID. A Service Class Attribute is included only if the UUID is not None.
"""
records = [
ServiceAttribute(
SDP_SERVICE_RECORD_HANDLE_ATTRIBUTE_ID,
DataElement.unsigned_integer_32(service_record_handle),
),
ServiceAttribute(
SDP_BROWSE_GROUP_LIST_ATTRIBUTE_ID,
DataElement.sequence([DataElement.uuid(SDP_PUBLIC_BROWSE_ROOT)]),
),
ServiceAttribute(
SDP_PROTOCOL_DESCRIPTOR_LIST_ATTRIBUTE_ID,
DataElement.sequence(
[
DataElement.sequence([DataElement.uuid(BT_L2CAP_PROTOCOL_ID)]),
DataElement.sequence(
[
DataElement.uuid(BT_RFCOMM_PROTOCOL_ID),
DataElement.unsigned_integer_8(channel),
]
),
]
),
),
]
if uuid:
records.append(
ServiceAttribute(
SDP_SERVICE_CLASS_ID_LIST_ATTRIBUTE_ID,
DataElement.sequence([DataElement.uuid(uuid)]),
)
)
return records
# -----------------------------------------------------------------------------
def compute_fcs(buffer: bytes) -> int:
result = 0xFF
@@ -149,9 +215,9 @@ class RFCOMM_Frame:
return RFCOMM_FRAME_TYPE_NAMES[self.type]
@staticmethod
def parse_mcc(data) -> Tuple[int, int, bytes]:
def parse_mcc(data) -> Tuple[int, bool, bytes]:
mcc_type = data[0] >> 2
c_r = (data[0] >> 1) & 1
c_r = bool((data[0] >> 1) & 1)
length = data[1]
if data[1] & 1:
length >>= 1
@@ -192,7 +258,7 @@ class RFCOMM_Frame:
)
@staticmethod
def from_bytes(data: bytes):
def from_bytes(data: bytes) -> RFCOMM_Frame:
# Extract fields
dlci = (data[0] >> 2) & 0x3F
c_r = (data[0] >> 1) & 0x01
@@ -215,7 +281,7 @@ class RFCOMM_Frame:
return frame
def __bytes__(self):
def __bytes__(self) -> bytes:
return (
bytes([self.address, self.control])
+ self.length
@@ -223,7 +289,7 @@ class RFCOMM_Frame:
+ bytes([self.fcs])
)
def __str__(self):
def __str__(self) -> str:
return (
f'{color(self.type_name(), "yellow")}'
f'(c/r={self.c_r},'
@@ -253,7 +319,7 @@ class RFCOMM_MCC_PN:
max_frame_size: int,
max_retransmissions: int,
window_size: int,
):
) -> None:
self.dlci = dlci
self.cl = cl
self.priority = priority
@@ -263,7 +329,7 @@ class RFCOMM_MCC_PN:
self.window_size = window_size
@staticmethod
def from_bytes(data: bytes):
def from_bytes(data: bytes) -> RFCOMM_MCC_PN:
return RFCOMM_MCC_PN(
dlci=data[0],
cl=data[1],
@@ -274,7 +340,7 @@ class RFCOMM_MCC_PN:
window_size=data[7],
)
def __bytes__(self):
def __bytes__(self) -> bytes:
return bytes(
[
self.dlci & 0xFF,
@@ -288,7 +354,7 @@ class RFCOMM_MCC_PN:
]
)
def __str__(self):
def __str__(self) -> str:
return (
f'PN(dlci={self.dlci},'
f'cl={self.cl},'
@@ -309,7 +375,9 @@ class RFCOMM_MCC_MSC:
ic: int
dv: int
def __init__(self, dlci: int, fc: int, rtc: int, rtr: int, ic: int, dv: int):
def __init__(
self, dlci: int, fc: int, rtc: int, rtr: int, ic: int, dv: int
) -> None:
self.dlci = dlci
self.fc = fc
self.rtc = rtc
@@ -318,7 +386,7 @@ class RFCOMM_MCC_MSC:
self.dv = dv
@staticmethod
def from_bytes(data: bytes):
def from_bytes(data: bytes) -> RFCOMM_MCC_MSC:
return RFCOMM_MCC_MSC(
dlci=data[0] >> 2,
fc=data[1] >> 1 & 1,
@@ -328,7 +396,7 @@ class RFCOMM_MCC_MSC:
dv=data[1] >> 7 & 1,
)
def __bytes__(self):
def __bytes__(self) -> bytes:
return bytes(
[
(self.dlci << 2) | 3,
@@ -341,7 +409,7 @@ class RFCOMM_MCC_MSC:
]
)
def __str__(self):
def __str__(self) -> str:
return (
f'MSC(dlci={self.dlci},'
f'fc={self.fc},'
@@ -354,29 +422,24 @@ class RFCOMM_MCC_MSC:
# -----------------------------------------------------------------------------
class DLC(EventEmitter):
# States
INIT = 0x00
CONNECTING = 0x01
CONNECTED = 0x02
DISCONNECTING = 0x03
DISCONNECTED = 0x04
RESET = 0x05
STATE_NAMES = {
INIT: 'INIT',
CONNECTING: 'CONNECTING',
CONNECTED: 'CONNECTED',
DISCONNECTING: 'DISCONNECTING',
DISCONNECTED: 'DISCONNECTED',
RESET: 'RESET',
}
class State(enum.IntEnum):
INIT = 0x00
CONNECTING = 0x01
CONNECTED = 0x02
DISCONNECTING = 0x03
DISCONNECTED = 0x04
RESET = 0x05
connection_result: Optional[asyncio.Future]
sink: Optional[Callable[[bytes], None]]
def __init__(
self, multiplexer, dlci: int, max_frame_size: int, initial_tx_credits: int
):
self,
multiplexer: Multiplexer,
dlci: int,
max_frame_size: int,
initial_tx_credits: int,
) -> None:
super().__init__()
self.multiplexer = multiplexer
self.dlci = dlci
@@ -384,9 +447,9 @@ class DLC(EventEmitter):
self.rx_threshold = self.rx_credits // 2
self.tx_credits = initial_tx_credits
self.tx_buffer = b''
self.state = DLC.INIT
self.state = DLC.State.INIT
self.role = multiplexer.role
self.c_r = 1 if self.role == Multiplexer.INITIATOR else 0
self.c_r = 1 if self.role == Multiplexer.Role.INITIATOR else 0
self.sink = None
self.connection_result = None
@@ -396,14 +459,8 @@ class DLC(EventEmitter):
max_frame_size, self.multiplexer.l2cap_channel.mtu - max_overhead
)
@staticmethod
def state_name(state: int) -> str:
return DLC.STATE_NAMES[state]
def change_state(self, new_state: int) -> None:
logger.debug(
f'{self} state change -> {color(self.state_name(new_state), "magenta")}'
)
def change_state(self, new_state: State) -> None:
logger.debug(f'{self} state change -> {color(new_state.name, "magenta")}')
self.state = new_state
def send_frame(self, frame: RFCOMM_Frame) -> None:
@@ -413,8 +470,8 @@ class DLC(EventEmitter):
handler = getattr(self, f'on_{frame.type_name()}_frame'.lower())
handler(frame)
def on_sabm_frame(self, _frame) -> None:
if self.state != DLC.CONNECTING:
def on_sabm_frame(self, _frame: RFCOMM_Frame) -> None:
if self.state != DLC.State.CONNECTING:
logger.warning(
color('!!! received SABM when not in CONNECTING state', 'red')
)
@@ -430,11 +487,11 @@ class DLC(EventEmitter):
logger.debug(f'>>> MCC MSC Command: {msc}')
self.send_frame(RFCOMM_Frame.uih(c_r=self.c_r, dlci=0, information=mcc))
self.change_state(DLC.CONNECTED)
self.change_state(DLC.State.CONNECTED)
self.emit('open')
def on_ua_frame(self, _frame) -> None:
if self.state != DLC.CONNECTING:
def on_ua_frame(self, _frame: RFCOMM_Frame) -> None:
if self.state != DLC.State.CONNECTING:
logger.warning(
color('!!! received SABM when not in CONNECTING state', 'red')
)
@@ -448,14 +505,14 @@ class DLC(EventEmitter):
logger.debug(f'>>> MCC MSC Command: {msc}')
self.send_frame(RFCOMM_Frame.uih(c_r=self.c_r, dlci=0, information=mcc))
self.change_state(DLC.CONNECTED)
self.change_state(DLC.State.CONNECTED)
self.multiplexer.on_dlc_open_complete(self)
def on_dm_frame(self, frame) -> None:
def on_dm_frame(self, frame: RFCOMM_Frame) -> None:
# TODO: handle all states
pass
def on_disc_frame(self, _frame) -> None:
def on_disc_frame(self, _frame: RFCOMM_Frame) -> None:
# TODO: handle all states
self.send_frame(RFCOMM_Frame.ua(c_r=1 - self.c_r, dlci=self.dlci))
@@ -489,10 +546,10 @@ class DLC(EventEmitter):
# Check if there's anything to send (including credits)
self.process_tx()
def on_ui_frame(self, frame) -> None:
def on_ui_frame(self, frame: RFCOMM_Frame) -> None:
pass
def on_mcc_msc(self, c_r, msc) -> None:
def on_mcc_msc(self, c_r: bool, msc: RFCOMM_MCC_MSC) -> None:
if c_r:
# Command
logger.debug(f'<<< MCC MSC Command: {msc}')
@@ -507,15 +564,15 @@ class DLC(EventEmitter):
logger.debug(f'<<< MCC MSC Response: {msc}')
def connect(self) -> None:
if self.state != DLC.INIT:
if self.state != DLC.State.INIT:
raise InvalidStateError('invalid state')
self.change_state(DLC.CONNECTING)
self.change_state(DLC.State.CONNECTING)
self.connection_result = asyncio.get_running_loop().create_future()
self.send_frame(RFCOMM_Frame.sabm(c_r=self.c_r, dlci=self.dlci))
def accept(self) -> None:
if self.state != DLC.INIT:
if self.state != DLC.State.INIT:
raise InvalidStateError('invalid state')
pn = RFCOMM_MCC_PN(
@@ -530,7 +587,7 @@ class DLC(EventEmitter):
mcc = RFCOMM_Frame.make_mcc(mcc_type=RFCOMM_MCC_PN_TYPE, c_r=0, data=bytes(pn))
logger.debug(f'>>> PN Response: {pn}')
self.send_frame(RFCOMM_Frame.uih(c_r=self.c_r, dlci=0, information=mcc))
self.change_state(DLC.CONNECTING)
self.change_state(DLC.State.CONNECTING)
def rx_credits_needed(self) -> int:
if self.rx_credits <= self.rx_threshold:
@@ -592,34 +649,24 @@ class DLC(EventEmitter):
# TODO
pass
def __str__(self):
return f'DLC(dlci={self.dlci},state={self.state_name(self.state)})'
def __str__(self) -> str:
return f'DLC(dlci={self.dlci},state={self.state.name})'
# -----------------------------------------------------------------------------
class Multiplexer(EventEmitter):
# Roles
INITIATOR = 0x00
RESPONDER = 0x01
class Role(enum.IntEnum):
INITIATOR = 0x00
RESPONDER = 0x01
# States
INIT = 0x00
CONNECTING = 0x01
CONNECTED = 0x02
OPENING = 0x03
DISCONNECTING = 0x04
DISCONNECTED = 0x05
RESET = 0x06
STATE_NAMES = {
INIT: 'INIT',
CONNECTING: 'CONNECTING',
CONNECTED: 'CONNECTED',
OPENING: 'OPENING',
DISCONNECTING: 'DISCONNECTING',
DISCONNECTED: 'DISCONNECTED',
RESET: 'RESET',
}
class State(enum.IntEnum):
INIT = 0x00
CONNECTING = 0x01
CONNECTED = 0x02
OPENING = 0x03
DISCONNECTING = 0x04
DISCONNECTED = 0x05
RESET = 0x06
connection_result: Optional[asyncio.Future]
disconnection_result: Optional[asyncio.Future]
@@ -627,11 +674,11 @@ class Multiplexer(EventEmitter):
acceptor: Optional[Callable[[int], bool]]
dlcs: Dict[int, DLC]
def __init__(self, l2cap_channel: l2cap.Channel, role: int) -> None:
def __init__(self, l2cap_channel: l2cap.Channel, role: Role) -> None:
super().__init__()
self.role = role
self.l2cap_channel = l2cap_channel
self.state = Multiplexer.INIT
self.state = Multiplexer.State.INIT
self.dlcs = {} # DLCs, by DLCI
self.connection_result = None
self.disconnection_result = None
@@ -641,14 +688,8 @@ class Multiplexer(EventEmitter):
# Become a sink for the L2CAP channel
l2cap_channel.sink = self.on_pdu
@staticmethod
def state_name(state: int):
return Multiplexer.STATE_NAMES[state]
def change_state(self, new_state: int) -> None:
logger.debug(
f'{self} state change -> {color(self.state_name(new_state), "cyan")}'
)
def change_state(self, new_state: State) -> None:
logger.debug(f'{self} state change -> {color(new_state.name, "cyan")}')
self.state = new_state
def send_frame(self, frame: RFCOMM_Frame) -> None:
@@ -679,28 +720,28 @@ class Multiplexer(EventEmitter):
handler = getattr(self, f'on_{frame.type_name()}_frame'.lower())
handler(frame)
def on_sabm_frame(self, _frame) -> None:
if self.state != Multiplexer.INIT:
def on_sabm_frame(self, _frame: RFCOMM_Frame) -> None:
if self.state != Multiplexer.State.INIT:
logger.debug('not in INIT state, ignoring SABM')
return
self.change_state(Multiplexer.CONNECTED)
self.change_state(Multiplexer.State.CONNECTED)
self.send_frame(RFCOMM_Frame.ua(c_r=1, dlci=0))
def on_ua_frame(self, _frame) -> None:
if self.state == Multiplexer.CONNECTING:
self.change_state(Multiplexer.CONNECTED)
def on_ua_frame(self, _frame: RFCOMM_Frame) -> None:
if self.state == Multiplexer.State.CONNECTING:
self.change_state(Multiplexer.State.CONNECTED)
if self.connection_result:
self.connection_result.set_result(0)
self.connection_result = None
elif self.state == Multiplexer.DISCONNECTING:
self.change_state(Multiplexer.DISCONNECTED)
elif self.state == Multiplexer.State.DISCONNECTING:
self.change_state(Multiplexer.State.DISCONNECTED)
if self.disconnection_result:
self.disconnection_result.set_result(None)
self.disconnection_result = None
def on_dm_frame(self, _frame) -> None:
if self.state == Multiplexer.OPENING:
self.change_state(Multiplexer.CONNECTED)
def on_dm_frame(self, _frame: RFCOMM_Frame) -> None:
if self.state == Multiplexer.State.OPENING:
self.change_state(Multiplexer.State.CONNECTED)
if self.open_result:
self.open_result.set_exception(
core.ConnectionError(
@@ -713,10 +754,12 @@ class Multiplexer(EventEmitter):
else:
logger.warning(f'unexpected state for DM: {self}')
def on_disc_frame(self, _frame) -> None:
self.change_state(Multiplexer.DISCONNECTED)
def on_disc_frame(self, _frame: RFCOMM_Frame) -> None:
self.change_state(Multiplexer.State.DISCONNECTED)
self.send_frame(
RFCOMM_Frame.ua(c_r=0 if self.role == Multiplexer.INITIATOR else 1, dlci=0)
RFCOMM_Frame.ua(
c_r=0 if self.role == Multiplexer.Role.INITIATOR else 1, dlci=0
)
)
def on_uih_frame(self, frame: RFCOMM_Frame) -> None:
@@ -729,11 +772,11 @@ class Multiplexer(EventEmitter):
mcs = RFCOMM_MCC_MSC.from_bytes(value)
self.on_mcc_msc(c_r, mcs)
def on_ui_frame(self, frame) -> None:
def on_ui_frame(self, frame: RFCOMM_Frame) -> None:
pass
def on_mcc_pn(self, c_r, pn) -> None:
if c_r == 1:
def on_mcc_pn(self, c_r: bool, pn: RFCOMM_MCC_PN) -> None:
if c_r:
# Command
logger.debug(f'<<< PN Command: {pn}')
@@ -764,14 +807,14 @@ class Multiplexer(EventEmitter):
else:
# Response
logger.debug(f'>>> PN Response: {pn}')
if self.state == Multiplexer.OPENING:
if self.state == Multiplexer.State.OPENING:
dlc = DLC(self, pn.dlci, pn.max_frame_size, pn.window_size)
self.dlcs[pn.dlci] = dlc
dlc.connect()
else:
logger.warning('ignoring PN response')
def on_mcc_msc(self, c_r, msc) -> None:
def on_mcc_msc(self, c_r: bool, msc: RFCOMM_MCC_MSC) -> None:
dlc = self.dlcs.get(msc.dlci)
if dlc is None:
logger.warning(f'no dlc for DLCI {msc.dlci}')
@@ -779,30 +822,30 @@ class Multiplexer(EventEmitter):
dlc.on_mcc_msc(c_r, msc)
async def connect(self) -> None:
if self.state != Multiplexer.INIT:
if self.state != Multiplexer.State.INIT:
raise InvalidStateError('invalid state')
self.change_state(Multiplexer.CONNECTING)
self.change_state(Multiplexer.State.CONNECTING)
self.connection_result = asyncio.get_running_loop().create_future()
self.send_frame(RFCOMM_Frame.sabm(c_r=1, dlci=0))
return await self.connection_result
async def disconnect(self) -> None:
if self.state != Multiplexer.CONNECTED:
if self.state != Multiplexer.State.CONNECTED:
return
self.disconnection_result = asyncio.get_running_loop().create_future()
self.change_state(Multiplexer.DISCONNECTING)
self.change_state(Multiplexer.State.DISCONNECTING)
self.send_frame(
RFCOMM_Frame.disc(
c_r=1 if self.role == Multiplexer.INITIATOR else 0, dlci=0
c_r=1 if self.role == Multiplexer.Role.INITIATOR else 0, dlci=0
)
)
await self.disconnection_result
async def open_dlc(self, channel: int) -> DLC:
if self.state != Multiplexer.CONNECTED:
if self.state == Multiplexer.OPENING:
if self.state != Multiplexer.State.CONNECTED:
if self.state == Multiplexer.State.OPENING:
raise InvalidStateError('open already in progress')
raise InvalidStateError('not connected')
@@ -819,10 +862,10 @@ class Multiplexer(EventEmitter):
mcc = RFCOMM_Frame.make_mcc(mcc_type=RFCOMM_MCC_PN_TYPE, c_r=1, data=bytes(pn))
logger.debug(f'>>> Sending MCC: {pn}')
self.open_result = asyncio.get_running_loop().create_future()
self.change_state(Multiplexer.OPENING)
self.change_state(Multiplexer.State.OPENING)
self.send_frame(
RFCOMM_Frame.uih(
c_r=1 if self.role == Multiplexer.INITIATOR else 0,
c_r=1 if self.role == Multiplexer.Role.INITIATOR else 0,
dlci=0,
information=mcc,
)
@@ -831,14 +874,14 @@ class Multiplexer(EventEmitter):
self.open_result = None
return result
def on_dlc_open_complete(self, dlc: DLC):
def on_dlc_open_complete(self, dlc: DLC) -> None:
logger.debug(f'DLC [{dlc.dlci}] open complete')
self.change_state(Multiplexer.CONNECTED)
self.change_state(Multiplexer.State.CONNECTED)
if self.open_result:
self.open_result.set_result(dlc)
def __str__(self):
return f'Multiplexer(state={self.state_name(self.state)})'
def __str__(self) -> str:
return f'Multiplexer(state={self.state.name})'
# -----------------------------------------------------------------------------
@@ -846,7 +889,7 @@ class Client:
multiplexer: Optional[Multiplexer]
l2cap_channel: Optional[l2cap.Channel]
def __init__(self, device, connection) -> None:
def __init__(self, device: Device, connection: Connection) -> None:
self.device = device
self.connection = connection
self.l2cap_channel = None
@@ -864,7 +907,7 @@ class Client:
assert self.l2cap_channel is not None
# Create a mutliplexer to manage DLCs with the server
self.multiplexer = Multiplexer(self.l2cap_channel, Multiplexer.INITIATOR)
self.multiplexer = Multiplexer(self.l2cap_channel, Multiplexer.Role.INITIATOR)
# Connect the multiplexer
await self.multiplexer.connect()
@@ -886,7 +929,7 @@ class Client:
class Server(EventEmitter):
acceptors: Dict[int, Callable[[DLC], None]]
def __init__(self, device) -> None:
def __init__(self, device: Device) -> None:
super().__init__()
self.device = device
self.multiplexer = None
@@ -925,7 +968,7 @@ class Server(EventEmitter):
logger.debug(f'$$$ L2CAP channel open: {l2cap_channel}')
# Create a new multiplexer for the channel
multiplexer = Multiplexer(l2cap_channel, Multiplexer.RESPONDER)
multiplexer = Multiplexer(l2cap_channel, Multiplexer.Role.RESPONDER)
multiplexer.acceptor = self.accept_dlc
multiplexer.on('dlc', self.on_dlc)

View File

@@ -18,13 +18,16 @@
from __future__ import annotations
import logging
import struct
from typing import Dict, List, Type
from typing import Dict, List, Type, Optional, Tuple, Union, NewType, TYPE_CHECKING
from . import core
from . import core, l2cap
from .colors import color
from .core import InvalidStateError
from .hci import HCI_Object, name_or_number, key_with_value
if TYPE_CHECKING:
from .device import Device, Connection
# -----------------------------------------------------------------------------
# Logging
# -----------------------------------------------------------------------------
@@ -94,6 +97,10 @@ SDP_CLIENT_EXECUTABLE_URL_ATTRIBUTE_ID = 0X000B
SDP_ICON_URL_ATTRIBUTE_ID = 0X000C
SDP_ADDITIONAL_PROTOCOL_DESCRIPTOR_LIST_ATTRIBUTE_ID = 0X000D
# Attribute Identifier (cf. Assigned Numbers for Service Discovery)
# used by AVRCP, HFP and A2DP
SDP_SUPPORTED_FEATURES_ATTRIBUTE_ID = 0x0311
SDP_ATTRIBUTE_ID_NAMES = {
SDP_SERVICE_RECORD_HANDLE_ATTRIBUTE_ID: 'SDP_SERVICE_RECORD_HANDLE_ATTRIBUTE_ID',
SDP_SERVICE_CLASS_ID_LIST_ATTRIBUTE_ID: 'SDP_SERVICE_CLASS_ID_LIST_ATTRIBUTE_ID',
@@ -462,7 +469,7 @@ class ServiceAttribute:
self.value = value
@staticmethod
def list_from_data_elements(elements):
def list_from_data_elements(elements: List[DataElement]) -> List[ServiceAttribute]:
attribute_list = []
for i in range(0, len(elements) // 2):
attribute_id, attribute_value = elements[2 * i : 2 * (i + 1)]
@@ -474,7 +481,9 @@ class ServiceAttribute:
return attribute_list
@staticmethod
def find_attribute_in_list(attribute_list, attribute_id):
def find_attribute_in_list(
attribute_list: List[ServiceAttribute], attribute_id: int
) -> Optional[DataElement]:
return next(
(
attribute.value
@@ -489,7 +498,7 @@ class ServiceAttribute:
return name_or_number(SDP_ATTRIBUTE_ID_NAMES, id_code)
@staticmethod
def is_uuid_in_value(uuid, value):
def is_uuid_in_value(uuid: core.UUID, value: DataElement) -> bool:
# Find if a uuid matches a value, either directly or recursing into sequences
if value.type == DataElement.UUID:
return value.value == uuid
@@ -543,7 +552,9 @@ class SDP_PDU:
return self
@staticmethod
def parse_service_record_handle_list_preceded_by_count(data, offset):
def parse_service_record_handle_list_preceded_by_count(
data: bytes, offset: int
) -> Tuple[int, List[int]]:
count = struct.unpack_from('>H', data, offset - 2)[0]
handle_list = [
struct.unpack_from('>I', data, offset + x * 4)[0] for x in range(count)
@@ -641,6 +652,10 @@ class SDP_ServiceSearchRequest(SDP_PDU):
See Bluetooth spec @ Vol 3, Part B - 4.5.1 SDP_ServiceSearchRequest PDU
'''
service_search_pattern: DataElement
maximum_service_record_count: int
continuation_state: bytes
# -----------------------------------------------------------------------------
@SDP_PDU.subclass(
@@ -659,6 +674,11 @@ class SDP_ServiceSearchResponse(SDP_PDU):
See Bluetooth spec @ Vol 3, Part B - 4.5.2 SDP_ServiceSearchResponse PDU
'''
service_record_handle_list: List[int]
total_service_record_count: int
current_service_record_count: int
continuation_state: bytes
# -----------------------------------------------------------------------------
@SDP_PDU.subclass(
@@ -674,6 +694,11 @@ class SDP_ServiceAttributeRequest(SDP_PDU):
See Bluetooth spec @ Vol 3, Part B - 4.6.1 SDP_ServiceAttributeRequest PDU
'''
service_record_handle: int
maximum_attribute_byte_count: int
attribute_id_list: DataElement
continuation_state: bytes
# -----------------------------------------------------------------------------
@SDP_PDU.subclass(
@@ -688,6 +713,10 @@ class SDP_ServiceAttributeResponse(SDP_PDU):
See Bluetooth spec @ Vol 3, Part B - 4.6.2 SDP_ServiceAttributeResponse PDU
'''
attribute_list_byte_count: int
attribute_list: bytes
continuation_state: bytes
# -----------------------------------------------------------------------------
@SDP_PDU.subclass(
@@ -703,6 +732,11 @@ class SDP_ServiceSearchAttributeRequest(SDP_PDU):
See Bluetooth spec @ Vol 3, Part B - 4.7.1 SDP_ServiceSearchAttributeRequest PDU
'''
service_search_pattern: DataElement
maximum_attribute_byte_count: int
attribute_id_list: DataElement
continuation_state: bytes
# -----------------------------------------------------------------------------
@SDP_PDU.subclass(
@@ -717,26 +751,34 @@ class SDP_ServiceSearchAttributeResponse(SDP_PDU):
See Bluetooth spec @ Vol 3, Part B - 4.7.2 SDP_ServiceSearchAttributeResponse PDU
'''
attribute_list_byte_count: int
attribute_list: bytes
continuation_state: bytes
# -----------------------------------------------------------------------------
class Client:
def __init__(self, device):
channel: Optional[l2cap.Channel]
def __init__(self, device: Device) -> None:
self.device = device
self.pending_request = None
self.channel = None
async def connect(self, connection):
async def connect(self, connection: Connection) -> None:
result = await self.device.l2cap_channel_manager.connect(connection, SDP_PSM)
self.channel = result
async def disconnect(self):
async def disconnect(self) -> None:
if self.channel:
await self.channel.disconnect()
self.channel = None
async def search_services(self, uuids):
async def search_services(self, uuids: List[core.UUID]) -> List[int]:
if self.pending_request is not None:
raise InvalidStateError('request already pending')
if self.channel is None:
raise InvalidStateError('L2CAP not connected')
service_search_pattern = DataElement.sequence(
[DataElement.uuid(uuid) for uuid in uuids]
@@ -766,9 +808,13 @@ class Client:
return service_record_handle_list
async def search_attributes(self, uuids, attribute_ids):
async def search_attributes(
self, uuids: List[core.UUID], attribute_ids: List[Union[int, Tuple[int, int]]]
) -> List[List[ServiceAttribute]]:
if self.pending_request is not None:
raise InvalidStateError('request already pending')
if self.channel is None:
raise InvalidStateError('L2CAP not connected')
service_search_pattern = DataElement.sequence(
[DataElement.uuid(uuid) for uuid in uuids]
@@ -819,9 +865,15 @@ class Client:
if sequence.type == DataElement.SEQUENCE
]
async def get_attributes(self, service_record_handle, attribute_ids):
async def get_attributes(
self,
service_record_handle: int,
attribute_ids: List[Union[int, Tuple[int, int]]],
) -> List[ServiceAttribute]:
if self.pending_request is not None:
raise InvalidStateError('request already pending')
if self.channel is None:
raise InvalidStateError('L2CAP not connected')
attribute_id_list = DataElement.sequence(
[
@@ -869,21 +921,25 @@ class Client:
# -----------------------------------------------------------------------------
class Server:
CONTINUATION_STATE = bytes([0x01, 0x43])
channel: Optional[l2cap.Channel]
Service = NewType('Service', List[ServiceAttribute])
service_records: Dict[int, Service]
current_response: Union[None, bytes, Tuple[int, List[int]]]
def __init__(self, device):
def __init__(self, device: Device) -> None:
self.device = device
self.service_records = {} # Service records maps, by record handle
self.channel = None
self.current_response = None
def register(self, l2cap_channel_manager):
def register(self, l2cap_channel_manager: l2cap.ChannelManager) -> None:
l2cap_channel_manager.register_server(SDP_PSM, self.on_connection)
def send_response(self, response):
logger.debug(f'{color(">>> Sending SDP Response", "blue")}: {response}')
self.channel.send_pdu(response)
def match_services(self, search_pattern):
def match_services(self, search_pattern: DataElement) -> Dict[int, Service]:
# Find the services for which the attributes in the pattern is a subset of the
# service's attribute values (NOTE: the value search recurses into sequences)
matching_services = {}
@@ -953,7 +1009,9 @@ class Server:
return (payload, continuation_state)
@staticmethod
def get_service_attributes(service, attribute_ids):
def get_service_attributes(
service: Service, attribute_ids: List[DataElement]
) -> DataElement:
attributes = []
for attribute_id in attribute_ids:
if attribute_id.value_size == 4:
@@ -978,10 +1036,10 @@ class Server:
return attribute_list
def on_sdp_service_search_request(self, request):
def on_sdp_service_search_request(self, request: SDP_ServiceSearchRequest) -> None:
# Check if this is a continuation
if len(request.continuation_state) > 1:
if not self.current_response:
if self.current_response is None:
self.send_response(
SDP_ErrorResponse(
transaction_id=request.transaction_id,
@@ -1010,6 +1068,7 @@ class Server:
)
# Respond, keeping any unsent handles for later
assert isinstance(self.current_response, tuple)
service_record_handles = self.current_response[1][
: request.maximum_service_record_count
]
@@ -1033,10 +1092,12 @@ class Server:
)
)
def on_sdp_service_attribute_request(self, request):
def on_sdp_service_attribute_request(
self, request: SDP_ServiceAttributeRequest
) -> None:
# Check if this is a continuation
if len(request.continuation_state) > 1:
if not self.current_response:
if self.current_response is None:
self.send_response(
SDP_ErrorResponse(
transaction_id=request.transaction_id,
@@ -1069,22 +1130,24 @@ class Server:
self.current_response = bytes(attribute_list)
# Respond, keeping any pending chunks for later
attribute_list, continuation_state = self.get_next_response_payload(
attribute_list_response, continuation_state = self.get_next_response_payload(
request.maximum_attribute_byte_count
)
self.send_response(
SDP_ServiceAttributeResponse(
transaction_id=request.transaction_id,
attribute_list_byte_count=len(attribute_list),
attribute_list_byte_count=len(attribute_list_response),
attribute_list=attribute_list,
continuation_state=continuation_state,
)
)
def on_sdp_service_search_attribute_request(self, request):
def on_sdp_service_search_attribute_request(
self, request: SDP_ServiceSearchAttributeRequest
) -> None:
# Check if this is a continuation
if len(request.continuation_state) > 1:
if not self.current_response:
if self.current_response is None:
self.send_response(
SDP_ErrorResponse(
transaction_id=request.transaction_id,
@@ -1114,13 +1177,13 @@ class Server:
self.current_response = bytes(attribute_lists)
# Respond, keeping any pending chunks for later
attribute_lists, continuation_state = self.get_next_response_payload(
attribute_lists_response, continuation_state = self.get_next_response_payload(
request.maximum_attribute_byte_count
)
self.send_response(
SDP_ServiceSearchAttributeResponse(
transaction_id=request.transaction_id,
attribute_lists_byte_count=len(attribute_lists),
attribute_lists_byte_count=len(attribute_lists_response),
attribute_lists=attribute_lists,
continuation_state=continuation_state,
)

View File

@@ -25,6 +25,7 @@
from __future__ import annotations
import logging
import asyncio
import enum
import secrets
from typing import (
TYPE_CHECKING,
@@ -36,6 +37,7 @@ from typing import (
Optional,
Tuple,
Type,
cast,
)
from pyee import EventEmitter
@@ -553,20 +555,16 @@ class AddressResolver:
# -----------------------------------------------------------------------------
class Session:
# Pairing methods
class PairingMethod(enum.IntEnum):
JUST_WORKS = 0
NUMERIC_COMPARISON = 1
PASSKEY = 2
OOB = 3
CTKD_OVER_CLASSIC = 4
PAIRING_METHOD_NAMES = {
JUST_WORKS: 'JUST_WORKS',
NUMERIC_COMPARISON: 'NUMERIC_COMPARISON',
PASSKEY: 'PASSKEY',
OOB: 'OOB',
}
# -----------------------------------------------------------------------------
class Session:
# I/O Capability to pairing method decision matrix
#
# See Bluetooth spec @ Vol 3, part H - Table 2.8: Mapping of IO Capabilities to Key
@@ -581,47 +579,50 @@ class Session:
# (False).
PAIRING_METHODS = {
SMP_DISPLAY_ONLY_IO_CAPABILITY: {
SMP_DISPLAY_ONLY_IO_CAPABILITY: JUST_WORKS,
SMP_DISPLAY_YES_NO_IO_CAPABILITY: JUST_WORKS,
SMP_KEYBOARD_ONLY_IO_CAPABILITY: (PASSKEY, True, False),
SMP_NO_INPUT_NO_OUTPUT_IO_CAPABILITY: JUST_WORKS,
SMP_KEYBOARD_DISPLAY_IO_CAPABILITY: (PASSKEY, True, False),
SMP_DISPLAY_ONLY_IO_CAPABILITY: PairingMethod.JUST_WORKS,
SMP_DISPLAY_YES_NO_IO_CAPABILITY: PairingMethod.JUST_WORKS,
SMP_KEYBOARD_ONLY_IO_CAPABILITY: (PairingMethod.PASSKEY, True, False),
SMP_NO_INPUT_NO_OUTPUT_IO_CAPABILITY: PairingMethod.JUST_WORKS,
SMP_KEYBOARD_DISPLAY_IO_CAPABILITY: (PairingMethod.PASSKEY, True, False),
},
SMP_DISPLAY_YES_NO_IO_CAPABILITY: {
SMP_DISPLAY_ONLY_IO_CAPABILITY: JUST_WORKS,
SMP_DISPLAY_YES_NO_IO_CAPABILITY: (JUST_WORKS, NUMERIC_COMPARISON),
SMP_KEYBOARD_ONLY_IO_CAPABILITY: (PASSKEY, True, False),
SMP_NO_INPUT_NO_OUTPUT_IO_CAPABILITY: JUST_WORKS,
SMP_DISPLAY_ONLY_IO_CAPABILITY: PairingMethod.JUST_WORKS,
SMP_DISPLAY_YES_NO_IO_CAPABILITY: (
PairingMethod.JUST_WORKS,
PairingMethod.NUMERIC_COMPARISON,
),
SMP_KEYBOARD_ONLY_IO_CAPABILITY: (PairingMethod.PASSKEY, True, False),
SMP_NO_INPUT_NO_OUTPUT_IO_CAPABILITY: PairingMethod.JUST_WORKS,
SMP_KEYBOARD_DISPLAY_IO_CAPABILITY: (
(PASSKEY, True, False),
NUMERIC_COMPARISON,
(PairingMethod.PASSKEY, True, False),
PairingMethod.NUMERIC_COMPARISON,
),
},
SMP_KEYBOARD_ONLY_IO_CAPABILITY: {
SMP_DISPLAY_ONLY_IO_CAPABILITY: (PASSKEY, False, True),
SMP_DISPLAY_YES_NO_IO_CAPABILITY: (PASSKEY, False, True),
SMP_KEYBOARD_ONLY_IO_CAPABILITY: (PASSKEY, False, False),
SMP_NO_INPUT_NO_OUTPUT_IO_CAPABILITY: JUST_WORKS,
SMP_KEYBOARD_DISPLAY_IO_CAPABILITY: (PASSKEY, False, True),
SMP_DISPLAY_ONLY_IO_CAPABILITY: (PairingMethod.PASSKEY, False, True),
SMP_DISPLAY_YES_NO_IO_CAPABILITY: (PairingMethod.PASSKEY, False, True),
SMP_KEYBOARD_ONLY_IO_CAPABILITY: (PairingMethod.PASSKEY, False, False),
SMP_NO_INPUT_NO_OUTPUT_IO_CAPABILITY: PairingMethod.JUST_WORKS,
SMP_KEYBOARD_DISPLAY_IO_CAPABILITY: (PairingMethod.PASSKEY, False, True),
},
SMP_NO_INPUT_NO_OUTPUT_IO_CAPABILITY: {
SMP_DISPLAY_ONLY_IO_CAPABILITY: JUST_WORKS,
SMP_DISPLAY_YES_NO_IO_CAPABILITY: JUST_WORKS,
SMP_KEYBOARD_ONLY_IO_CAPABILITY: JUST_WORKS,
SMP_NO_INPUT_NO_OUTPUT_IO_CAPABILITY: JUST_WORKS,
SMP_KEYBOARD_DISPLAY_IO_CAPABILITY: JUST_WORKS,
SMP_DISPLAY_ONLY_IO_CAPABILITY: PairingMethod.JUST_WORKS,
SMP_DISPLAY_YES_NO_IO_CAPABILITY: PairingMethod.JUST_WORKS,
SMP_KEYBOARD_ONLY_IO_CAPABILITY: PairingMethod.JUST_WORKS,
SMP_NO_INPUT_NO_OUTPUT_IO_CAPABILITY: PairingMethod.JUST_WORKS,
SMP_KEYBOARD_DISPLAY_IO_CAPABILITY: PairingMethod.JUST_WORKS,
},
SMP_KEYBOARD_DISPLAY_IO_CAPABILITY: {
SMP_DISPLAY_ONLY_IO_CAPABILITY: (PASSKEY, False, True),
SMP_DISPLAY_ONLY_IO_CAPABILITY: (PairingMethod.PASSKEY, False, True),
SMP_DISPLAY_YES_NO_IO_CAPABILITY: (
(PASSKEY, False, True),
NUMERIC_COMPARISON,
(PairingMethod.PASSKEY, False, True),
PairingMethod.NUMERIC_COMPARISON,
),
SMP_KEYBOARD_ONLY_IO_CAPABILITY: (PASSKEY, True, False),
SMP_NO_INPUT_NO_OUTPUT_IO_CAPABILITY: JUST_WORKS,
SMP_KEYBOARD_ONLY_IO_CAPABILITY: (PairingMethod.PASSKEY, True, False),
SMP_NO_INPUT_NO_OUTPUT_IO_CAPABILITY: PairingMethod.JUST_WORKS,
SMP_KEYBOARD_DISPLAY_IO_CAPABILITY: (
(PASSKEY, True, False),
NUMERIC_COMPARISON,
(PairingMethod.PASSKEY, True, False),
PairingMethod.NUMERIC_COMPARISON,
),
},
}
@@ -664,7 +665,7 @@ class Session:
self.passkey_ready = asyncio.Event()
self.passkey_step = 0
self.passkey_display = False
self.pairing_method = 0
self.pairing_method: PairingMethod = PairingMethod.JUST_WORKS
self.pairing_config = pairing_config
self.wait_before_continuing: Optional[asyncio.Future[None]] = None
self.completed = False
@@ -769,19 +770,23 @@ class Session:
def decide_pairing_method(
self, auth_req: int, initiator_io_capability: int, responder_io_capability: int
) -> None:
if self.connection.transport == BT_BR_EDR_TRANSPORT:
self.pairing_method = PairingMethod.CTKD_OVER_CLASSIC
return
if (not self.mitm) and (auth_req & SMP_MITM_AUTHREQ == 0):
self.pairing_method = self.JUST_WORKS
self.pairing_method = PairingMethod.JUST_WORKS
return
details = self.PAIRING_METHODS[initiator_io_capability][responder_io_capability] # type: ignore[index]
if isinstance(details, tuple) and len(details) == 2:
# One entry for legacy pairing and one for secure connections
details = details[1 if self.sc else 0]
if isinstance(details, int):
if isinstance(details, PairingMethod):
# Just a method ID
self.pairing_method = details
else:
# PASSKEY method, with a method ID and display/input flags
assert isinstance(details[0], PairingMethod)
self.pairing_method = details[0]
self.passkey_display = details[1 if self.is_initiator else 2]
@@ -858,10 +863,13 @@ class Session:
self.tk = self.passkey.to_bytes(16, byteorder='little')
logger.debug(f'TK from passkey = {self.tk.hex()}')
self.connection.abort_on(
'disconnection',
self.pairing_config.delegate.display_number(self.passkey, digits=6),
)
try:
self.connection.abort_on(
'disconnection',
self.pairing_config.delegate.display_number(self.passkey, digits=6),
)
except Exception as error:
logger.warning(f'exception while displaying number: {error}')
def input_passkey(self, next_steps: Optional[Callable[[], None]] = None) -> None:
# Prompt the user for the passkey displayed on the peer
@@ -929,9 +937,12 @@ class Session:
if self.sc:
async def next_steps() -> None:
if self.pairing_method in (self.JUST_WORKS, self.NUMERIC_COMPARISON):
if self.pairing_method in (
PairingMethod.JUST_WORKS,
PairingMethod.NUMERIC_COMPARISON,
):
z = 0
elif self.pairing_method == self.PASSKEY:
elif self.pairing_method == PairingMethod.PASSKEY:
# We need a passkey
await self.passkey_ready.wait()
assert self.passkey
@@ -983,6 +994,19 @@ class Session:
)
)
def send_identity_address_command(self) -> None:
identity_address = {
None: self.connection.self_address,
Address.PUBLIC_DEVICE_ADDRESS: self.manager.device.public_address,
Address.RANDOM_DEVICE_ADDRESS: self.manager.device.random_address,
}[self.pairing_config.identity_address_type]
self.send_command(
SMP_Identity_Address_Information_Command(
addr_type=identity_address.address_type,
bd_addr=identity_address,
)
)
def start_encryption(self, key: bytes) -> None:
# 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
@@ -1006,6 +1030,7 @@ class Session:
self.ltk = crypto.h6(ilk, b'brle')
def distribute_keys(self) -> None:
# Distribute the keys as required
if self.is_initiator:
# CTKD: Derive LTK from LinkKey
@@ -1035,12 +1060,7 @@ class Session:
identity_resolving_key=self.manager.device.irk
)
)
self.send_command(
SMP_Identity_Address_Information_Command(
addr_type=self.connection.self_address.address_type,
bd_addr=self.connection.self_address,
)
)
self.send_identity_address_command()
# Distribute CSRK
csrk = bytes(16) # FIXME: testing
@@ -1084,12 +1104,7 @@ class Session:
identity_resolving_key=self.manager.device.irk
)
)
self.send_command(
SMP_Identity_Address_Information_Command(
addr_type=self.connection.self_address.address_type,
bd_addr=self.connection.self_address,
)
)
self.send_identity_address_command()
# Distribute CSRK
csrk = bytes(16) # FIXME: testing
@@ -1224,7 +1239,7 @@ class Session:
# Create an object to hold the keys
keys = PairingKeys()
keys.address_type = peer_address.address_type
authenticated = self.pairing_method != self.JUST_WORKS
authenticated = self.pairing_method != PairingMethod.JUST_WORKS
if self.sc or self.connection.transport == BT_BR_EDR_TRANSPORT:
keys.ltk = PairingKeys.Key(value=self.ltk, authenticated=authenticated)
else:
@@ -1258,7 +1273,7 @@ class Session:
keys.link_key = PairingKeys.Key(
value=self.link_key, authenticated=authenticated
)
self.manager.on_pairing(self, peer_address, keys)
await self.manager.on_pairing(self, peer_address, keys)
def on_pairing_failure(self, reason: int) -> None:
logger.warning(f'pairing failure ({error_name(reason)})')
@@ -1300,7 +1315,11 @@ class Session:
self, command: SMP_Pairing_Request_Command
) -> None:
# Check if the request should proceed
accepted = await self.pairing_config.delegate.accept()
try:
accepted = await self.pairing_config.delegate.accept()
except Exception as error:
logger.warning(f'exception while accepting: {error}')
accepted = False
if not accepted:
logger.debug('pairing rejected by delegate')
self.send_pairing_failed(SMP_PAIRING_NOT_SUPPORTED_ERROR)
@@ -1323,9 +1342,7 @@ class Session:
self.decide_pairing_method(
command.auth_req, command.io_capability, self.io_capability
)
logger.debug(
f'pairing method: {self.PAIRING_METHOD_NAMES[self.pairing_method]}'
)
logger.debug(f'pairing method: {self.pairing_method.name}')
# Key distribution
(
@@ -1341,7 +1358,7 @@ class Session:
# Display a passkey if we need to
if not self.sc:
if self.pairing_method == self.PASSKEY and self.passkey_display:
if self.pairing_method == PairingMethod.PASSKEY and self.passkey_display:
self.display_passkey()
# Respond
@@ -1382,9 +1399,7 @@ class Session:
self.decide_pairing_method(
command.auth_req, self.io_capability, command.io_capability
)
logger.debug(
f'pairing method: {self.PAIRING_METHOD_NAMES[self.pairing_method]}'
)
logger.debug(f'pairing method: {self.pairing_method.name}')
# Key distribution
if (
@@ -1400,13 +1415,16 @@ class Session:
self.compute_peer_expected_distributions(self.responder_key_distribution)
# Start phase 2
if self.sc:
if self.pairing_method == self.PASSKEY:
if self.pairing_method == PairingMethod.CTKD_OVER_CLASSIC:
# Authentication is already done in SMP, so remote shall start keys distribution immediately
return
elif self.sc:
if self.pairing_method == PairingMethod.PASSKEY:
self.display_or_input_passkey()
self.send_public_key_command()
else:
if self.pairing_method == self.PASSKEY:
if self.pairing_method == PairingMethod.PASSKEY:
self.display_or_input_passkey(self.send_pairing_confirm_command)
else:
self.send_pairing_confirm_command()
@@ -1418,7 +1436,10 @@ class Session:
self.send_pairing_random_command()
else:
# If the method is PASSKEY, now is the time to input the code
if self.pairing_method == self.PASSKEY and not self.passkey_display:
if (
self.pairing_method == PairingMethod.PASSKEY
and not self.passkey_display
):
self.input_passkey(self.send_pairing_confirm_command)
else:
self.send_pairing_confirm_command()
@@ -1426,11 +1447,14 @@ class Session:
def on_smp_pairing_confirm_command_secure_connections(
self, _: SMP_Pairing_Confirm_Command
) -> None:
if self.pairing_method in (self.JUST_WORKS, self.NUMERIC_COMPARISON):
if self.pairing_method in (
PairingMethod.JUST_WORKS,
PairingMethod.NUMERIC_COMPARISON,
):
if self.is_initiator:
self.r = crypto.r()
self.send_pairing_random_command()
elif self.pairing_method == self.PASSKEY:
elif self.pairing_method == PairingMethod.PASSKEY:
if self.is_initiator:
self.send_pairing_random_command()
else:
@@ -1486,13 +1510,16 @@ class Session:
def on_smp_pairing_random_command_secure_connections(
self, command: SMP_Pairing_Random_Command
) -> None:
if self.pairing_method == self.PASSKEY and self.passkey is None:
if self.pairing_method == PairingMethod.PASSKEY and self.passkey is None:
logger.warning('no passkey entered, ignoring command')
return
# pylint: disable=too-many-return-statements
if self.is_initiator:
if self.pairing_method in (self.JUST_WORKS, self.NUMERIC_COMPARISON):
if self.pairing_method in (
PairingMethod.JUST_WORKS,
PairingMethod.NUMERIC_COMPARISON,
):
assert self.confirm_value
# Check that the random value matches what was committed to earlier
confirm_verifier = crypto.f4(
@@ -1502,7 +1529,7 @@ class Session:
self.confirm_value, confirm_verifier, SMP_CONFIRM_VALUE_FAILED_ERROR
):
return
elif self.pairing_method == self.PASSKEY:
elif self.pairing_method == PairingMethod.PASSKEY:
assert self.passkey and self.confirm_value
# Check that the random value matches what was committed to earlier
confirm_verifier = crypto.f4(
@@ -1525,9 +1552,12 @@ class Session:
else:
return
else:
if self.pairing_method in (self.JUST_WORKS, self.NUMERIC_COMPARISON):
if self.pairing_method in (
PairingMethod.JUST_WORKS,
PairingMethod.NUMERIC_COMPARISON,
):
self.send_pairing_random_command()
elif self.pairing_method == self.PASSKEY:
elif self.pairing_method == PairingMethod.PASSKEY:
assert self.passkey and self.confirm_value
# Check that the random value matches what was committed to earlier
confirm_verifier = crypto.f4(
@@ -1558,10 +1588,13 @@ class Session:
(mac_key, self.ltk) = crypto.f5(self.dh_key, self.na, self.nb, a, b)
# Compute the DH Key checks
if self.pairing_method in (self.JUST_WORKS, self.NUMERIC_COMPARISON):
if self.pairing_method in (
PairingMethod.JUST_WORKS,
PairingMethod.NUMERIC_COMPARISON,
):
ra = bytes(16)
rb = ra
elif self.pairing_method == self.PASSKEY:
elif self.pairing_method == PairingMethod.PASSKEY:
assert self.passkey
ra = self.passkey.to_bytes(16, byteorder='little')
rb = ra
@@ -1585,13 +1618,16 @@ class Session:
self.wait_before_continuing.set_result(None)
# Prompt the user for confirmation if needed
if self.pairing_method in (self.JUST_WORKS, self.NUMERIC_COMPARISON):
if self.pairing_method in (
PairingMethod.JUST_WORKS,
PairingMethod.NUMERIC_COMPARISON,
):
# Compute the 6-digit code
code = crypto.g2(self.pka, self.pkb, self.na, self.nb) % 1000000
# Ask for user confirmation
self.wait_before_continuing = asyncio.get_running_loop().create_future()
if self.pairing_method == self.JUST_WORKS:
if self.pairing_method == PairingMethod.JUST_WORKS:
self.prompt_user_for_confirmation(next_steps)
else:
self.prompt_user_for_numeric_comparison(code, next_steps)
@@ -1628,13 +1664,16 @@ class Session:
if self.is_initiator:
self.send_pairing_confirm_command()
else:
if self.pairing_method == self.PASSKEY:
if self.pairing_method == PairingMethod.PASSKEY:
self.display_or_input_passkey()
# Send our public key back to the initiator
self.send_public_key_command()
if self.pairing_method in (self.JUST_WORKS, self.NUMERIC_COMPARISON):
if self.pairing_method in (
PairingMethod.JUST_WORKS,
PairingMethod.NUMERIC_COMPARISON,
):
# We can now send the confirmation value
self.send_pairing_confirm_command()
@@ -1733,7 +1772,26 @@ class Manager(EventEmitter):
cid = SMP_BR_CID if connection.transport == BT_BR_EDR_TRANSPORT else SMP_CID
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:
# 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
if not (session := self.sessions.get(connection.handle)):
if connection.role == BT_CENTRAL_ROLE:
@@ -1744,13 +1802,6 @@ class Manager(EventEmitter):
)
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
session.on_smp_command(command)
@@ -1789,20 +1840,14 @@ class Manager(EventEmitter):
def on_session_start(self, session: Session) -> None:
self.device.on_pairing_start(session.connection)
def on_pairing(
async def on_pairing(
self, session: Session, identity_address: Optional[Address], keys: PairingKeys
) -> None:
# Store the keys in the key store
if self.device.keystore and identity_address is not None:
async def store_keys():
try:
assert self.device.keystore
await self.device.keystore.update(str(identity_address), keys)
except Exception as error:
logger.warning(f'!!! error while storing keys: {error}')
self.device.abort_on('flush', store_keys())
self.device.abort_on(
'flush', self.device.update_keys(str(identity_address), keys)
)
# Notify the device
self.device.on_pairing(session.connection, identity_address, keys, session.sc)

View File

@@ -20,7 +20,6 @@ import logging
import os
from .common import Transport, AsyncPipeSink, SnoopingTransport
from ..controller import Controller
from ..snoop import create_snooper
# -----------------------------------------------------------------------------
@@ -69,6 +68,7 @@ async def open_transport(name: str) -> Transport:
* usb
* pyusb
* android-emulator
* android-netsim
"""
return _wrap_transport(await _open_transport(name))
@@ -118,7 +118,8 @@ async def _open_transport(name: str) -> Transport:
if scheme == 'file':
from .file import open_file_transport
return await open_file_transport(spec[0] if spec else None)
assert spec is not None
return await open_file_transport(spec[0])
if scheme == 'vhci':
from .vhci import open_vhci_transport
@@ -133,12 +134,14 @@ async def _open_transport(name: str) -> Transport:
if scheme == 'usb':
from .usb import open_usb_transport
return await open_usb_transport(spec[0] if spec else None)
assert spec is not None
return await open_usb_transport(spec[0])
if scheme == 'pyusb':
from .pyusb import open_pyusb_transport
return await open_pyusb_transport(spec[0] if spec else None)
assert spec is not None
return await open_pyusb_transport(spec[0])
if scheme == 'android-emulator':
from .android_emulator import open_android_emulator_transport
@@ -167,6 +170,7 @@ async def open_transport_or_link(name: str) -> Transport:
"""
if name.startswith('link-relay:'):
from ..controller import Controller
from ..link import RemoteLink # lazy import
link = RemoteLink(name[11:])

View File

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

View File

@@ -18,11 +18,12 @@
import asyncio
import atexit
import logging
import grpc.aio
import os
import pathlib
import sys
from typing import Optional
from typing import Dict, Optional
import grpc.aio
from .common import (
ParserSource,
@@ -33,8 +34,8 @@ from .common import (
)
# pylint: disable=no-name-in-module
from .grpc_protobuf.packet_streamer_pb2_grpc import PacketStreamerStub
from .grpc_protobuf.packet_streamer_pb2_grpc import (
PacketStreamerStub,
PacketStreamerServicer,
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.common_pb2 import ChipKind
# -----------------------------------------------------------------------------
# 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()):
logger.debug('no known directory for .ini file')
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():
logger.debug(f'Found .ini file at {ini_file}')
with open(ini_file, 'r') as ini_file_data:
for line in ini_file_data.readlines():
if '=' in line:
@@ -90,12 +98,14 @@ def find_grpc_port() -> int:
logger.debug(f'gRPC port = {value}')
return int(value)
logger.debug('no grpc.port property found in .ini file')
# Not found
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()):
logger.debug('no known directory for .ini file')
return False
@@ -104,7 +114,7 @@ def publish_grpc_port(grpc_port) -> bool:
logger.debug('ini directory does not exist')
return False
ini_file = ini_dir / 'netsim.ini'
ini_file = ini_dir / ini_file_name(instance_number)
try:
ini_file.write_text(f'grpc.port={grpc_port}\n')
logger.debug(f"published gRPC port at {ini_file}")
@@ -121,13 +131,16 @@ def publish_grpc_port(grpc_port) -> bool:
# -----------------------------------------------------------------------------
async def open_android_netsim_controller_transport(server_host, server_port):
async def open_android_netsim_controller_transport(
server_host: Optional[str], server_port: int, options: Dict[str, str]
) -> Transport:
if not server_port:
raise ValueError('invalid port')
if server_host == '_' or not server_host:
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")
class HciDevice:
@@ -184,15 +197,12 @@ async def open_android_netsim_controller_transport(server_host, server_port):
logger.debug(f'<<< PACKET: {data.hex()}')
self.on_data_received(data)
def send_packet(self, data):
async def send():
await self.context.write(
PacketResponse(
hci_packet=HCIPacket(packet_type=data[0], packet=data[1:])
)
async def send_packet(self, data):
return await self.context.write(
PacketResponse(
hci_packet=HCIPacket(packet_type=data[0], packet=data[1:])
)
self.loop.create_task(send())
)
def terminate(self):
self.task.cancel()
@@ -226,17 +236,17 @@ async def open_android_netsim_controller_transport(server_host, server_port):
logger.debug('gRPC server cancelled')
await self.grpc_server.stop(None)
def on_packet(self, packet):
async def send_packet(self, packet):
if not self.device:
logger.debug('no device, dropping packet')
return
self.device.send_packet(packet)
return await self.device.send_packet(packet)
async def StreamPackets(self, _request_iterator, context):
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:
logger.debug('busy, already serving a device')
return PacketResponse(error='Busy')
@@ -259,15 +269,42 @@ async def open_android_netsim_controller_transport(server_host, server_port):
await server.start()
asyncio.get_running_loop().create_task(server.serve())
class GrpcServerTransport(Transport):
async def close(self):
await super().close()
return GrpcServerTransport(server, server)
sink = PumpedPacketSink(server.send_packet)
sink.start()
return Transport(server, sink)
# -----------------------------------------------------------------------------
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
class HciDevice:
def __init__(self, name, manufacturer, hci_device):
@@ -286,10 +323,12 @@ async def open_android_netsim_host_transport(server_host, server_port, options):
async def read(self):
response = await self.hci_device.read()
response_type = response.WhichOneof('response_type')
if response_type == 'error':
logger.warning(f'received error: {response.error}')
raise RuntimeError(response.error)
elif response_type == 'hci_packet':
if response_type == 'hci_packet':
return (
bytes([response.hci_packet.packet_type])
+ response.hci_packet.packet
@@ -304,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
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
service = PacketStreamerStub(channel)
hci_device = HciDevice(
@@ -332,10 +356,14 @@ async def open_android_netsim_host_transport(server_host, server_port, options):
await hci_device.start()
# 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),
PumpedPacketSink(hci_device.write),
channel.close,
)
transport.start()
@@ -343,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`
simulator protocol over gRPC.
@@ -357,6 +385,11 @@ async def open_android_netsim_transport(spec):
to connect *to* a netsim server (netsim is the controller), or accept
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:
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`
@@ -385,14 +418,15 @@ async def open_android_netsim_transport(spec):
params = spec.split(',') if spec else []
if params and ':' in params[0]:
# Explicit <host>:<port>
host, port = params[0].split(':')
host, port_str = params[0].split(':')
port = int(port_str)
params_offset = 1
else:
host = None
port = 0
params_offset = 0
options = {}
options: Dict[str, str] = {}
for param in params[params_offset:]:
if '=' not in param:
raise ValueError('invalid parameter, expected <name>=<value>')
@@ -401,10 +435,12 @@ async def open_android_netsim_transport(spec):
mode = options.get('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 host is None:
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')

View File

@@ -20,11 +20,12 @@ import contextlib
import struct
import asyncio
import logging
from typing import ContextManager
import io
from typing import ContextManager, Tuple, Optional, Protocol, Dict
from .. import hci
from ..colors import color
from ..snoop import Snooper
from bumble import hci
from bumble.colors import color
from bumble.snoop import Snooper
# -----------------------------------------------------------------------------
@@ -36,7 +37,7 @@ logger = logging.getLogger(__name__)
# Information needed to parse HCI packets with a generic parser:
# For each packet type, the info represents:
# (length-size, length-offset, unpack-type)
HCI_PACKET_INFO = {
HCI_PACKET_INFO: Dict[int, Tuple[int, int, str]] = {
hci.HCI_COMMAND_PACKET: (1, 2, 'B'),
hci.HCI_ACL_DATA_PACKET: (2, 2, 'H'),
hci.HCI_SYNCHRONOUS_DATA_PACKET: (1, 2, 'B'),
@@ -45,33 +46,54 @@ HCI_PACKET_INFO = {
# -----------------------------------------------------------------------------
class PacketPump:
'''
Pump HCI packets from a reader to a sink
'''
# Errors
# -----------------------------------------------------------------------------
class TransportLostError(Exception):
"""
The Transport has been lost/disconnected.
"""
def __init__(self, reader, sink):
# -----------------------------------------------------------------------------
# Typing Protocols
# -----------------------------------------------------------------------------
class TransportSink(Protocol):
def on_packet(self, packet: bytes) -> None:
...
class TransportSource(Protocol):
terminated: asyncio.Future[None]
def set_packet_sink(self, sink: TransportSink) -> None:
...
# -----------------------------------------------------------------------------
class PacketPump:
"""
Pump HCI packets from a reader to a sink.
"""
def __init__(self, reader: AsyncPacketReader, sink: TransportSink) -> None:
self.reader = reader
self.sink = sink
async def run(self):
async def run(self) -> None:
while True:
try:
# Get a packet from the source
packet = hci.HCI_Packet.from_bytes(await self.reader.next_packet())
# Deliver the packet to the sink
self.sink.on_packet(packet)
self.sink.on_packet(await self.reader.next_packet())
except Exception as error:
logger.warning(f'!!! {error}')
# -----------------------------------------------------------------------------
class PacketParser:
'''
"""
In-line parser that accepts data and emits 'on_packet' when a full packet has been
parsed
'''
parsed.
"""
# pylint: disable=attribute-defined-outside-init
@@ -79,18 +101,22 @@ class PacketParser:
NEED_LENGTH = 1
NEED_BODY = 2
def __init__(self, sink=None):
sink: Optional[TransportSink]
extended_packet_info: Dict[int, Tuple[int, int, str]]
packet_info: Optional[Tuple[int, int, str]] = None
def __init__(self, sink: Optional[TransportSink] = None) -> None:
self.sink = sink
self.extended_packet_info = {}
self.reset()
def reset(self):
def reset(self) -> None:
self.state = PacketParser.NEED_TYPE
self.bytes_needed = 1
self.packet = bytearray()
self.packet_info = None
def feed_data(self, data):
def feed_data(self, data: bytes) -> None:
data_offset = 0
data_left = len(data)
while data_left and self.bytes_needed:
@@ -111,6 +137,7 @@ class PacketParser:
self.state = PacketParser.NEED_LENGTH
self.bytes_needed = self.packet_info[0] + self.packet_info[1]
elif self.state == PacketParser.NEED_LENGTH:
assert self.packet_info is not None
body_length = struct.unpack_from(
self.packet_info[2], self.packet, 1 + self.packet_info[1]
)[0]
@@ -128,20 +155,20 @@ class PacketParser:
)
self.reset()
def set_packet_sink(self, sink):
def set_packet_sink(self, sink: TransportSink) -> None:
self.sink = sink
# -----------------------------------------------------------------------------
class PacketReader:
'''
Reader that reads HCI packets from a sync source
'''
"""
Reader that reads HCI packets from a sync source.
"""
def __init__(self, source):
def __init__(self, source: io.BufferedReader) -> None:
self.source = source
def next_packet(self):
def next_packet(self) -> Optional[bytes]:
# Get the packet type
packet_type = self.source.read(1)
if len(packet_type) != 1:
@@ -150,7 +177,7 @@ class PacketReader:
# Get the packet info based on its type
packet_info = HCI_PACKET_INFO.get(packet_type[0])
if packet_info is None:
raise ValueError(f'invalid packet type {packet_type} found')
raise ValueError(f'invalid packet type {packet_type[0]} found')
# Read the header (that includes the length)
header_size = packet_info[0] + packet_info[1]
@@ -169,21 +196,21 @@ class PacketReader:
# -----------------------------------------------------------------------------
class AsyncPacketReader:
'''
Reader that reads HCI packets from an async source
'''
"""
Reader that reads HCI packets from an async source.
"""
def __init__(self, source):
def __init__(self, source: asyncio.StreamReader) -> None:
self.source = source
async def next_packet(self):
async def next_packet(self) -> bytes:
# Get the packet type
packet_type = await self.source.readexactly(1)
# Get the packet info based on its type
packet_info = HCI_PACKET_INFO.get(packet_type[0])
if packet_info is None:
raise ValueError(f'invalid packet type {packet_type} found')
raise ValueError(f'invalid packet type {packet_type[0]} found')
# Read the header (that includes the length)
header_size = packet_info[0] + packet_info[1]
@@ -198,15 +225,15 @@ class AsyncPacketReader:
# -----------------------------------------------------------------------------
class AsyncPipeSink:
'''
Sink that forwards packets asynchronously to another sink
'''
"""
Sink that forwards packets asynchronously to another sink.
"""
def __init__(self, sink):
def __init__(self, sink: TransportSink) -> None:
self.sink = sink
self.loop = asyncio.get_running_loop()
def on_packet(self, packet):
def on_packet(self, packet: bytes) -> None:
self.loop.call_soon(self.sink.on_packet, packet)
@@ -216,35 +243,48 @@ class ParserSource:
Base class designed to be subclassed by transport-specific source classes
"""
def __init__(self):
terminated: asyncio.Future[None]
parser: PacketParser
def __init__(self) -> None:
self.parser = PacketParser()
self.terminated = asyncio.get_running_loop().create_future()
def set_packet_sink(self, sink):
def set_packet_sink(self, sink: TransportSink) -> None:
self.parser.set_packet_sink(sink)
async def wait_for_termination(self):
def on_transport_lost(self) -> None:
self.terminated.set_result(None)
if self.parser.sink:
if hasattr(self.parser.sink, 'on_transport_lost'):
self.parser.sink.on_transport_lost()
async def wait_for_termination(self) -> None:
"""
Convenience method for backward compatibility. Prefer using the `terminated`
attribute instead.
"""
return await self.terminated
def close(self):
def close(self) -> None:
pass
# -----------------------------------------------------------------------------
class StreamPacketSource(asyncio.Protocol, ParserSource):
def data_received(self, data):
def data_received(self, data: bytes) -> None:
self.parser.feed_data(data)
# -----------------------------------------------------------------------------
class StreamPacketSink:
def __init__(self, transport):
def __init__(self, transport: asyncio.WriteTransport) -> None:
self.transport = transport
def on_packet(self, packet):
def on_packet(self, packet: bytes) -> None:
self.transport.write(packet)
def close(self):
def close(self) -> None:
self.transport.close()
@@ -264,7 +304,7 @@ class Transport:
...
"""
def __init__(self, source, sink):
def __init__(self, source: TransportSource, sink: TransportSink) -> None:
self.source = source
self.sink = sink
@@ -278,34 +318,39 @@ class Transport:
return iter((self.source, self.sink))
async def close(self) -> None:
self.source.close()
self.sink.close()
if hasattr(self.source, 'close'):
self.source.close()
if hasattr(self.sink, 'close'):
self.sink.close()
# -----------------------------------------------------------------------------
class PumpedPacketSource(ParserSource):
def __init__(self, receive):
pump_task: Optional[asyncio.Task[None]]
def __init__(self, receive) -> None:
super().__init__()
self.receive_function = receive
self.pump_task = None
def start(self):
async def pump_packets():
def start(self) -> None:
async def pump_packets() -> None:
while True:
try:
packet = await self.receive_function()
self.parser.feed_data(packet)
except asyncio.exceptions.CancelledError:
except asyncio.CancelledError:
logger.debug('source pump task done')
self.terminated.set_result(None)
break
except Exception as error:
logger.warning(f'exception while waiting for packet: {error}')
self.terminated.set_result(error)
self.terminated.set_exception(error)
break
self.pump_task = asyncio.create_task(pump_packets())
def close(self):
def close(self) -> None:
if self.pump_task:
self.pump_task.cancel()
@@ -317,7 +362,7 @@ class PumpedPacketSink:
self.packet_queue = asyncio.Queue()
self.pump_task = None
def on_packet(self, packet):
def on_packet(self, packet: bytes) -> None:
self.packet_queue.put_nowait(packet)
def start(self):
@@ -326,7 +371,7 @@ class PumpedPacketSink:
try:
packet = await self.packet_queue.get()
await self.send_function(packet)
except asyncio.exceptions.CancelledError:
except asyncio.CancelledError:
logger.debug('sink pump task done')
break
except Exception as error:
@@ -342,18 +387,20 @@ class PumpedPacketSink:
# -----------------------------------------------------------------------------
class PumpedTransport(Transport):
def __init__(self, source, sink, close_function):
super().__init__(source, sink)
self.close_function = close_function
source: PumpedPacketSource
sink: PumpedPacketSink
def start(self):
def __init__(
self,
source: PumpedPacketSource,
sink: PumpedPacketSink,
) -> None:
super().__init__(source, sink)
def start(self) -> None:
self.source.start()
self.sink.start()
async def close(self):
await super().close()
await self.close_function()
# -----------------------------------------------------------------------------
class SnoopingTransport(Transport):
@@ -375,31 +422,38 @@ class SnoopingTransport(Transport):
raise RuntimeError('unexpected code path') # Satisfy the type checker
class Source:
def __init__(self, source, snooper):
sink: TransportSink
def __init__(self, source: TransportSource, snooper: Snooper):
self.source = source
self.snooper = snooper
self.sink = None
self.terminated = source.terminated
def set_packet_sink(self, sink):
def set_packet_sink(self, sink: TransportSink) -> None:
self.sink = sink
self.source.set_packet_sink(self)
def on_packet(self, packet):
def on_packet(self, packet: bytes) -> None:
self.snooper.snoop(packet, Snooper.Direction.CONTROLLER_TO_HOST)
if self.sink:
self.sink.on_packet(packet)
class Sink:
def __init__(self, sink, snooper):
def __init__(self, sink: TransportSink, snooper: Snooper) -> None:
self.sink = sink
self.snooper = snooper
def on_packet(self, packet):
def on_packet(self, packet: bytes) -> None:
self.snooper.snoop(packet, Snooper.Direction.HOST_TO_CONTROLLER)
if self.sink:
self.sink.on_packet(packet)
def __init__(self, transport, snooper, close_snooper=None):
def __init__(
self,
transport: Transport,
snooper: Snooper,
close_snooper=None,
) -> None:
super().__init__(
self.Source(transport.source, snooper), self.Sink(transport.sink, snooper)
)

View File

@@ -28,7 +28,7 @@ logger = logging.getLogger(__name__)
# -----------------------------------------------------------------------------
async def open_file_transport(spec):
async def open_file_transport(spec: str) -> Transport:
'''
Open a File transport (typically not for a real file, but for a PTY or other unix
virtual files).

View File

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

View File

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

View File

@@ -35,7 +35,7 @@ logger = logging.getLogger(__name__)
# -----------------------------------------------------------------------------
async def open_pyusb_transport(spec):
async def open_pyusb_transport(spec: str) -> Transport:
'''
Open a USB transport. [Implementation based on PyUSB]
The parameter string has this syntax:

View File

@@ -28,7 +28,7 @@ logger = logging.getLogger(__name__)
# -----------------------------------------------------------------------------
async def open_serial_transport(spec):
async def open_serial_transport(spec: str) -> Transport:
'''
Open a serial port transport.
The parameter string has this syntax:

View File

@@ -27,7 +27,7 @@ logger = logging.getLogger(__name__)
# -----------------------------------------------------------------------------
async def open_tcp_client_transport(spec):
async def open_tcp_client_transport(spec: str) -> Transport:
'''
Open a TCP client transport.
The parameter string has this syntax:
@@ -39,7 +39,7 @@ async def open_tcp_client_transport(spec):
class TcpPacketSource(StreamPacketSource):
def connection_lost(self, exc):
logger.debug(f'connection lost: {exc}')
self.terminated.set_result(exc)
self.on_transport_lost()
remote_host, remote_port = spec.split(':')
tcp_transport, packet_source = await asyncio.get_running_loop().create_connection(

View File

@@ -15,6 +15,7 @@
# -----------------------------------------------------------------------------
# Imports
# -----------------------------------------------------------------------------
from __future__ import annotations
import asyncio
import logging
@@ -27,7 +28,7 @@ logger = logging.getLogger(__name__)
# -----------------------------------------------------------------------------
async def open_tcp_server_transport(spec):
async def open_tcp_server_transport(spec: str) -> Transport:
'''
Open a TCP server transport.
The parameter string has this syntax:
@@ -42,7 +43,7 @@ async def open_tcp_server_transport(spec):
async def close(self):
await super().close()
class TcpServerProtocol:
class TcpServerProtocol(asyncio.BaseProtocol):
def __init__(self, packet_source, packet_sink):
self.packet_source = packet_source
self.packet_sink = packet_sink

View File

@@ -27,7 +27,7 @@ logger = logging.getLogger(__name__)
# -----------------------------------------------------------------------------
async def open_udp_transport(spec):
async def open_udp_transport(spec: str) -> Transport:
'''
Open a UDP transport.
The parameter string has this syntax:

View File

@@ -60,7 +60,7 @@ def load_libusb():
usb1.loadLibrary(libusb_dll)
async def open_usb_transport(spec):
async def open_usb_transport(spec: str) -> Transport:
'''
Open a USB transport.
The moniker string has this syntax:
@@ -206,10 +206,11 @@ async def open_usb_transport(spec):
logger.debug('OUT transfer likely already completed')
class UsbPacketSource(asyncio.Protocol, ParserSource):
def __init__(self, context, device, acl_in, events_in):
def __init__(self, context, device, metadata, acl_in, events_in):
super().__init__()
self.context = context
self.device = device
self.metadata = metadata
self.acl_in = acl_in
self.events_in = events_in
self.loop = asyncio.get_running_loop()
@@ -510,6 +511,10 @@ async def open_usb_transport(spec):
f'events_in=0x{events_in:02X}, '
)
device_metadata = {
'vendor_id': found.getVendorID(),
'product_id': found.getProductID(),
}
device = found.open()
# Auto-detach the kernel driver if supported
@@ -535,7 +540,7 @@ async def open_usb_transport(spec):
except usb1.USBError:
logger.warning('failed to set configuration')
source = UsbPacketSource(context, device, acl_in, events_in)
source = UsbPacketSource(context, device, device_metadata, acl_in, events_in)
sink = UsbPacketSink(device, acl_out)
return UsbTransport(context, device, interface, setting, source, sink)
except usb1.USBError as error:

View File

@@ -17,6 +17,9 @@
# -----------------------------------------------------------------------------
import logging
from typing import Optional
from .common import Transport
from .file import open_file_transport
# -----------------------------------------------------------------------------
@@ -26,7 +29,7 @@ logger = logging.getLogger(__name__)
# -----------------------------------------------------------------------------
async def open_vhci_transport(spec):
async def open_vhci_transport(spec: Optional[str]) -> Transport:
'''
Open a VHCI transport (only available on some platforms).
The parameter string is either empty (to use the default VHCI device
@@ -42,15 +45,15 @@ async def open_vhci_transport(spec):
# Override the source's `data_received` method so that we can
# filter out the vendor packet that is received just after the
# initial open
def vhci_data_received(data):
def vhci_data_received(data: bytes) -> None:
if len(data) > 0 and data[0] == HCI_VENDOR_PKT:
if len(data) == 4:
hci_index = data[2] << 8 | data[3]
logger.info(f'HCI index {hci_index}')
else:
transport.source.parser.feed_data(data)
transport.source.parser.feed_data(data) # type: ignore
transport.source.data_received = vhci_data_received
transport.source.data_received = vhci_data_received # type: ignore
# Write the initial config
transport.sink.on_packet(bytes([HCI_VENDOR_PKT, HCI_BREDR]))

View File

@@ -16,9 +16,9 @@
# Imports
# -----------------------------------------------------------------------------
import logging
import websockets
import websockets.client
from .common import PumpedPacketSource, PumpedPacketSink, PumpedTransport
from .common import PumpedPacketSource, PumpedPacketSink, PumpedTransport, Transport
# -----------------------------------------------------------------------------
# Logging
@@ -27,23 +27,25 @@ logger = logging.getLogger(__name__)
# -----------------------------------------------------------------------------
async def open_ws_client_transport(spec):
async def open_ws_client_transport(spec: str) -> Transport:
'''
Open a WebSocket client transport.
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(':')
uri = f'ws://{remote_host}:{remote_port}'
websocket = await websockets.connect(uri)
websocket = await websockets.client.connect(spec)
transport = PumpedTransport(
class WsTransport(PumpedTransport):
async def close(self):
await super().close()
await websocket.close()
transport = WsTransport(
PumpedPacketSource(websocket.recv),
PumpedPacketSink(websocket.send),
websocket.close,
)
transport.start()
return transport

View File

@@ -15,7 +15,6 @@
# -----------------------------------------------------------------------------
# Imports
# -----------------------------------------------------------------------------
import asyncio
import logging
import websockets
@@ -28,7 +27,7 @@ logger = logging.getLogger(__name__)
# -----------------------------------------------------------------------------
async def open_ws_server_transport(spec):
async def open_ws_server_transport(spec: str) -> Transport:
'''
Open a WebSocket server transport.
The parameter string has this syntax:
@@ -43,7 +42,7 @@ async def open_ws_server_transport(spec):
def __init__(self):
source = ParserSource()
sink = PumpedPacketSink(self.send_packet)
self.connection = asyncio.get_running_loop().create_future()
self.connection = None
self.server = None
super().__init__(source, sink)
@@ -63,7 +62,7 @@ async def open_ws_server_transport(spec):
f'new connection on {connection.local_address} '
f'from {connection.remote_address}'
)
self.connection.set_result(connection)
self.connection = connection
# pylint: disable=no-member
try:
async for packet in connection:
@@ -74,12 +73,14 @@ async def open_ws_server_transport(spec):
except websockets.WebSocketException as error:
logger.debug(f'exception while receiving packet: {error}')
# Wait for a new connection
self.connection = asyncio.get_running_loop().create_future()
# We're now disconnected
self.connection = None
async def send_packet(self, packet):
connection = await self.connection
return await connection.send(packet)
if self.connection is None:
logger.debug('no connection, dropping packet')
return
return await self.connection.send(packet)
local_host, local_port = spec.split(':')
transport = WsServerTransport()

View File

@@ -15,12 +15,24 @@
# -----------------------------------------------------------------------------
# Imports
# -----------------------------------------------------------------------------
from __future__ import annotations
import asyncio
import logging
import traceback
import collections
import sys
from typing import Awaitable, Set, TypeVar
from typing import (
Awaitable,
Set,
TypeVar,
List,
Tuple,
Callable,
Any,
Optional,
Union,
overload,
)
from functools import wraps
from pyee import EventEmitter
@@ -64,6 +76,102 @@ def composite_listener(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')

0
bumble/vendor/__init__.py vendored Normal file
View File

0
bumble/vendor/android/__init__.py vendored Normal file
View File

318
bumble/vendor/android/hci.py vendored Normal file
View File

@@ -0,0 +1,318 @@
# 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 struct
from bumble.hci import (
name_or_number,
hci_vendor_command_op_code,
Address,
HCI_Constant,
HCI_Object,
HCI_Command,
HCI_Vendor_Event,
STATUS_SPEC,
)
# -----------------------------------------------------------------------------
# Constants
# -----------------------------------------------------------------------------
# Android Vendor Specific Commands and Events.
# Only a subset of the commands are implemented here currently.
#
# pylint: disable-next=line-too-long
# See https://source.android.com/docs/core/connect/bluetooth/hci_requirements#chip-capabilities-and-configuration
HCI_LE_GET_VENDOR_CAPABILITIES_COMMAND = hci_vendor_command_op_code(0x153)
HCI_LE_APCF_COMMAND = hci_vendor_command_op_code(0x157)
HCI_GET_CONTROLLER_ACTIVITY_ENERGY_INFO_COMMAND = hci_vendor_command_op_code(0x159)
HCI_A2DP_HARDWARE_OFFLOAD_COMMAND = hci_vendor_command_op_code(0x15D)
HCI_BLUETOOTH_QUALITY_REPORT_COMMAND = hci_vendor_command_op_code(0x15E)
HCI_DYNAMIC_AUDIO_BUFFER_COMMAND = hci_vendor_command_op_code(0x15F)
HCI_BLUETOOTH_QUALITY_REPORT_EVENT = 0x58
HCI_Command.register_commands(globals())
HCI_Vendor_Event.register_subevents(globals())
# -----------------------------------------------------------------------------
@HCI_Command.command(
return_parameters_fields=[
('status', STATUS_SPEC),
('max_advt_instances', 1),
('offloaded_resolution_of_private_address', 1),
('total_scan_results_storage', 2),
('max_irk_list_sz', 1),
('filtering_support', 1),
('max_filter', 1),
('activity_energy_info_support', 1),
('version_supported', 2),
('total_num_of_advt_tracked', 2),
('extended_scan_support', 1),
('debug_logging_supported', 1),
('le_address_generation_offloading_support', 1),
('a2dp_source_offload_capability_mask', 4),
('bluetooth_quality_report_support', 1),
('dynamic_audio_buffer_support', 4),
]
)
class HCI_LE_Get_Vendor_Capabilities_Command(HCI_Command):
# pylint: disable=line-too-long
'''
See https://source.android.com/docs/core/connect/bluetooth/hci_requirements#vendor-specific-capabilities
'''
@classmethod
def parse_return_parameters(cls, parameters):
# There are many versions of this data structure, so we need to parse until
# there are no more bytes to parse, and leave un-signal parameters set to
# None (older versions)
nones = {field: None for field, _ in cls.return_parameters_fields}
return_parameters = HCI_Object(cls.return_parameters_fields, **nones)
try:
offset = 0
for field in cls.return_parameters_fields:
field_name, field_type = field
field_value, field_size = HCI_Object.parse_field(
parameters, offset, field_type
)
setattr(return_parameters, field_name, field_value)
offset += field_size
except struct.error:
pass
return return_parameters
# -----------------------------------------------------------------------------
@HCI_Command.command(
fields=[
(
'opcode',
{
'size': 1,
'mapper': lambda x: HCI_LE_APCF_Command.opcode_name(x),
},
),
('payload', '*'),
],
return_parameters_fields=[
('status', STATUS_SPEC),
(
'opcode',
{
'size': 1,
'mapper': lambda x: HCI_LE_APCF_Command.opcode_name(x),
},
),
('payload', '*'),
],
)
class HCI_LE_APCF_Command(HCI_Command):
# pylint: disable=line-too-long
'''
See https://source.android.com/docs/core/connect/bluetooth/hci_requirements#le_apcf_command
NOTE: the subcommand-specific payloads are left as opaque byte arrays in this
implementation. A future enhancement may define subcommand-specific data structures.
'''
# APCF Subcommands
# TODO: use the OpenIntEnum class (when upcoming PR is merged)
APCF_ENABLE = 0x00
APCF_SET_FILTERING_PARAMETERS = 0x01
APCF_BROADCASTER_ADDRESS = 0x02
APCF_SERVICE_UUID = 0x03
APCF_SERVICE_SOLICITATION_UUID = 0x04
APCF_LOCAL_NAME = 0x05
APCF_MANUFACTURER_DATA = 0x06
APCF_SERVICE_DATA = 0x07
APCF_TRANSPORT_DISCOVERY_SERVICE = 0x08
APCF_AD_TYPE_FILTER = 0x09
APCF_READ_EXTENDED_FEATURES = 0xFF
OPCODE_NAMES = {
APCF_ENABLE: 'APCF_ENABLE',
APCF_SET_FILTERING_PARAMETERS: 'APCF_SET_FILTERING_PARAMETERS',
APCF_BROADCASTER_ADDRESS: 'APCF_BROADCASTER_ADDRESS',
APCF_SERVICE_UUID: 'APCF_SERVICE_UUID',
APCF_SERVICE_SOLICITATION_UUID: 'APCF_SERVICE_SOLICITATION_UUID',
APCF_LOCAL_NAME: 'APCF_LOCAL_NAME',
APCF_MANUFACTURER_DATA: 'APCF_MANUFACTURER_DATA',
APCF_SERVICE_DATA: 'APCF_SERVICE_DATA',
APCF_TRANSPORT_DISCOVERY_SERVICE: 'APCF_TRANSPORT_DISCOVERY_SERVICE',
APCF_AD_TYPE_FILTER: 'APCF_AD_TYPE_FILTER',
APCF_READ_EXTENDED_FEATURES: 'APCF_READ_EXTENDED_FEATURES',
}
@classmethod
def opcode_name(cls, opcode):
return name_or_number(cls.OPCODE_NAMES, opcode)
# -----------------------------------------------------------------------------
@HCI_Command.command(
return_parameters_fields=[
('status', STATUS_SPEC),
('total_tx_time_ms', 4),
('total_rx_time_ms', 4),
('total_idle_time_ms', 4),
('total_energy_used', 4),
],
)
class HCI_Get_Controller_Activity_Energy_Info_Command(HCI_Command):
# pylint: disable=line-too-long
'''
See https://source.android.com/docs/core/connect/bluetooth/hci_requirements#le_get_controller_activity_energy_info
'''
# -----------------------------------------------------------------------------
@HCI_Command.command(
fields=[
(
'opcode',
{
'size': 1,
'mapper': lambda x: HCI_A2DP_Hardware_Offload_Command.opcode_name(x),
},
),
('payload', '*'),
],
return_parameters_fields=[
('status', STATUS_SPEC),
(
'opcode',
{
'size': 1,
'mapper': lambda x: HCI_A2DP_Hardware_Offload_Command.opcode_name(x),
},
),
('payload', '*'),
],
)
class HCI_A2DP_Hardware_Offload_Command(HCI_Command):
# pylint: disable=line-too-long
'''
See https://source.android.com/docs/core/connect/bluetooth/hci_requirements#a2dp-hardware-offload-support
NOTE: the subcommand-specific payloads are left as opaque byte arrays in this
implementation. A future enhancement may define subcommand-specific data structures.
'''
# A2DP Hardware Offload Subcommands
# TODO: use the OpenIntEnum class (when upcoming PR is merged)
START_A2DP_OFFLOAD = 0x01
STOP_A2DP_OFFLOAD = 0x02
OPCODE_NAMES = {
START_A2DP_OFFLOAD: 'START_A2DP_OFFLOAD',
STOP_A2DP_OFFLOAD: 'STOP_A2DP_OFFLOAD',
}
@classmethod
def opcode_name(cls, opcode):
return name_or_number(cls.OPCODE_NAMES, opcode)
# -----------------------------------------------------------------------------
@HCI_Command.command(
fields=[
(
'opcode',
{
'size': 1,
'mapper': lambda x: HCI_Dynamic_Audio_Buffer_Command.opcode_name(x),
},
),
('payload', '*'),
],
return_parameters_fields=[
('status', STATUS_SPEC),
(
'opcode',
{
'size': 1,
'mapper': lambda x: HCI_Dynamic_Audio_Buffer_Command.opcode_name(x),
},
),
('payload', '*'),
],
)
class HCI_Dynamic_Audio_Buffer_Command(HCI_Command):
# pylint: disable=line-too-long
'''
See https://source.android.com/docs/core/connect/bluetooth/hci_requirements#dynamic-audio-buffer-command
NOTE: the subcommand-specific payloads are left as opaque byte arrays in this
implementation. A future enhancement may define subcommand-specific data structures.
'''
# Dynamic Audio Buffer Subcommands
# TODO: use the OpenIntEnum class (when upcoming PR is merged)
GET_AUDIO_BUFFER_TIME_CAPABILITY = 0x01
OPCODE_NAMES = {
GET_AUDIO_BUFFER_TIME_CAPABILITY: 'GET_AUDIO_BUFFER_TIME_CAPABILITY',
}
@classmethod
def opcode_name(cls, opcode):
return name_or_number(cls.OPCODE_NAMES, opcode)
# -----------------------------------------------------------------------------
@HCI_Vendor_Event.event(
fields=[
('quality_report_id', 1),
('packet_types', 1),
('connection_handle', 2),
('connection_role', {'size': 1, 'mapper': HCI_Constant.role_name}),
('tx_power_level', -1),
('rssi', -1),
('snr', 1),
('unused_afh_channel_count', 1),
('afh_select_unideal_channel_count', 1),
('lsto', 2),
('connection_piconet_clock', 4),
('retransmission_count', 4),
('no_rx_count', 4),
('nak_count', 4),
('last_tx_ack_timestamp', 4),
('flow_off_count', 4),
('last_flow_on_timestamp', 4),
('buffer_overflow_bytes', 4),
('buffer_underflow_bytes', 4),
('bdaddr', Address.parse_address),
('cal_failed_item_count', 1),
('tx_total_packets', 4),
('tx_unacked_packets', 4),
('tx_flushed_packets', 4),
('tx_last_subevent_packets', 4),
('crc_error_packets', 4),
('rx_duplicate_packets', 4),
('vendor_specific_parameters', '*'),
]
)
class HCI_Bluetooth_Quality_Report_Event(HCI_Vendor_Event):
# pylint: disable=line-too-long
'''
See https://source.android.com/docs/core/connect/bluetooth/hci_requirements#bluetooth-quality-report-sub-event
'''

0
bumble/vendor/zephyr/__init__.py vendored Normal file
View File

88
bumble/vendor/zephyr/hci.py vendored Normal file
View File

@@ -0,0 +1,88 @@
# 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 bumble.hci import (
hci_vendor_command_op_code,
HCI_Command,
STATUS_SPEC,
)
# -----------------------------------------------------------------------------
# Constants
# -----------------------------------------------------------------------------
# Zephyr RTOS Vendor Specific Commands and Events.
# Only a subset of the commands are implemented here currently.
#
# pylint: disable-next=line-too-long
# See https://github.com/zephyrproject-rtos/zephyr/blob/main/include/zephyr/bluetooth/hci_vs.h
HCI_WRITE_TX_POWER_LEVEL_COMMAND = hci_vendor_command_op_code(0x000E)
HCI_READ_TX_POWER_LEVEL_COMMAND = hci_vendor_command_op_code(0x000F)
HCI_Command.register_commands(globals())
# -----------------------------------------------------------------------------
class TX_Power_Level_Command:
'''
Base class for read and write TX power level HCI commands
'''
TX_POWER_HANDLE_TYPE_ADV = 0x00
TX_POWER_HANDLE_TYPE_SCAN = 0x01
TX_POWER_HANDLE_TYPE_CONN = 0x02
# -----------------------------------------------------------------------------
@HCI_Command.command(
fields=[('handle_type', 1), ('connection_handle', 2), ('tx_power_level', -1)],
return_parameters_fields=[
('status', STATUS_SPEC),
('handle_type', 1),
('connection_handle', 2),
('selected_tx_power_level', -1),
],
)
class HCI_Write_Tx_Power_Level_Command(HCI_Command, TX_Power_Level_Command):
'''
Write TX power level. See BT_HCI_OP_VS_WRITE_TX_POWER_LEVEL in
https://github.com/zephyrproject-rtos/zephyr/blob/main/include/zephyr/bluetooth/hci_vs.h
Power level is in dB. Connection handle for TX_POWER_HANDLE_TYPE_ADV and
TX_POWER_HANDLE_TYPE_SCAN should be zero.
'''
# -----------------------------------------------------------------------------
@HCI_Command.command(
fields=[('handle_type', 1), ('connection_handle', 2)],
return_parameters_fields=[
('status', STATUS_SPEC),
('handle_type', 1),
('connection_handle', 2),
('tx_power_level', -1),
],
)
class HCI_Read_Tx_Power_Level_Command(HCI_Command, TX_Power_Level_Command):
'''
Read TX power level. See BT_HCI_OP_VS_READ_TX_POWER_LEVEL in
https://github.com/zephyrproject-rtos/zephyr/blob/main/include/zephyr/bluetooth/hci_vs.h
Power level is in dB. Connection handle for TX_POWER_HANDLE_TYPE_ADV and
TX_POWER_HANDLE_TYPE_SCAN should be zero.
'''

View File

@@ -36,6 +36,9 @@ nav:
- HCI Socket: transports/hci_socket.md
- Android Emulator: transports/android_emulator.md
- File: transports/file.md
- Drivers:
- Overview: drivers/index.md
- Realtek: drivers/realtek.md
- API:
- Guide: api/guide.md
- Examples: api/examples.md
@@ -61,6 +64,7 @@ nav:
- Linux: platforms/linux.md
- Windows: platforms/windows.md
- Android: platforms/android.md
- Zephyr: platforms/zephyr.md
- Examples:
- Overview: examples/index.md

Binary file not shown.

View File

@@ -0,0 +1,10 @@
DRIVERS
=======
Some Bluetooth controllers require a driver to function properly.
This may include, for instance, loading a Firmware image or patch,
loading a configuration.
Drivers included in the module are:
* [Realtek](realtek.md): Loading of Firmware and Config for Realtek USB dongles.

View File

@@ -0,0 +1,62 @@
REALTEK DRIVER
==============
This driver supports loading firmware images and optional config data to
USB dongles with a Realtek chipset.
A number of USB dongles are supported, but likely not all.
When using a USB dongle, the USB product ID and manufacturer ID are used
to find whether a matching set of firmware image and config data
is needed for that specific model. If a match exists, the driver will try
load the firmware image and, if needed, config data.
The driver will look for those files by name, in order, in:
* The directory specified by the environment variable `BUMBLE_RTK_FIRMWARE_DIR`
if set.
* The directory `<package-dir>/drivers/rtk_fw` where `<package-dir>` is the directory
where the `bumble` package is installed.
* The current directory.
Obtaining Firmware Images and Config Data
-----------------------------------------
Firmware images and config data may be obtained from a variety of online
sources.
To facilitate finding a downloading the, the utility program `bumble-rtk-fw-download`
may be used.
```
Usage: bumble-rtk-fw-download [OPTIONS]
Download RTK firmware images and configs.
Options:
--output-dir TEXT Output directory where the files will be
saved [default: .]
--source [linux-kernel|realtek-opensource|linux-from-scratch]
[default: linux-kernel]
--single TEXT Only download a single image set, by its
base name
--force Overwrite files if they already exist
--parse Parse the FW image after saving
--help Show this message and exit.
```
Utility
-------
The `bumble-rtk-util` utility may be used to interact with a Realtek USB dongle
and/or firmware images.
```
Usage: bumble-rtk-util [OPTIONS] COMMAND [ARGS]...
Options:
--help Show this message and exit.
Commands:
drop Drop a firmware image from the USB dongle.
info Get the firmware info from a USB dongle.
load Load a firmware image into the USB dongle.
parse Parse a firmware image.
```

View File

@@ -9,3 +9,4 @@ For platform-specific information, see the following pages:
* :material-linux: Linux - see the [Linux platform page](linux.md)
* :material-microsoft-windows: Windows - see the [Windows platform page](windows.md)
* :material-android: Android - see the [Android platform page](android.md)
* :material-memory: Zephyr - see the [Zephyr platform page](zephyr.md)

View File

@@ -0,0 +1,51 @@
:material-memory: ZEPHYR PLATFORM
=================================
Set TX Power on nRF52840
------------------------
The Nordic nRF52840 supports Zephyr's vendor specific HCI command for setting TX
power during advertising, connection, or scanning. With the example [HCI
USB](https://docs.zephyrproject.org/latest/samples/bluetooth/hci_usb/README.html)
application, an [nRF52840
dongle](https://www.nordicsemi.com/Products/Development-
hardware/nRF52840-Dongle) can be used as a Bumble controller.
To add dynamic TX power support to the HCI USB application, add the following to
`zephyr/samples/bluetooth/hci_usb/prj.conf` and build.
```
CONFIG_BT_CTLR_ADVANCED_FEATURES=y
CONFIG_BT_CTLR_CONN_RSSI=y
CONFIG_BT_CTLR_TX_PWR_DYNAMIC_CONTROL=y
```
Alternatively, a prebuilt firmware application can be downloaded here:
[hci_usb.zip](../downloads/zephyr/hci_usb.zip).
Put the nRF52840 dongle into bootloader mode by pressing the RESET button. The
LED should pulse red. Load the firmware application with the `nrfutil` tool:
```
nrfutil dfu usb-serial -pkg hci_usb.zip -p /dev/ttyACM0
```
The vendor specific HCI commands to read and write TX power are defined in
`bumble/vendor/zephyr/hci.py` and may be used as such:
```python
from bumble.vendor.zephyr.hci import HCI_Write_Tx_Power_Level_Command
# set advertising power to -4 dB
response = await host.send_command(
HCI_Write_Tx_Power_Level_Command(
handle_type=HCI_Write_Tx_Power_Level_Command.TX_POWER_HANDLE_TYPE_ADV,
connection_handle=0,
tx_power_level=-4,
)
)
if response.return_parameters.status == HCI_SUCCESS:
print(f"TX power set to {response.return_parameters.selected_tx_power_level}")
```

View File

@@ -1,11 +1,11 @@
UDP TRANSPORT
=============
WEBSOCKET CLIENT TRANSPORT
==========================
The UDP transport is a UDP socket, receiving packets on a specified port number, and sending packets to a specified host and port number.
The WebSocket Client transport is WebSocket connection to a WebSocket server over which HCI packets
are sent and received.
## Moniker
The moniker syntax for a UDP transport is: `udp:<local-host>:<local-port>,<remote-host>:<remote-port>`.
The moniker syntax for a WebSocket Client transport is: `ws-client:<ws-url>`
!!! example
`udp:0.0.0.0:9000,127.0.0.1:9001`
UDP transport where packets are received on port `9000` and sent to `127.0.0.1` on port `9001`
`ws-client:ws://localhost:1234/some/path`

View File

@@ -1,11 +1,13 @@
UDP TRANSPORT
=============
WEBSOCKET SERVER TRANSPORT
==========================
The UDP transport is a UDP socket, receiving packets on a specified port number, and sending packets to a specified host and port number.
The WebSocket Server transport is WebSocket server that accepts connections from a WebSocket
client. HCI packets are sent and received over the connection.
## Moniker
The moniker syntax for a UDP transport is: `udp:<local-host>:<local-port>,<remote-host>:<remote-port>`.
The moniker syntax for a WebSocket Server transport is: `ws-server:<host>:<port>`,
where `<host>` may be the address of a local network interface, or `_`to accept connections on all local network interfaces. `<port>` is the TCP port number on which to accept connections.
!!! example
`udp:0.0.0.0:9000,127.0.0.1:9001`
UDP transport where packets are received on port `9000` and sent to `127.0.0.1` on port `9001`
`ws-server:_:9001`

View File

@@ -3,7 +3,7 @@ channels:
- defaults
- conda-forge
dependencies:
- pip=20
- pip=23
- python=3.8
- pip:
- --editable .[development,documentation,test]

View File

@@ -30,7 +30,7 @@ from bumble.core import (
BT_RFCOMM_PROTOCOL_ID,
BT_BR_EDR_TRANSPORT,
)
from bumble.rfcomm import Client
from bumble import rfcomm, hfp
from bumble.sdp import (
Client as SDP_Client,
DataElement,
@@ -39,7 +39,9 @@ from bumble.sdp import (
SDP_SERVICE_CLASS_ID_LIST_ATTRIBUTE_ID,
SDP_BLUETOOTH_PROFILE_DESCRIPTOR_LIST_ATTRIBUTE_ID,
)
from bumble.hfp import HfpProtocol
logger = logging.getLogger(__name__)
# -----------------------------------------------------------------------------
@@ -181,7 +183,7 @@ async def main():
# Create a client and start it
print('@@@ Starting to RFCOMM client...')
rfcomm_client = Client(device, connection)
rfcomm_client = rfcomm.Client(device, connection)
rfcomm_mux = await rfcomm_client.start()
print('@@@ Started')
@@ -196,7 +198,7 @@ async def main():
return
# Protocol loop (just for testing at this point)
protocol = HfpProtocol(session)
protocol = hfp.HfpProtocol(session)
while True:
line = await protocol.next_line()

View File

@@ -21,82 +21,22 @@ import os
import logging
import json
import websockets
from typing import Optional
from bumble.device import Device
from bumble.transport import open_transport_or_link
from bumble.rfcomm import Server as RfcommServer
from bumble.sdp import (
DataElement,
ServiceAttribute,
SDP_SERVICE_RECORD_HANDLE_ATTRIBUTE_ID,
SDP_SERVICE_CLASS_ID_LIST_ATTRIBUTE_ID,
SDP_PROTOCOL_DESCRIPTOR_LIST_ATTRIBUTE_ID,
SDP_BLUETOOTH_PROFILE_DESCRIPTOR_LIST_ATTRIBUTE_ID,
)
from bumble.core import (
BT_GENERIC_AUDIO_SERVICE,
BT_HANDSFREE_SERVICE,
BT_L2CAP_PROTOCOL_ID,
BT_RFCOMM_PROTOCOL_ID,
)
from bumble.hfp import HfpProtocol
# -----------------------------------------------------------------------------
def make_sdp_records(rfcomm_channel):
return {
0x00010001: [
ServiceAttribute(
SDP_SERVICE_RECORD_HANDLE_ATTRIBUTE_ID,
DataElement.unsigned_integer_32(0x00010001),
),
ServiceAttribute(
SDP_SERVICE_CLASS_ID_LIST_ATTRIBUTE_ID,
DataElement.sequence(
[
DataElement.uuid(BT_HANDSFREE_SERVICE),
DataElement.uuid(BT_GENERIC_AUDIO_SERVICE),
]
),
),
ServiceAttribute(
SDP_PROTOCOL_DESCRIPTOR_LIST_ATTRIBUTE_ID,
DataElement.sequence(
[
DataElement.sequence([DataElement.uuid(BT_L2CAP_PROTOCOL_ID)]),
DataElement.sequence(
[
DataElement.uuid(BT_RFCOMM_PROTOCOL_ID),
DataElement.unsigned_integer_8(rfcomm_channel),
]
),
]
),
),
ServiceAttribute(
SDP_BLUETOOTH_PROFILE_DESCRIPTOR_LIST_ATTRIBUTE_ID,
DataElement.sequence(
[
DataElement.sequence(
[
DataElement.uuid(BT_HANDSFREE_SERVICE),
DataElement.unsigned_integer_16(0x0105),
]
)
]
),
),
]
}
from bumble import hfp
from bumble.hfp import HfProtocol
# -----------------------------------------------------------------------------
class UiServer:
protocol = None
protocol: Optional[HfProtocol] = None
async def start(self):
# Start a Websocket server to receive events from a web page
"""Start a Websocket server to receive events from a web page."""
async def serve(websocket, _path):
while True:
try:
@@ -107,7 +47,7 @@ class UiServer:
message_type = parsed['type']
if message_type == 'at_command':
if self.protocol is not None:
self.protocol.send_command_line(parsed['command'])
await self.protocol.execute_command(parsed['command'])
except websockets.exceptions.ConnectionClosedOK:
pass
@@ -117,19 +57,11 @@ class UiServer:
# -----------------------------------------------------------------------------
async def protocol_loop(protocol):
await protocol.initialize_service()
while True:
await (protocol.next_line())
# -----------------------------------------------------------------------------
def on_dlc(dlc):
def on_dlc(dlc, configuration: hfp.Configuration):
print('*** DLC connected', dlc)
protocol = HfpProtocol(dlc)
protocol = HfProtocol(dlc, configuration)
UiServer.protocol = protocol
asyncio.create_task(protocol_loop(protocol))
asyncio.create_task(protocol.run())
# -----------------------------------------------------------------------------
@@ -143,6 +75,27 @@ async def main():
async with await open_transport_or_link(sys.argv[2]) as (hci_source, hci_sink):
print('<<< connected')
# Hands-Free profile configuration.
# TODO: load configuration from file.
configuration = hfp.Configuration(
supported_hf_features=[
hfp.HfFeature.THREE_WAY_CALLING,
hfp.HfFeature.REMOTE_VOLUME_CONTROL,
hfp.HfFeature.ENHANCED_CALL_STATUS,
hfp.HfFeature.ENHANCED_CALL_CONTROL,
hfp.HfFeature.CODEC_NEGOTIATION,
hfp.HfFeature.HF_INDICATORS,
hfp.HfFeature.ESCO_S4_SETTINGS_SUPPORTED,
],
supported_hf_indicators=[
hfp.HfIndicator.BATTERY_LEVEL,
],
supported_audio_codecs=[
hfp.AudioCodec.CVSD,
hfp.AudioCodec.MSBC,
],
)
# Create a device
device = Device.from_config_file_with_hci(sys.argv[1], hci_source, hci_sink)
device.classic_enabled = True
@@ -151,11 +104,13 @@ async def main():
rfcomm_server = RfcommServer(device)
# Listen for incoming DLC connections
channel_number = rfcomm_server.listen(on_dlc)
channel_number = rfcomm_server.listen(lambda dlc: on_dlc(dlc, configuration))
print(f'### Listening for connection on channel {channel_number}')
# Advertise the HFP RFComm channel in the SDP
device.sdp_service_records = make_sdp_records(channel_number)
device.sdp_service_records = {
0x00010001: hfp.sdp_records(0x00010001, channel_number, configuration)
}
# Let's go!
await device.power_on()

View File

@@ -20,83 +20,109 @@ import sys
import os
import logging
from bumble.core import UUID
from bumble.device import Device
from bumble.transport import open_transport_or_link
from bumble.core import BT_L2CAP_PROTOCOL_ID, BT_RFCOMM_PROTOCOL_ID, UUID
from bumble.rfcomm import Server
from bumble.sdp import (
DataElement,
ServiceAttribute,
SDP_PUBLIC_BROWSE_ROOT,
SDP_SERVICE_RECORD_HANDLE_ATTRIBUTE_ID,
SDP_BROWSE_GROUP_LIST_ATTRIBUTE_ID,
SDP_SERVICE_CLASS_ID_LIST_ATTRIBUTE_ID,
SDP_PROTOCOL_DESCRIPTOR_LIST_ATTRIBUTE_ID,
)
from bumble.utils import AsyncRunner
from bumble.rfcomm import make_service_sdp_records
# -----------------------------------------------------------------------------
def sdp_records(channel):
def sdp_records(channel, uuid):
service_record_handle = 0x00010001
return {
0x00010001: [
ServiceAttribute(
SDP_SERVICE_RECORD_HANDLE_ATTRIBUTE_ID,
DataElement.unsigned_integer_32(0x00010001),
),
ServiceAttribute(
SDP_BROWSE_GROUP_LIST_ATTRIBUTE_ID,
DataElement.sequence([DataElement.uuid(SDP_PUBLIC_BROWSE_ROOT)]),
),
ServiceAttribute(
SDP_SERVICE_CLASS_ID_LIST_ATTRIBUTE_ID,
DataElement.sequence(
[DataElement.uuid(UUID('E6D55659-C8B4-4B85-96BB-B1143AF6D3AE'))]
),
),
ServiceAttribute(
SDP_PROTOCOL_DESCRIPTOR_LIST_ATTRIBUTE_ID,
DataElement.sequence(
[
DataElement.sequence([DataElement.uuid(BT_L2CAP_PROTOCOL_ID)]),
DataElement.sequence(
[
DataElement.uuid(BT_RFCOMM_PROTOCOL_ID),
DataElement.unsigned_integer_8(channel),
]
),
]
),
),
]
service_record_handle: make_service_sdp_records(
service_record_handle, channel, UUID(uuid)
)
}
# -----------------------------------------------------------------------------
def on_dlc(dlc):
print('*** DLC connected', dlc)
dlc.sink = lambda data: on_rfcomm_data_received(dlc, data)
def on_rfcomm_session(rfcomm_session, tcp_server):
print('*** RFComm session connected', rfcomm_session)
tcp_server.attach_session(rfcomm_session)
# -----------------------------------------------------------------------------
def on_rfcomm_data_received(dlc, data):
print(f'<<< Data received: {data.hex()}')
try:
message = data.decode('utf-8')
print(f'<<< Message = {message}')
except Exception:
pass
class TcpServerProtocol(asyncio.Protocol):
def __init__(self, server):
self.server = server
# Echo everything back
dlc.write(data)
def connection_made(self, transport):
peer_name = transport.get_extra_info('peer_name')
print(f'<<< TCP Server: connection from {peer_name}')
if self.server:
self.server.tcp_transport = transport
else:
transport.close()
def connection_lost(self, exc):
print('<<< TCP Server: connection lost')
if self.server:
self.server.tcp_transport = None
def data_received(self, data):
print(f'<<< TCP Server: data received: {len(data)} bytes - {data.hex()}')
if self.server:
self.server.tcp_data_received(data)
# -----------------------------------------------------------------------------
class TcpServer:
def __init__(self, port):
self.rfcomm_session = None
self.tcp_transport = None
AsyncRunner.spawn(self.run(port))
def attach_session(self, rfcomm_session):
if self.rfcomm_session:
self.rfcomm_session.sink = None
self.rfcomm_session = rfcomm_session
rfcomm_session.sink = self.rfcomm_data_received
def rfcomm_data_received(self, data):
print(f'<<< RFCOMM Data: {data.hex()}')
if self.tcp_transport:
self.tcp_transport.write(data)
else:
print('!!! no TCP connection, dropping data')
def tcp_data_received(self, data):
if self.rfcomm_session:
self.rfcomm_session.write(data)
else:
print('!!! no RFComm session, dropping data')
async def run(self, port):
print(f'$$$ Starting TCP server on port {port}')
server = await asyncio.get_running_loop().create_server(
lambda: TcpServerProtocol(self), '127.0.0.1', port
)
async with server:
await server.serve_forever()
# -----------------------------------------------------------------------------
async def main():
if len(sys.argv) < 3:
print('Usage: run_rfcomm_server.py <device-config> <transport-spec>')
print('example: run_rfcomm_server.py classic2.json usb:04b4:f901')
if len(sys.argv) < 4:
print(
'Usage: run_rfcomm_server.py <device-config> <transport-spec> '
'<tcp-port> [<uuid>]'
)
print('example: run_rfcomm_server.py classic2.json usb:0 8888')
return
tcp_port = int(sys.argv[3])
if len(sys.argv) >= 5:
uuid = sys.argv[4]
else:
uuid = 'E6D55659-C8B4-4B85-96BB-B1143AF6D3AE'
print('<<< connecting to HCI...')
async with await open_transport_or_link(sys.argv[2]) as (hci_source, hci_sink):
print('<<< connected')
@@ -105,15 +131,20 @@ async def main():
device = Device.from_config_file_with_hci(sys.argv[1], hci_source, hci_sink)
device.classic_enabled = True
# Create and register a server
# Create a TCP server
tcp_server = TcpServer(tcp_port)
# Create and register an RFComm server
rfcomm_server = Server(device)
# Listen for incoming DLC connections
channel_number = rfcomm_server.listen(on_dlc)
print(f'### Listening for connection on channel {channel_number}')
channel_number = rfcomm_server.listen(
lambda session: on_rfcomm_session(session, tcp_server)
)
print(f'### Listening for RFComm connections on channel {channel_number}')
# Setup the SDP to advertise this channel
device.sdp_service_records = sdp_records(channel_number)
device.sdp_service_records = sdp_records(channel_number, uuid)
# Start the controller
await device.power_on()

View File

@@ -62,7 +62,7 @@ async def main():
print(
f'>>> {color(advertisement.address, address_color)} '
f'[{color(address_type_string, type_color)}]'
f'{address_qualifier}:{separator}RSSI:{advertisement.rssi}'
f'{address_qualifier}:{separator}RSSI: {advertisement.rssi}'
f'{separator}'
f'{advertisement.data.to_string(separator)}'
)

2
rust/.gitignore vendored Normal file
View File

@@ -0,0 +1,2 @@
/target
/.idea

7
rust/CHANGELOG.md Normal file
View File

@@ -0,0 +1,7 @@
# Next
- Code-gen company ID table
# 0.1.0
- Initial release

1976
rust/Cargo.lock generated Normal file

File diff suppressed because it is too large Load Diff

86
rust/Cargo.toml Normal file
View File

@@ -0,0 +1,86 @@
[package]
name = "bumble"
description = "Rust API for the Bumble Bluetooth stack"
version = "0.1.0"
edition = "2021"
license = "Apache-2.0"
homepage = "https://google.github.io/bumble/index.html"
repository = "https://github.com/google/bumble"
documentation = "https://docs.rs/crate/bumble"
authors = ["Marshall Pierce <marshallpierce@google.com>"]
keywords = ["bluetooth", "ble"]
categories = ["api-bindings", "network-programming"]
rust-version = "1.70.0"
[dependencies]
pyo3 = { version = "0.18.3", features = ["macros"] }
pyo3-asyncio = { version = "0.18.0", features = ["tokio-runtime"] }
tokio = { version = "1.28.2", features = ["macros", "signal"] }
nom = "7.1.3"
strum = "0.25.0"
strum_macros = "0.25.0"
hex = "0.4.3"
itertools = "0.11.0"
lazy_static = "1.4.0"
thiserror = "1.0.41"
# Dev tools
file-header = { version = "0.1.2", optional = true }
globset = { version = "0.4.13", optional = true }
# CLI
anyhow = { version = "1.0.71", optional = true }
clap = { version = "4.3.3", features = ["derive"], optional = true }
directories = { version = "5.0.1", optional = true }
env_logger = { version = "0.10.0", optional = true }
futures = { version = "0.3.28", optional = true }
log = { version = "0.4.19", optional = true }
owo-colors = { version = "3.5.0", optional = true }
reqwest = { version = "0.11.20", features = ["blocking"], optional = true }
rusb = { version = "0.9.2", optional = true }
[dev-dependencies]
tokio = { version = "1.28.2", features = ["full"] }
tempfile = "3.6.0"
nix = "0.26.2"
anyhow = "1.0.71"
pyo3 = { version = "0.18.3", features = ["macros", "anyhow"] }
pyo3-asyncio = { version = "0.18.0", features = ["tokio-runtime", "attributes", "testing"] }
rusb = "0.9.2"
rand = "0.8.5"
clap = { version = "4.3.3", features = ["derive"] }
owo-colors = "3.5.0"
log = "0.4.19"
env_logger = "0.10.0"
[package.metadata.docs.rs]
rustdoc-args = ["--generate-link-to-definition"]
[[bin]]
name = "file-header"
path = "tools/file_header.rs"
required-features = ["dev-tools"]
[[bin]]
name = "gen-assigned-numbers"
path = "tools/gen_assigned_numbers.rs"
required-features = ["dev-tools"]
[[bin]]
name = "bumble"
path = "src/main.rs"
required-features = ["bumble-tools"]
# test entry point that uses pyo3_asyncio's test harness
[[test]]
name = "pytests"
path = "pytests/pytests.rs"
harness = false
[features]
anyhow = ["pyo3/anyhow"]
pyo3-asyncio-attributes = ["pyo3-asyncio/attributes"]
dev-tools = ["dep:anyhow", "dep:clap", "dep:file-header", "dep:globset"]
# separate feature for CLI so that dependencies don't spend time building these
bumble-tools = ["dep:clap", "anyhow", "dep:anyhow", "dep:directories", "pyo3-asyncio-attributes", "dep:owo-colors", "dep:reqwest", "dep:rusb", "dep:log", "dep:env_logger", "dep:futures"]
default = []

66
rust/README.md Normal file
View File

@@ -0,0 +1,66 @@
# What is this?
Rust wrappers around the [Bumble](https://github.com/google/bumble) Python API.
Method calls are mapped to the equivalent Python, and return types adapted where
relevant.
See the CLI in `src/main.rs` or the `examples` directory for how to use the
Bumble API.
# Usage
Set up a virtualenv for Bumble, or otherwise have an isolated Python environment
for Bumble and its dependencies.
Due to Python being
[picky about how its sys path is set up](https://github.com/PyO3/pyo3/issues/1741,
it's necessary to explicitly point to the virtualenv's `site-packages`. Use
suitable virtualenv paths as appropriate for your OS, as seen here running
the `battery_client` example:
```
PYTHONPATH=..:~/.virtualenvs/bumble/lib/python3.10/site-packages/ \
cargo run --example battery_client -- \
--transport android-netsim --target-addr F0:F1:F2:F3:F4:F5
```
Run the corresponding `battery_server` Python example, and launch an emulator in
Android Studio (currently, Canary is required) to run netsim.
# CLI
Explore the available subcommands:
```
PYTHONPATH=..:[virtualenv site-packages] \
cargo run --features bumble-tools --bin bumble -- --help
```
# Development
Run the tests:
```
PYTHONPATH=.. cargo test
```
Check lints:
```
cargo clippy --all-targets
```
## Code gen
To have the fastest startup while keeping the build simple, code gen for
assigned numbers is done with the `gen_assigned_numbers` tool. It should
be re-run whenever the Python assigned numbers are changed. To ensure that the
generated code is kept up to date, the Rust data is compared to the Python
in tests at `pytests/assigned_numbers.rs`.
To regenerate the assigned number tables based on the Python codebase:
```
PYTHONPATH=.. cargo run --bin gen-assigned-numbers --features dev-tools
```

View File

@@ -0,0 +1,112 @@
// 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
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//! Counterpart to the Python example `battery_server.py`.
//!
//! Start an Android emulator from Android Studio, or otherwise have netsim running.
//!
//! Run the server from the project root:
//! ```
//! PYTHONPATH=. python examples/battery_server.py \
//! examples/device1.json android-netsim
//! ```
//!
//! Then run this example from the `rust` directory:
//!
//! ```
//! PYTHONPATH=..:/path/to/virtualenv/site-packages/ \
//! cargo run --example battery_client -- \
//! --transport android-netsim \
//! --target-addr F0:F1:F2:F3:F4:F5
//! ```
use bumble::wrapper::{
device::{Device, Peer},
profile::BatteryServiceProxy,
transport::Transport,
PyObjectExt,
};
use clap::Parser as _;
use log::info;
use owo_colors::OwoColorize;
use pyo3::prelude::*;
#[pyo3_asyncio::tokio::main]
async fn main() -> PyResult<()> {
env_logger::builder()
.filter_level(log::LevelFilter::Info)
.init();
let cli = Cli::parse();
let transport = Transport::open(cli.transport).await?;
let device = Device::with_hci(
"Bumble",
"F0:F1:F2:F3:F4:F5",
transport.source()?,
transport.sink()?,
)?;
device.power_on().await?;
let conn = device.connect(&cli.target_addr).await?;
let mut peer = Peer::new(conn)?;
for mut s in peer.discover_services().await? {
s.discover_characteristics().await?;
}
let battery_service = peer
.create_service_proxy::<BatteryServiceProxy>()?
.ok_or(anyhow::anyhow!("No battery service found"))?;
let mut battery_level_char = battery_service
.battery_level()?
.ok_or(anyhow::anyhow!("No battery level characteristic"))?;
info!(
"{} {}",
"Initial Battery Level:".green(),
battery_level_char
.read_value()
.await?
.extract_with_gil::<u32>()?
);
battery_level_char
.subscribe(|_py, args| {
info!(
"{} {:?}",
"Battery level update:".green(),
args.get_item(0)?.extract::<u32>()?,
);
Ok(())
})
.await?;
// wait until user kills the process
tokio::signal::ctrl_c().await?;
Ok(())
}
#[derive(clap::Parser)]
#[command(author, version, about, long_about = None)]
struct Cli {
/// Bumble transport spec.
///
/// <https://google.github.io/bumble/transports/index.html>
#[arg(long)]
transport: String,
/// Address to connect to
#[arg(long)]
target_addr: String,
}

View File

@@ -0,0 +1,98 @@
// 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
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
use anyhow::anyhow;
use bumble::{
adv::{AdvertisementDataBuilder, CommonDataType},
wrapper::{
device::Device,
logging::{bumble_env_logging_level, py_logging_basic_config},
transport::Transport,
},
};
use clap::Parser as _;
use pyo3::PyResult;
use rand::Rng;
use std::path;
#[pyo3_asyncio::tokio::main]
async fn main() -> PyResult<()> {
env_logger::builder()
.filter_level(log::LevelFilter::Info)
.init();
let cli = Cli::parse();
if cli.log_hci {
py_logging_basic_config(bumble_env_logging_level("DEBUG"))?;
}
let transport = Transport::open(cli.transport).await?;
let mut device = Device::from_config_file_with_hci(
&cli.device_config,
transport.source()?,
transport.sink()?,
)?;
let mut adv_data = AdvertisementDataBuilder::new();
adv_data
.append(
CommonDataType::CompleteLocalName,
"Bumble from Rust".as_bytes(),
)
.map_err(|e| anyhow!(e))?;
// Randomized TX power
adv_data
.append(
CommonDataType::TxPowerLevel,
&[rand::thread_rng().gen_range(-100_i8..=20) as u8],
)
.map_err(|e| anyhow!(e))?;
device.set_advertising_data(adv_data)?;
device.power_on().await?;
println!("Advertising...");
device.start_advertising(true).await?;
// wait until user kills the process
tokio::signal::ctrl_c().await?;
println!("Stopping...");
device.stop_advertising().await?;
Ok(())
}
#[derive(clap::Parser)]
#[command(author, version, about, long_about = None)]
struct Cli {
/// Bumble device config.
///
/// See, for instance, `examples/device1.json` in the Python project.
#[arg(long)]
device_config: path::PathBuf,
/// Bumble transport spec.
///
/// <https://google.github.io/bumble/transports/index.html>
#[arg(long)]
transport: String,
/// Log HCI commands
#[arg(long)]
log_hci: bool,
}

185
rust/examples/scanner.rs Normal file
View File

@@ -0,0 +1,185 @@
// 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
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//! Counterpart to the Python example `run_scanner.py`.
//!
//! Device deduplication is done here rather than relying on the controller's filtering to provide
//! for additional features, like the ability to make deduplication time-bounded.
use bumble::{
adv::CommonDataType,
wrapper::{
core::AdvertisementDataUnit, device::Device, hci::AddressType, transport::Transport,
},
};
use clap::Parser as _;
use itertools::Itertools;
use owo_colors::{OwoColorize, Style};
use pyo3::PyResult;
use std::{
collections,
sync::{Arc, Mutex},
time,
};
#[pyo3_asyncio::tokio::main]
async fn main() -> PyResult<()> {
env_logger::builder()
.filter_level(log::LevelFilter::Info)
.init();
let cli = Cli::parse();
let transport = Transport::open(cli.transport).await?;
let mut device = Device::with_hci(
"Bumble",
"F0:F1:F2:F3:F4:F5",
transport.source()?,
transport.sink()?,
)?;
// in practice, devices can send multiple advertisements from the same address, so we keep
// track of a timestamp for each set of data
let seen_advertisements = Arc::new(Mutex::new(collections::HashMap::<
Vec<u8>,
collections::HashMap<Vec<AdvertisementDataUnit>, time::Instant>,
>::new()));
let seen_adv_clone = seen_advertisements.clone();
device.on_advertisement(move |_py, adv| {
let rssi = adv.rssi()?;
let data_units = adv.data()?.data_units()?;
let addr = adv.address()?;
let show_adv = if cli.filter_duplicates {
let addr_bytes = addr.as_le_bytes()?;
let mut seen_adv_cache = seen_adv_clone.lock().unwrap();
let expiry_duration = time::Duration::from_secs(cli.dedup_expiry_secs);
let advs_from_addr = seen_adv_cache
.entry(addr_bytes)
.or_insert_with(collections::HashMap::new);
// we expect cache hits to be the norm, so we do a separate lookup to avoid cloning
// on every lookup with entry()
let show = if let Some(prev) = advs_from_addr.get_mut(&data_units) {
let expired = prev.elapsed() > expiry_duration;
*prev = time::Instant::now();
expired
} else {
advs_from_addr.insert(data_units.clone(), time::Instant::now());
true
};
// clean out anything we haven't seen in a while
advs_from_addr.retain(|_, instant| instant.elapsed() <= expiry_duration);
show
} else {
true
};
if !show_adv {
return Ok(());
}
let addr_style = if adv.is_connectable()? {
Style::new().yellow()
} else {
Style::new().red()
};
let (type_style, qualifier) = match adv.address()?.address_type()? {
AddressType::PublicIdentity | AddressType::PublicDevice => (Style::new().cyan(), ""),
_ => {
if addr.is_static()? {
(Style::new().green(), "(static)")
} else if addr.is_resolvable()? {
(Style::new().magenta(), "(resolvable)")
} else {
(Style::new().default_color(), "")
}
}
};
println!(
">>> {} [{:?}] {qualifier}:\n RSSI: {}",
addr.as_hex()?.style(addr_style),
addr.address_type()?.style(type_style),
rssi,
);
data_units.into_iter().for_each(|(code, data)| {
let matching = CommonDataType::for_type_code(code).collect::<Vec<_>>();
let code_str = if matching.is_empty() {
format!("0x{}", hex::encode_upper([code.into()]))
} else {
matching
.iter()
.map(|t| format!("{}", t))
.join(" / ")
.blue()
.to_string()
};
// use the first matching type's formatted data, if any
let data_str = matching
.iter()
.filter_map(|t| {
t.format_data(&data).map(|formatted| {
format!(
"{} {}",
formatted,
format!("(raw: 0x{})", hex::encode_upper(&data)).dimmed()
)
})
})
.next()
.unwrap_or_else(|| format!("0x{}", hex::encode_upper(&data)));
println!(" [{}]: {}", code_str, data_str)
});
Ok(())
})?;
device.power_on().await?;
// do our own dedup
device.start_scanning(false).await?;
// wait until user kills the process
tokio::signal::ctrl_c().await?;
Ok(())
}
#[derive(clap::Parser)]
#[command(author, version, about, long_about = None)]
struct Cli {
/// Bumble transport spec.
///
/// <https://google.github.io/bumble/transports/index.html>
#[arg(long)]
transport: String,
/// Filter duplicate advertisements
#[arg(long, default_value_t = false)]
filter_duplicates: bool,
/// How long before a deduplicated advertisement that hasn't been seen in a while is considered
/// fresh again, in seconds
#[arg(long, default_value_t = 10, requires = "filter_duplicates")]
dedup_expiry_secs: u64,
}

View File

@@ -0,0 +1,44 @@
// 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
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
use bumble::wrapper::{self, core::Uuid16};
use pyo3::{intern, prelude::*, types::PyDict};
use std::collections;
#[pyo3_asyncio::tokio::test]
async fn company_ids_matches_python() -> PyResult<()> {
let ids_from_python = Python::with_gil(|py| {
PyModule::import(py, intern!(py, "bumble.company_ids"))?
.getattr(intern!(py, "COMPANY_IDENTIFIERS"))?
.downcast::<PyDict>()?
.into_iter()
.map(|(k, v)| {
Ok((
Uuid16::from_be_bytes(k.extract::<u16>()?.to_be_bytes()),
v.str()?.to_str()?.to_string(),
))
})
.collect::<PyResult<collections::HashMap<_, _>>>()
})?;
assert_eq!(
wrapper::assigned_numbers::COMPANY_IDS
.iter()
.map(|(id, name)| (*id, name.to_string()))
.collect::<collections::HashMap<_, _>>(),
ids_from_python,
"Company ids do not match -- re-run gen_assigned_numbers?"
);
Ok(())
}

21
rust/pytests/pytests.rs Normal file
View File

@@ -0,0 +1,21 @@
// 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
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
#[pyo3_asyncio::tokio::main]
async fn main() -> pyo3::PyResult<()> {
pyo3_asyncio::testing::main().await
}
mod assigned_numbers;
mod wrapper;

37
rust/pytests/wrapper.rs Normal file
View File

@@ -0,0 +1,37 @@
// 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
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
use bumble::wrapper::{drivers::rtk::DriverInfo, transport::Transport};
use nix::sys::stat::Mode;
use pyo3::PyResult;
#[pyo3_asyncio::tokio::test]
async fn fifo_transport_can_open() -> PyResult<()> {
let dir = tempfile::tempdir().unwrap();
let mut fifo = dir.path().to_path_buf();
fifo.push("bumble-transport-fifo");
nix::unistd::mkfifo(&fifo, Mode::S_IRWXU).unwrap();
let mut t = Transport::open(format!("file:{}", fifo.to_str().unwrap())).await?;
t.close().await?;
Ok(())
}
#[pyo3_asyncio::tokio::test]
async fn realtek_driver_info_all_drivers() -> PyResult<()> {
assert_eq!(12, DriverInfo::all_drivers()?.len());
Ok(())
}

View File

@@ -0,0 +1,4 @@
This dir contains samples firmware images in the format used for Realtek chips,
but with repetitions of the length of the section as a little-endian 32-bit int
for the patch data instead of actual firmware, since we only need the structure
to test parsing.

460
rust/src/adv.rs Normal file
View File

@@ -0,0 +1,460 @@
// 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
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//! BLE advertisements.
use crate::wrapper::assigned_numbers::{COMPANY_IDS, SERVICE_IDS};
use crate::wrapper::core::{Uuid128, Uuid16, Uuid32};
use itertools::Itertools;
use nom::{combinator, multi, number};
use std::fmt;
use strum::IntoEnumIterator;
/// The numeric code for a common data type.
///
/// For known types, see [CommonDataType], or use this type directly for non-assigned codes.
#[derive(PartialEq, Eq, Debug, Clone, Copy, Hash)]
pub struct CommonDataTypeCode(u8);
impl From<CommonDataType> for CommonDataTypeCode {
fn from(value: CommonDataType) -> Self {
let byte = match value {
CommonDataType::Flags => 0x01,
CommonDataType::IncompleteListOf16BitServiceClassUuids => 0x02,
CommonDataType::CompleteListOf16BitServiceClassUuids => 0x03,
CommonDataType::IncompleteListOf32BitServiceClassUuids => 0x04,
CommonDataType::CompleteListOf32BitServiceClassUuids => 0x05,
CommonDataType::IncompleteListOf128BitServiceClassUuids => 0x06,
CommonDataType::CompleteListOf128BitServiceClassUuids => 0x07,
CommonDataType::ShortenedLocalName => 0x08,
CommonDataType::CompleteLocalName => 0x09,
CommonDataType::TxPowerLevel => 0x0A,
CommonDataType::ClassOfDevice => 0x0D,
CommonDataType::SimplePairingHashC192 => 0x0E,
CommonDataType::SimplePairingRandomizerR192 => 0x0F,
// These two both really have type code 0x10! D:
CommonDataType::DeviceId => 0x10,
CommonDataType::SecurityManagerTkValue => 0x10,
CommonDataType::SecurityManagerOutOfBandFlags => 0x11,
CommonDataType::PeripheralConnectionIntervalRange => 0x12,
CommonDataType::ListOf16BitServiceSolicitationUuids => 0x14,
CommonDataType::ListOf128BitServiceSolicitationUuids => 0x15,
CommonDataType::ServiceData16BitUuid => 0x16,
CommonDataType::PublicTargetAddress => 0x17,
CommonDataType::RandomTargetAddress => 0x18,
CommonDataType::Appearance => 0x19,
CommonDataType::AdvertisingInterval => 0x1A,
CommonDataType::LeBluetoothDeviceAddress => 0x1B,
CommonDataType::LeRole => 0x1C,
CommonDataType::SimplePairingHashC256 => 0x1D,
CommonDataType::SimplePairingRandomizerR256 => 0x1E,
CommonDataType::ListOf32BitServiceSolicitationUuids => 0x1F,
CommonDataType::ServiceData32BitUuid => 0x20,
CommonDataType::ServiceData128BitUuid => 0x21,
CommonDataType::LeSecureConnectionsConfirmationValue => 0x22,
CommonDataType::LeSecureConnectionsRandomValue => 0x23,
CommonDataType::Uri => 0x24,
CommonDataType::IndoorPositioning => 0x25,
CommonDataType::TransportDiscoveryData => 0x26,
CommonDataType::LeSupportedFeatures => 0x27,
CommonDataType::ChannelMapUpdateIndication => 0x28,
CommonDataType::PbAdv => 0x29,
CommonDataType::MeshMessage => 0x2A,
CommonDataType::MeshBeacon => 0x2B,
CommonDataType::BigInfo => 0x2C,
CommonDataType::BroadcastCode => 0x2D,
CommonDataType::ResolvableSetIdentifier => 0x2E,
CommonDataType::AdvertisingIntervalLong => 0x2F,
CommonDataType::ThreeDInformationData => 0x3D,
CommonDataType::ManufacturerSpecificData => 0xFF,
};
Self(byte)
}
}
impl From<u8> for CommonDataTypeCode {
fn from(value: u8) -> Self {
Self(value)
}
}
impl From<CommonDataTypeCode> for u8 {
fn from(value: CommonDataTypeCode) -> Self {
value.0
}
}
/// Data types for assigned type codes.
///
/// See Bluetooth Assigned Numbers § 2.3
#[derive(Debug, Clone, Copy, PartialEq, Eq, strum_macros::EnumIter)]
#[allow(missing_docs)]
pub enum CommonDataType {
Flags,
IncompleteListOf16BitServiceClassUuids,
CompleteListOf16BitServiceClassUuids,
IncompleteListOf32BitServiceClassUuids,
CompleteListOf32BitServiceClassUuids,
IncompleteListOf128BitServiceClassUuids,
CompleteListOf128BitServiceClassUuids,
ShortenedLocalName,
CompleteLocalName,
TxPowerLevel,
ClassOfDevice,
SimplePairingHashC192,
SimplePairingRandomizerR192,
DeviceId,
SecurityManagerTkValue,
SecurityManagerOutOfBandFlags,
PeripheralConnectionIntervalRange,
ListOf16BitServiceSolicitationUuids,
ListOf128BitServiceSolicitationUuids,
ServiceData16BitUuid,
PublicTargetAddress,
RandomTargetAddress,
Appearance,
AdvertisingInterval,
LeBluetoothDeviceAddress,
LeRole,
SimplePairingHashC256,
SimplePairingRandomizerR256,
ListOf32BitServiceSolicitationUuids,
ServiceData32BitUuid,
ServiceData128BitUuid,
LeSecureConnectionsConfirmationValue,
LeSecureConnectionsRandomValue,
Uri,
IndoorPositioning,
TransportDiscoveryData,
LeSupportedFeatures,
ChannelMapUpdateIndication,
PbAdv,
MeshMessage,
MeshBeacon,
BigInfo,
BroadcastCode,
ResolvableSetIdentifier,
AdvertisingIntervalLong,
ThreeDInformationData,
ManufacturerSpecificData,
}
impl CommonDataType {
/// Iterate over the zero, one, or more matching types for the provided code.
///
/// `0x10` maps to both Device Id and Security Manager TK Value, so multiple matching types
/// may exist for a single code.
pub fn for_type_code(code: CommonDataTypeCode) -> impl Iterator<Item = CommonDataType> {
Self::iter().filter(move |t| CommonDataTypeCode::from(*t) == code)
}
/// Apply type-specific human-oriented formatting to data, if any is applicable
pub fn format_data(&self, data: &[u8]) -> Option<String> {
match self {
Self::Flags => Some(Flags::matching(data).map(|f| format!("{:?}", f)).join(",")),
Self::CompleteListOf16BitServiceClassUuids
| Self::IncompleteListOf16BitServiceClassUuids
| Self::ListOf16BitServiceSolicitationUuids => {
combinator::complete(multi::many0(Uuid16::parse_le))(data)
.map(|(_res, uuids)| {
uuids
.into_iter()
.map(|uuid| {
SERVICE_IDS
.get(&uuid)
.map(|name| format!("{:?} ({name})", uuid))
.unwrap_or_else(|| format!("{:?}", uuid))
})
.join(", ")
})
.ok()
}
Self::CompleteListOf32BitServiceClassUuids
| Self::IncompleteListOf32BitServiceClassUuids
| Self::ListOf32BitServiceSolicitationUuids => {
combinator::complete(multi::many0(Uuid32::parse))(data)
.map(|(_res, uuids)| uuids.into_iter().map(|u| format!("{:?}", u)).join(", "))
.ok()
}
Self::CompleteListOf128BitServiceClassUuids
| Self::IncompleteListOf128BitServiceClassUuids
| Self::ListOf128BitServiceSolicitationUuids => {
combinator::complete(multi::many0(Uuid128::parse_le))(data)
.map(|(_res, uuids)| uuids.into_iter().map(|u| format!("{:?}", u)).join(", "))
.ok()
}
Self::ServiceData16BitUuid => Uuid16::parse_le(data)
.map(|(rem, uuid)| {
format!(
"service={:?}, data={}",
SERVICE_IDS
.get(&uuid)
.map(|name| format!("{:?} ({name})", uuid))
.unwrap_or_else(|| format!("{:?}", uuid)),
hex::encode_upper(rem)
)
})
.ok(),
Self::ServiceData32BitUuid => Uuid32::parse(data)
.map(|(rem, uuid)| format!("service={:?}, data={}", uuid, hex::encode_upper(rem)))
.ok(),
Self::ServiceData128BitUuid => Uuid128::parse_le(data)
.map(|(rem, uuid)| format!("service={:?}, data={}", uuid, hex::encode_upper(rem)))
.ok(),
Self::ShortenedLocalName | Self::CompleteLocalName => {
std::str::from_utf8(data).ok().map(|s| format!("\"{}\"", s))
}
Self::TxPowerLevel => {
let (_, tx) =
combinator::complete(number::complete::i8::<_, nom::error::Error<_>>)(data)
.ok()?;
Some(tx.to_string())
}
Self::ManufacturerSpecificData => {
let (rem, id) = Uuid16::parse_le(data).ok()?;
Some(format!(
"company={}, data=0x{}",
COMPANY_IDS
.get(&id)
.map(|s| s.to_string())
.unwrap_or_else(|| format!("{:?}", id)),
hex::encode_upper(rem)
))
}
_ => None,
}
}
}
impl fmt::Display for CommonDataType {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
CommonDataType::Flags => write!(f, "Flags"),
CommonDataType::IncompleteListOf16BitServiceClassUuids => {
write!(f, "Incomplete List of 16-bit Service Class UUIDs")
}
CommonDataType::CompleteListOf16BitServiceClassUuids => {
write!(f, "Complete List of 16-bit Service Class UUIDs")
}
CommonDataType::IncompleteListOf32BitServiceClassUuids => {
write!(f, "Incomplete List of 32-bit Service Class UUIDs")
}
CommonDataType::CompleteListOf32BitServiceClassUuids => {
write!(f, "Complete List of 32-bit Service Class UUIDs")
}
CommonDataType::ListOf16BitServiceSolicitationUuids => {
write!(f, "List of 16-bit Service Solicitation UUIDs")
}
CommonDataType::ListOf32BitServiceSolicitationUuids => {
write!(f, "List of 32-bit Service Solicitation UUIDs")
}
CommonDataType::ListOf128BitServiceSolicitationUuids => {
write!(f, "List of 128-bit Service Solicitation UUIDs")
}
CommonDataType::IncompleteListOf128BitServiceClassUuids => {
write!(f, "Incomplete List of 128-bit Service Class UUIDs")
}
CommonDataType::CompleteListOf128BitServiceClassUuids => {
write!(f, "Complete List of 128-bit Service Class UUIDs")
}
CommonDataType::ShortenedLocalName => write!(f, "Shortened Local Name"),
CommonDataType::CompleteLocalName => write!(f, "Complete Local Name"),
CommonDataType::TxPowerLevel => write!(f, "TX Power Level"),
CommonDataType::ClassOfDevice => write!(f, "Class of Device"),
CommonDataType::SimplePairingHashC192 => {
write!(f, "Simple Pairing Hash C-192")
}
CommonDataType::SimplePairingHashC256 => {
write!(f, "Simple Pairing Hash C 256")
}
CommonDataType::SimplePairingRandomizerR192 => {
write!(f, "Simple Pairing Randomizer R-192")
}
CommonDataType::SimplePairingRandomizerR256 => {
write!(f, "Simple Pairing Randomizer R 256")
}
CommonDataType::DeviceId => write!(f, "Device Id"),
CommonDataType::SecurityManagerTkValue => {
write!(f, "Security Manager TK Value")
}
CommonDataType::SecurityManagerOutOfBandFlags => {
write!(f, "Security Manager Out of Band Flags")
}
CommonDataType::PeripheralConnectionIntervalRange => {
write!(f, "Peripheral Connection Interval Range")
}
CommonDataType::ServiceData16BitUuid => {
write!(f, "Service Data 16-bit UUID")
}
CommonDataType::ServiceData32BitUuid => {
write!(f, "Service Data 32-bit UUID")
}
CommonDataType::ServiceData128BitUuid => {
write!(f, "Service Data 128-bit UUID")
}
CommonDataType::PublicTargetAddress => write!(f, "Public Target Address"),
CommonDataType::RandomTargetAddress => write!(f, "Random Target Address"),
CommonDataType::Appearance => write!(f, "Appearance"),
CommonDataType::AdvertisingInterval => write!(f, "Advertising Interval"),
CommonDataType::LeBluetoothDeviceAddress => {
write!(f, "LE Bluetooth Device Address")
}
CommonDataType::LeRole => write!(f, "LE Role"),
CommonDataType::LeSecureConnectionsConfirmationValue => {
write!(f, "LE Secure Connections Confirmation Value")
}
CommonDataType::LeSecureConnectionsRandomValue => {
write!(f, "LE Secure Connections Random Value")
}
CommonDataType::LeSupportedFeatures => write!(f, "LE Supported Features"),
CommonDataType::Uri => write!(f, "URI"),
CommonDataType::IndoorPositioning => write!(f, "Indoor Positioning"),
CommonDataType::TransportDiscoveryData => {
write!(f, "Transport Discovery Data")
}
CommonDataType::ChannelMapUpdateIndication => {
write!(f, "Channel Map Update Indication")
}
CommonDataType::PbAdv => write!(f, "PB-ADV"),
CommonDataType::MeshMessage => write!(f, "Mesh Message"),
CommonDataType::MeshBeacon => write!(f, "Mesh Beacon"),
CommonDataType::BigInfo => write!(f, "BIGIInfo"),
CommonDataType::BroadcastCode => write!(f, "Broadcast Code"),
CommonDataType::ResolvableSetIdentifier => {
write!(f, "Resolvable Set Identifier")
}
CommonDataType::AdvertisingIntervalLong => {
write!(f, "Advertising Interval Long")
}
CommonDataType::ThreeDInformationData => write!(f, "3D Information Data"),
CommonDataType::ManufacturerSpecificData => {
write!(f, "Manufacturer Specific Data")
}
}
}
}
/// Accumulates advertisement data to broadcast on a [crate::wrapper::device::Device].
#[derive(Debug, Clone, Default)]
pub struct AdvertisementDataBuilder {
encoded_data: Vec<u8>,
}
impl AdvertisementDataBuilder {
/// Returns a new, empty instance.
pub fn new() -> Self {
Self {
encoded_data: Vec::new(),
}
}
/// Append advertising data to the builder.
///
/// Returns an error if the data cannot be appended.
pub fn append(
&mut self,
type_code: impl Into<CommonDataTypeCode>,
data: &[u8],
) -> Result<(), AdvertisementDataBuilderError> {
self.encoded_data.push(
data.len()
.try_into()
.ok()
.and_then(|len: u8| len.checked_add(1))
.ok_or(AdvertisementDataBuilderError::DataTooLong)?,
);
self.encoded_data.push(type_code.into().0);
self.encoded_data.extend_from_slice(data);
Ok(())
}
pub(crate) fn into_bytes(self) -> Vec<u8> {
self.encoded_data
}
}
/// Errors that can occur when building advertisement data with [AdvertisementDataBuilder].
#[derive(Debug, PartialEq, Eq, thiserror::Error)]
pub enum AdvertisementDataBuilderError {
/// The provided adv data is too long to be encoded
#[error("Data too long")]
DataTooLong,
}
#[derive(PartialEq, Eq, strum_macros::EnumIter)]
#[allow(missing_docs)]
/// Features in the Flags AD
pub enum Flags {
LeLimited,
LeDiscoverable,
NoBrEdr,
BrEdrController,
BrEdrHost,
}
impl fmt::Debug for Flags {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{}", self.short_name())
}
}
impl Flags {
/// Iterates over the flags that are present in the provided `flags` bytes.
pub fn matching(flags: &[u8]) -> impl Iterator<Item = Self> + '_ {
// The encoding is not clear from the spec: do we look at the first byte? or the last?
// In practice it's only one byte.
let first_byte = flags.first().unwrap_or(&0_u8);
Self::iter().filter(move |f| {
let mask = match f {
Flags::LeLimited => 0x01_u8,
Flags::LeDiscoverable => 0x02,
Flags::NoBrEdr => 0x04,
Flags::BrEdrController => 0x08,
Flags::BrEdrHost => 0x10,
};
mask & first_byte > 0
})
}
/// An abbreviated form of the flag name.
///
/// See [Flags::name] for the full name.
pub fn short_name(&self) -> &'static str {
match self {
Flags::LeLimited => "LE Limited",
Flags::LeDiscoverable => "LE General",
Flags::NoBrEdr => "No BR/EDR",
Flags::BrEdrController => "BR/EDR C",
Flags::BrEdrHost => "BR/EDR H",
}
}
/// The human-readable name of the flag.
///
/// See [Flags::short_name] for a shorter string for use if compactness is important.
pub fn name(&self) -> &'static str {
match self {
Flags::LeLimited => "LE Limited Discoverable Mode",
Flags::LeDiscoverable => "LE General Discoverable Mode",
Flags::NoBrEdr => "BR/EDR Not Supported",
Flags::BrEdrController => "Simultaneous LE and BR/EDR (Controller)",
Flags::BrEdrHost => "Simultaneous LE and BR/EDR (Host)",
}
}
}

View File

@@ -0,0 +1,15 @@
// 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
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
pub(crate) mod rtk;

View File

@@ -0,0 +1,265 @@
// 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
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//! Realtek firmware tools
use crate::{Download, Source};
use anyhow::anyhow;
use bumble::wrapper::{
drivers::rtk::{Driver, DriverInfo, Firmware},
host::{DriverFactory, Host},
transport::Transport,
};
use owo_colors::{colors::css, OwoColorize};
use pyo3::PyResult;
use std::{fs, path};
pub(crate) async fn download(dl: Download) -> PyResult<()> {
let data_dir = dl
.output_dir
.or_else(|| {
directories::ProjectDirs::from("com", "google", "bumble")
.map(|pd| pd.data_local_dir().join("firmware").join("realtek"))
})
.unwrap_or_else(|| {
eprintln!("Could not determine standard data directory");
path::PathBuf::from(".")
});
fs::create_dir_all(&data_dir)?;
let (base_url, uses_bin_suffix) = match dl.source {
Source::LinuxKernel => ("https://git.kernel.org/pub/scm/linux/kernel/git/firmware/linux-firmware.git/plain/rtl_bt", true),
Source::RealtekOpensource => ("https://github.com/Realtek-OpenSource/android_hardware_realtek/raw/rtk1395/bt/rtkbt/Firmware/BT", false),
Source::LinuxFromScratch => ("https://anduin.linuxfromscratch.org/sources/linux-firmware/rtl_bt", true),
};
println!("Downloading");
println!("{} {}", "FROM:".green(), base_url);
println!("{} {}", "TO:".green(), data_dir.to_string_lossy());
let url_for_file = |file_name: &str| {
let url_suffix = if uses_bin_suffix {
file_name
} else {
file_name.trim_end_matches(".bin")
};
let mut url = base_url.to_string();
url.push('/');
url.push_str(url_suffix);
url
};
let to_download = if let Some(single) = dl.single {
vec![(
format!("{single}_fw.bin"),
Some(format!("{single}_config.bin")),
false,
)]
} else {
DriverInfo::all_drivers()?
.iter()
.map(|di| Ok((di.firmware_name()?, di.config_name()?, di.config_needed()?)))
.collect::<PyResult<Vec<_>>>()?
};
let client = SimpleClient::new();
for (fw_filename, config_filename, config_needed) in to_download {
println!("{}", "---".yellow());
let fw_path = data_dir.join(&fw_filename);
let config_path = config_filename.as_ref().map(|f| data_dir.join(f));
if fw_path.exists() && !dl.overwrite {
println!(
"{}",
format!("{} already exists, skipping", fw_path.to_string_lossy())
.fg::<css::Orange>()
);
continue;
}
if let Some(cp) = config_path.as_ref() {
if cp.exists() && !dl.overwrite {
println!(
"{}",
format!("{} already exists, skipping", cp.to_string_lossy())
.fg::<css::Orange>()
);
continue;
}
}
let fw_contents = match client.get(&url_for_file(&fw_filename)).await {
Ok(data) => {
println!("Downloaded {}: {} bytes", fw_filename, data.len());
data
}
Err(e) => {
eprintln!(
"{} {} {:?}",
"Failed to download".red(),
fw_filename.red(),
e
);
continue;
}
};
let config_contents = if let Some(cn) = &config_filename {
match client.get(&url_for_file(cn)).await {
Ok(data) => {
println!("Downloaded {}: {} bytes", cn, data.len());
Some(data)
}
Err(e) => {
if config_needed {
eprintln!("{} {} {:?}", "Failed to download".red(), cn.red(), e);
continue;
} else {
eprintln!(
"{}",
format!("No config available as {cn}").fg::<css::Orange>()
);
None
}
}
}
} else {
None
};
fs::write(&fw_path, &fw_contents)?;
if !dl.no_parse && config_filename.is_some() {
println!("{} {}", "Parsing:".cyan(), &fw_filename);
match Firmware::parse(&fw_contents).map_err(|e| anyhow!("Parse error: {:?}", e)) {
Ok(fw) => dump_firmware_desc(&fw),
Err(e) => {
eprintln!(
"{} {:?}",
"Could not parse firmware:".fg::<css::Orange>(),
e
);
}
}
}
if let Some((cp, cd)) = config_path
.as_ref()
.and_then(|p| config_contents.map(|c| (p, c)))
{
fs::write(cp, &cd)?;
}
}
Ok(())
}
pub(crate) fn parse(firmware_path: &path::Path) -> PyResult<()> {
let contents = fs::read(firmware_path)?;
let fw = Firmware::parse(&contents)
// squish the error into a string to avoid the error type requiring that the input be
// 'static
.map_err(|e| anyhow!("Parse error: {:?}", e))?;
dump_firmware_desc(&fw);
Ok(())
}
pub(crate) async fn info(transport: &str, force: bool) -> PyResult<()> {
let transport = Transport::open(transport).await?;
let mut host = Host::new(transport.source()?, transport.sink()?)?;
host.reset(DriverFactory::None).await?;
if !force && !Driver::check(&host).await? {
println!("USB device not supported by this RTK driver");
} else if let Some(driver_info) = Driver::driver_info_for_host(&host).await? {
println!("Driver:");
println!(" {:10} {:04X}", "ROM:", driver_info.rom()?);
println!(" {:10} {}", "Firmware:", driver_info.firmware_name()?);
println!(
" {:10} {}",
"Config:",
driver_info.config_name()?.unwrap_or_default()
);
} else {
println!("Firmware already loaded or no supported driver for this device.")
}
Ok(())
}
pub(crate) async fn load(transport: &str, force: bool) -> PyResult<()> {
let transport = Transport::open(transport).await?;
let mut host = Host::new(transport.source()?, transport.sink()?)?;
host.reset(DriverFactory::None).await?;
match Driver::for_host(&host, force).await? {
None => {
eprintln!("Firmware already loaded or no supported driver for this device.");
}
Some(mut d) => d.download_firmware().await?,
};
Ok(())
}
pub(crate) async fn drop(transport: &str) -> PyResult<()> {
let transport = Transport::open(transport).await?;
let mut host = Host::new(transport.source()?, transport.sink()?)?;
host.reset(DriverFactory::None).await?;
Driver::drop_firmware(&mut host).await?;
Ok(())
}
fn dump_firmware_desc(fw: &Firmware) {
println!(
"Firmware: version=0x{:08X} project_id=0x{:04X}",
fw.version(),
fw.project_id()
);
for p in fw.patches() {
println!(
" Patch: chip_id=0x{:04X}, {} bytes, SVN Version={:08X}",
p.chip_id(),
p.contents().len(),
p.svn_version()
)
}
}
struct SimpleClient {
client: reqwest::Client,
}
impl SimpleClient {
fn new() -> Self {
Self {
client: reqwest::Client::new(),
}
}
async fn get(&self, url: &str) -> anyhow::Result<Vec<u8>> {
let resp = self.client.get(url).send().await?;
if !resp.status().is_success() {
return Err(anyhow!("Bad status: {}", resp.status()));
}
let bytes = resp.bytes().await?;
Ok(bytes.as_ref().to_vec())
}
}

View File

@@ -0,0 +1,191 @@
// 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
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
/// L2CAP CoC client bridge: connects to a BLE device, then waits for an inbound
/// TCP connection on a specified port number. When a TCP client connects, an
/// L2CAP CoC channel connection to the BLE device is established, and the data
/// is bridged in both directions, with flow control.
/// When the TCP connection is closed by the client, the L2CAP CoC channel is
/// disconnected, but the connection to the BLE device remains, ready for a new
/// TCP client to connect.
/// When the L2CAP CoC channel is closed, the TCP connection is closed as well.
use crate::cli::l2cap::{
proxy_l2cap_rx_to_tcp_tx, proxy_tcp_rx_to_l2cap_tx, run_future_with_current_task_locals,
BridgeData,
};
use bumble::wrapper::{
device::{Connection, Device},
hci::HciConstant,
};
use futures::executor::block_on;
use owo_colors::OwoColorize;
use pyo3::{PyResult, Python};
use std::{net::SocketAddr, sync::Arc};
use tokio::{
join,
net::{TcpListener, TcpStream},
sync::{mpsc, Mutex},
};
pub struct Args {
pub psm: u16,
pub max_credits: Option<u16>,
pub mtu: Option<u16>,
pub mps: Option<u16>,
pub bluetooth_address: String,
pub tcp_host: String,
pub tcp_port: u16,
}
pub async fn start(args: &Args, device: &mut Device) -> PyResult<()> {
println!(
"{}",
format!("### Connecting to {}...", args.bluetooth_address).yellow()
);
let mut ble_connection = device.connect(&args.bluetooth_address).await?;
ble_connection.on_disconnection(|_py, reason| {
let disconnection_info = match HciConstant::error_name(reason) {
Ok(info_string) => info_string,
Err(py_err) => format!("failed to get disconnection error name ({})", py_err),
};
println!(
"{} {}",
"@@@ Bluetooth disconnection: ".red(),
disconnection_info,
);
Ok(())
})?;
// Start the TCP server.
let listener = TcpListener::bind(format!("{}:{}", args.tcp_host, args.tcp_port))
.await
.expect("failed to bind tcp to address");
println!(
"{}",
format!(
"### Listening for TCP connections on port {}",
args.tcp_port
)
.magenta()
);
let psm = args.psm;
let max_credits = args.max_credits;
let mtu = args.mtu;
let mps = args.mps;
let ble_connection = Arc::new(Mutex::new(ble_connection));
// Ensure Python event loop is available to l2cap `disconnect`
let _ = run_future_with_current_task_locals(async move {
while let Ok((tcp_stream, addr)) = listener.accept().await {
let ble_connection = ble_connection.clone();
let _ = run_future_with_current_task_locals(proxy_data_between_tcp_and_l2cap(
ble_connection,
tcp_stream,
addr,
psm,
max_credits,
mtu,
mps,
));
}
Ok(())
});
Ok(())
}
async fn proxy_data_between_tcp_and_l2cap(
ble_connection: Arc<Mutex<Connection>>,
tcp_stream: TcpStream,
addr: SocketAddr,
psm: u16,
max_credits: Option<u16>,
mtu: Option<u16>,
mps: Option<u16>,
) -> PyResult<()> {
println!("{}", format!("<<< TCP connection from {}", addr).magenta());
println!(
"{}",
format!(">>> Opening L2CAP channel on PSM = {}", psm).yellow()
);
let mut l2cap_channel = match ble_connection
.lock()
.await
.open_l2cap_channel(psm, max_credits, mtu, mps)
.await
{
Ok(channel) => channel,
Err(e) => {
println!("{}", format!("!!! Connection failed: {e}").red());
// TCP stream will get dropped after returning, automatically shutting it down.
return Err(e);
}
};
let channel_info = l2cap_channel
.debug_string()
.unwrap_or_else(|e| format!("failed to get l2cap channel info ({e})"));
println!("{}{}", "*** L2CAP channel: ".cyan(), channel_info);
let (l2cap_to_tcp_tx, l2cap_to_tcp_rx) = mpsc::channel::<BridgeData>(10);
// Set l2cap callback (`set_sink`) for when data is received.
let l2cap_to_tcp_tx_clone = l2cap_to_tcp_tx.clone();
l2cap_channel
.set_sink(move |_py, sdu| {
block_on(l2cap_to_tcp_tx_clone.send(BridgeData::Data(sdu.into())))
.expect("failed to channel data to tcp");
Ok(())
})
.expect("failed to set sink for l2cap connection");
// Set l2cap callback for when the channel is closed.
l2cap_channel
.on_close(move |_py| {
println!("{}", "*** L2CAP channel closed".red());
block_on(l2cap_to_tcp_tx.send(BridgeData::CloseSignal))
.expect("failed to channel close signal to tcp");
Ok(())
})
.expect("failed to set on_close callback for l2cap channel");
let l2cap_channel = Arc::new(Mutex::new(Some(l2cap_channel)));
let (tcp_reader, tcp_writer) = tcp_stream.into_split();
// Do tcp stuff when something happens on the l2cap channel.
let handle_l2cap_data_future =
proxy_l2cap_rx_to_tcp_tx(l2cap_to_tcp_rx, tcp_writer, l2cap_channel.clone());
// Do l2cap stuff when something happens on tcp.
let handle_tcp_data_future = proxy_tcp_rx_to_l2cap_tx(tcp_reader, l2cap_channel.clone(), true);
let (handle_l2cap_result, handle_tcp_result) =
join!(handle_l2cap_data_future, handle_tcp_data_future);
if let Err(e) = handle_l2cap_result {
println!("!!! Error: {e}");
}
if let Err(e) = handle_tcp_result {
println!("!!! Error: {e}");
}
Python::with_gil(|_| {
// Must hold GIL at least once while/after dropping for Python heap object to ensure
// de-allocation.
drop(l2cap_channel);
});
Ok(())
}

190
rust/src/cli/l2cap/mod.rs Normal file
View File

@@ -0,0 +1,190 @@
// 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
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//! Rust version of the Python `l2cap_bridge.py` found under the `apps` folder.
use crate::L2cap;
use anyhow::anyhow;
use bumble::wrapper::{device::Device, l2cap::LeConnectionOrientedChannel, transport::Transport};
use owo_colors::{colors::css::Orange, OwoColorize};
use pyo3::{PyObject, PyResult, Python};
use std::{future::Future, path::PathBuf, sync::Arc};
use tokio::{
io::{AsyncReadExt, AsyncWriteExt},
net::tcp::{OwnedReadHalf, OwnedWriteHalf},
sync::{mpsc::Receiver, Mutex},
};
mod client_bridge;
mod server_bridge;
pub(crate) async fn run(
command: L2cap,
device_config: PathBuf,
transport: String,
psm: u16,
max_credits: Option<u16>,
mtu: Option<u16>,
mps: Option<u16>,
) -> PyResult<()> {
println!("<<< connecting to HCI...");
let transport = Transport::open(transport).await?;
println!("<<< connected");
let mut device =
Device::from_config_file_with_hci(&device_config, transport.source()?, transport.sink()?)?;
device.power_on().await?;
match command {
L2cap::Server { tcp_host, tcp_port } => {
let args = server_bridge::Args {
psm,
max_credits,
mtu,
mps,
tcp_host,
tcp_port,
};
server_bridge::start(&args, &mut device).await?
}
L2cap::Client {
bluetooth_address,
tcp_host,
tcp_port,
} => {
let args = client_bridge::Args {
psm,
max_credits,
mtu,
mps,
bluetooth_address,
tcp_host,
tcp_port,
};
client_bridge::start(&args, &mut device).await?
}
};
// wait until user kills the process
tokio::signal::ctrl_c().await?;
Ok(())
}
/// Used for channeling data from Python callbacks to a Rust consumer.
enum BridgeData {
Data(Vec<u8>),
CloseSignal,
}
async fn proxy_l2cap_rx_to_tcp_tx(
mut l2cap_data_receiver: Receiver<BridgeData>,
mut tcp_writer: OwnedWriteHalf,
l2cap_channel: Arc<Mutex<Option<LeConnectionOrientedChannel>>>,
) -> anyhow::Result<()> {
while let Some(bridge_data) = l2cap_data_receiver.recv().await {
match bridge_data {
BridgeData::Data(sdu) => {
println!("{}", format!("<<< [L2CAP SDU]: {} bytes", sdu.len()).cyan());
tcp_writer
.write_all(sdu.as_ref())
.await
.map_err(|_| anyhow!("Failed to write to tcp stream"))?;
tcp_writer
.flush()
.await
.map_err(|_| anyhow!("Failed to flush tcp stream"))?;
}
BridgeData::CloseSignal => {
l2cap_channel.lock().await.take();
tcp_writer
.shutdown()
.await
.map_err(|_| anyhow!("Failed to shut down write half of tcp stream"))?;
return Ok(());
}
}
}
Ok(())
}
async fn proxy_tcp_rx_to_l2cap_tx(
mut tcp_reader: OwnedReadHalf,
l2cap_channel: Arc<Mutex<Option<LeConnectionOrientedChannel>>>,
drain_l2cap_after_write: bool,
) -> PyResult<()> {
let mut buf = [0; 4096];
loop {
match tcp_reader.read(&mut buf).await {
Ok(len) => {
if len == 0 {
println!("{}", "!!! End of stream".fg::<Orange>());
if let Some(mut channel) = l2cap_channel.lock().await.take() {
channel.disconnect().await.map_err(|e| {
eprintln!("Failed to call disconnect on l2cap channel: {e}");
e
})?;
}
return Ok(());
}
println!("{}", format!("<<< [TCP DATA]: {len} bytes").blue());
match l2cap_channel.lock().await.as_mut() {
None => {
println!("{}", "!!! L2CAP channel not connected, dropping".red());
return Ok(());
}
Some(channel) => {
channel.write(&buf[..len])?;
if drain_l2cap_after_write {
channel.drain().await?;
}
}
}
}
Err(e) => {
println!("{}", format!("!!! TCP connection lost: {}", e).red());
if let Some(mut channel) = l2cap_channel.lock().await.take() {
let _ = channel.disconnect().await.map_err(|e| {
eprintln!("Failed to call disconnect on l2cap channel: {e}");
});
}
return Err(e.into());
}
}
}
}
/// Copies the current thread's TaskLocals into a Python "awaitable" and encapsulates it in a Rust
/// future, running it as a Python Task.
/// `TaskLocals` stores the current event loop, and allows the user to copy the current Python
/// context if necessary. In this case, the python event loop is used when calling `disconnect` on
/// an l2cap connection, or else the call will fail.
pub fn run_future_with_current_task_locals<F>(
fut: F,
) -> PyResult<impl Future<Output = PyResult<PyObject>> + Send>
where
F: Future<Output = PyResult<()>> + Send + 'static,
{
Python::with_gil(|py| {
let locals = pyo3_asyncio::tokio::get_current_locals(py)?;
let future = pyo3_asyncio::tokio::scope(locals.clone(), fut);
pyo3_asyncio::tokio::future_into_py_with_locals(py, locals, future)
.and_then(pyo3_asyncio::tokio::into_future)
})
}

View File

@@ -0,0 +1,205 @@
// 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
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
/// L2CAP CoC server bridge: waits for a peer to connect an L2CAP CoC channel
/// on a specified PSM. When the connection is made, the bridge connects a TCP
/// socket to a remote host and bridges the data in both directions, with flow
/// control.
/// When the L2CAP CoC channel is closed, the bridge disconnects the TCP socket
/// and waits for a new L2CAP CoC channel to be connected.
/// When the TCP connection is closed by the TCP server, the L2CAP connection is closed as well.
use crate::cli::l2cap::{
proxy_l2cap_rx_to_tcp_tx, proxy_tcp_rx_to_l2cap_tx, run_future_with_current_task_locals,
BridgeData,
};
use bumble::wrapper::{device::Device, hci::HciConstant, l2cap::LeConnectionOrientedChannel};
use futures::executor::block_on;
use owo_colors::OwoColorize;
use pyo3::{PyResult, Python};
use std::{sync::Arc, time::Duration};
use tokio::{
join,
net::TcpStream,
select,
sync::{mpsc, Mutex},
};
pub struct Args {
pub psm: u16,
pub max_credits: Option<u16>,
pub mtu: Option<u16>,
pub mps: Option<u16>,
pub tcp_host: String,
pub tcp_port: u16,
}
pub async fn start(args: &Args, device: &mut Device) -> PyResult<()> {
let host = args.tcp_host.clone();
let port = args.tcp_port;
device.register_l2cap_channel_server(
args.psm,
move |_py, l2cap_channel| {
let channel_info = l2cap_channel
.debug_string()
.unwrap_or_else(|e| format!("failed to get l2cap channel info ({e})"));
println!("{} {channel_info}", "*** L2CAP channel:".cyan());
let host = host.clone();
// Ensure Python event loop is available to l2cap `disconnect`
let _ = run_future_with_current_task_locals(proxy_data_between_l2cap_and_tcp(
l2cap_channel,
host,
port,
));
Ok(())
},
args.max_credits,
args.mtu,
args.mps,
)?;
println!(
"{}",
format!("### Listening for CoC connection on PSM {}", args.psm).yellow()
);
device.on_connection(|_py, mut connection| {
let connection_info = connection
.debug_string()
.unwrap_or_else(|e| format!("failed to get connection info ({e})"));
println!(
"{} {}",
"@@@ Bluetooth connection: ".green(),
connection_info,
);
connection.on_disconnection(|_py, reason| {
let disconnection_info = match HciConstant::error_name(reason) {
Ok(info_string) => info_string,
Err(py_err) => format!("failed to get disconnection error name ({})", py_err),
};
println!(
"{} {}",
"@@@ Bluetooth disconnection: ".red(),
disconnection_info,
);
Ok(())
})?;
Ok(())
})?;
device.start_advertising(false).await?;
Ok(())
}
async fn proxy_data_between_l2cap_and_tcp(
mut l2cap_channel: LeConnectionOrientedChannel,
tcp_host: String,
tcp_port: u16,
) -> PyResult<()> {
let (l2cap_to_tcp_tx, mut l2cap_to_tcp_rx) = mpsc::channel::<BridgeData>(10);
// Set callback (`set_sink`) for when l2cap data is received.
let l2cap_to_tcp_tx_clone = l2cap_to_tcp_tx.clone();
l2cap_channel
.set_sink(move |_py, sdu| {
block_on(l2cap_to_tcp_tx_clone.send(BridgeData::Data(sdu.into())))
.expect("failed to channel data to tcp");
Ok(())
})
.expect("failed to set sink for l2cap connection");
// Set l2cap callback for when the channel is closed.
l2cap_channel
.on_close(move |_py| {
println!("{}", "*** L2CAP channel closed".red());
block_on(l2cap_to_tcp_tx.send(BridgeData::CloseSignal))
.expect("failed to channel close signal to tcp");
Ok(())
})
.expect("failed to set on_close callback for l2cap channel");
println!(
"{}",
format!("### Connecting to TCP {tcp_host}:{tcp_port}...").yellow()
);
let l2cap_channel = Arc::new(Mutex::new(Some(l2cap_channel)));
let tcp_stream = match TcpStream::connect(format!("{tcp_host}:{tcp_port}")).await {
Ok(stream) => {
println!("{}", "### Connected".green());
Some(stream)
}
Err(err) => {
println!("{}", format!("!!! Connection failed: {err}").red());
if let Some(mut channel) = l2cap_channel.lock().await.take() {
// Bumble might enter an invalid state if disconnection request is received from
// l2cap client before receiving a disconnection response from the same client,
// blocking this async call from returning.
// See: https://github.com/google/bumble/issues/257
select! {
res = channel.disconnect() => {
let _ = res.map_err(|e| eprintln!("Failed to call disconnect on l2cap channel: {e}"));
},
_ = tokio::time::sleep(Duration::from_secs(1)) => eprintln!("Timed out while calling disconnect on l2cap channel."),
}
}
None
}
};
match tcp_stream {
None => {
while let Some(bridge_data) = l2cap_to_tcp_rx.recv().await {
match bridge_data {
BridgeData::Data(sdu) => {
println!("{}", format!("<<< [L2CAP SDU]: {} bytes", sdu.len()).cyan());
println!("{}", "!!! TCP socket not open, dropping".red())
}
BridgeData::CloseSignal => break,
}
}
}
Some(tcp_stream) => {
let (tcp_reader, tcp_writer) = tcp_stream.into_split();
// Do tcp stuff when something happens on the l2cap channel.
let handle_l2cap_data_future =
proxy_l2cap_rx_to_tcp_tx(l2cap_to_tcp_rx, tcp_writer, l2cap_channel.clone());
// Do l2cap stuff when something happens on tcp.
let handle_tcp_data_future =
proxy_tcp_rx_to_l2cap_tx(tcp_reader, l2cap_channel.clone(), false);
let (handle_l2cap_result, handle_tcp_result) =
join!(handle_l2cap_data_future, handle_tcp_data_future);
if let Err(e) = handle_l2cap_result {
println!("!!! Error: {e}");
}
if let Err(e) = handle_tcp_result {
println!("!!! Error: {e}");
}
}
};
Python::with_gil(|_| {
// Must hold GIL at least once while/after dropping for Python heap object to ensure
// de-allocation.
drop(l2cap_channel);
});
Ok(())
}

19
rust/src/cli/mod.rs Normal file
View File

@@ -0,0 +1,19 @@
// 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
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
pub(crate) mod firmware;
pub(crate) mod usb;
pub(crate) mod l2cap;

330
rust/src/cli/usb/mod.rs Normal file
View File

@@ -0,0 +1,330 @@
// 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
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//! Rust version of the Python `usb_probe.py`.
//!
//! This tool lists all the USB devices, with details about each device.
//! For each device, the different possible Bumble transport strings that can
//! refer to it are listed. If the device is known to be a Bluetooth HCI device,
//! its identifier is printed in reverse colors, and the transport names in cyan color.
//! For other devices, regardless of their type, the transport names are printed
//! in red. Whether that device is actually a Bluetooth device or not depends on
//! whether it is a Bluetooth device that uses a non-standard Class, or some other
//! type of device (there's no way to tell).
use itertools::Itertools as _;
use owo_colors::{OwoColorize, Style};
use rusb::{Device, DeviceDescriptor, Direction, TransferType, UsbContext};
use std::{
collections::{HashMap, HashSet},
time::Duration,
};
const USB_DEVICE_CLASS_DEVICE: u8 = 0x00;
const USB_DEVICE_CLASS_WIRELESS_CONTROLLER: u8 = 0xE0;
const USB_DEVICE_SUBCLASS_RF_CONTROLLER: u8 = 0x01;
const USB_DEVICE_PROTOCOL_BLUETOOTH_PRIMARY_CONTROLLER: u8 = 0x01;
pub(crate) fn probe(verbose: bool) -> anyhow::Result<()> {
let mut bt_dev_count = 0;
let mut device_serials_by_id: HashMap<(u16, u16), HashSet<String>> = HashMap::new();
for device in rusb::devices()?.iter() {
let device_desc = device.device_descriptor().unwrap();
let class_info = ClassInfo::from(&device_desc);
let handle = device.open()?;
let timeout = Duration::from_secs(1);
// some devices don't have languages
let lang = handle
.read_languages(timeout)
.ok()
.and_then(|langs| langs.into_iter().next());
let serial = lang.and_then(|l| {
handle
.read_serial_number_string(l, &device_desc, timeout)
.ok()
});
let mfg = lang.and_then(|l| {
handle
.read_manufacturer_string(l, &device_desc, timeout)
.ok()
});
let product = lang.and_then(|l| handle.read_product_string(l, &device_desc, timeout).ok());
let is_hci = is_bluetooth_hci(&device, &device_desc)?;
let addr_style = if is_hci {
bt_dev_count += 1;
Style::new().black().on_yellow()
} else {
Style::new().yellow().on_black()
};
let mut transport_names = Vec::new();
let basic_transport_name = format!(
"usb:{:04X}:{:04X}",
device_desc.vendor_id(),
device_desc.product_id()
);
if is_hci {
transport_names.push(format!("usb:{}", bt_dev_count - 1));
}
let device_id = (device_desc.vendor_id(), device_desc.product_id());
if !device_serials_by_id.contains_key(&device_id) {
transport_names.push(basic_transport_name.clone());
} else {
transport_names.push(format!(
"{}#{}",
basic_transport_name,
device_serials_by_id
.get(&device_id)
.map(|serials| serials.len())
.unwrap_or(0)
))
}
if let Some(s) = &serial {
if !device_serials_by_id
.get(&device_id)
.map(|serials| serials.contains(s))
.unwrap_or(false)
{
transport_names.push(format!("{}/{}", basic_transport_name, s))
}
}
println!(
"{}",
format!(
"ID {:04X}:{:04X}",
device_desc.vendor_id(),
device_desc.product_id()
)
.style(addr_style)
);
if !transport_names.is_empty() {
let style = if is_hci {
Style::new().cyan()
} else {
Style::new().red()
};
println!(
"{:26}{}",
" Bumble Transport Names:".blue(),
transport_names.iter().map(|n| n.style(style)).join(" or ")
)
}
println!(
"{:26}{:03}/{:03}",
" Bus/Device:".green(),
device.bus_number(),
device.address()
);
println!(
"{:26}{}",
" Class:".green(),
class_info.formatted_class_name()
);
println!(
"{:26}{}",
" Subclass/Protocol:".green(),
class_info.formatted_subclass_protocol()
);
if let Some(s) = serial {
println!("{:26}{}", " Serial:".green(), s);
device_serials_by_id
.entry(device_id)
.or_insert(HashSet::new())
.insert(s);
}
if let Some(m) = mfg {
println!("{:26}{}", " Manufacturer:".green(), m);
}
if let Some(p) = product {
println!("{:26}{}", " Product:".green(), p);
}
if verbose {
print_device_details(&device, &device_desc)?;
}
println!();
}
Ok(())
}
fn is_bluetooth_hci<T: UsbContext>(
device: &Device<T>,
device_desc: &DeviceDescriptor,
) -> rusb::Result<bool> {
if device_desc.class_code() == USB_DEVICE_CLASS_WIRELESS_CONTROLLER
&& device_desc.sub_class_code() == USB_DEVICE_SUBCLASS_RF_CONTROLLER
&& device_desc.protocol_code() == USB_DEVICE_PROTOCOL_BLUETOOTH_PRIMARY_CONTROLLER
{
Ok(true)
} else if device_desc.class_code() == USB_DEVICE_CLASS_DEVICE {
for i in 0..device_desc.num_configurations() {
for interface in device.config_descriptor(i)?.interfaces() {
for d in interface.descriptors() {
if d.class_code() == USB_DEVICE_CLASS_WIRELESS_CONTROLLER
&& d.sub_class_code() == USB_DEVICE_SUBCLASS_RF_CONTROLLER
&& d.protocol_code() == USB_DEVICE_PROTOCOL_BLUETOOTH_PRIMARY_CONTROLLER
{
return Ok(true);
}
}
}
}
Ok(false)
} else {
Ok(false)
}
}
fn print_device_details<T: UsbContext>(
device: &Device<T>,
device_desc: &DeviceDescriptor,
) -> anyhow::Result<()> {
for i in 0..device_desc.num_configurations() {
println!(" Configuration {}", i + 1);
for interface in device.config_descriptor(i)?.interfaces() {
let interface_descriptors: Vec<_> = interface.descriptors().collect();
for d in &interface_descriptors {
let class_info =
ClassInfo::new(d.class_code(), d.sub_class_code(), d.protocol_code());
println!(
" Interface: {}{} ({}, {})",
interface.number(),
if interface_descriptors.len() > 1 {
format!("/{}", d.setting_number())
} else {
String::new()
},
class_info.formatted_class_name(),
class_info.formatted_subclass_protocol()
);
for e in d.endpoint_descriptors() {
println!(
" Endpoint {:#04X}: {} {}",
e.address(),
match e.transfer_type() {
TransferType::Control => "CONTROL",
TransferType::Isochronous => "ISOCHRONOUS",
TransferType::Bulk => "BULK",
TransferType::Interrupt => "INTERRUPT",
},
match e.direction() {
Direction::In => "IN",
Direction::Out => "OUT",
}
)
}
}
}
}
Ok(())
}
struct ClassInfo {
class: u8,
sub_class: u8,
protocol: u8,
}
impl ClassInfo {
fn new(class: u8, sub_class: u8, protocol: u8) -> Self {
Self {
class,
sub_class,
protocol,
}
}
fn class_name(&self) -> Option<&str> {
match self.class {
0x00 => Some("Device"),
0x01 => Some("Audio"),
0x02 => Some("Communications and CDC Control"),
0x03 => Some("Human Interface Device"),
0x05 => Some("Physical"),
0x06 => Some("Still Imaging"),
0x07 => Some("Printer"),
0x08 => Some("Mass Storage"),
0x09 => Some("Hub"),
0x0A => Some("CDC Data"),
0x0B => Some("Smart Card"),
0x0D => Some("Content Security"),
0x0E => Some("Video"),
0x0F => Some("Personal Healthcare"),
0x10 => Some("Audio/Video"),
0x11 => Some("Billboard"),
0x12 => Some("USB Type-C Bridge"),
0x3C => Some("I3C"),
0xDC => Some("Diagnostic"),
USB_DEVICE_CLASS_WIRELESS_CONTROLLER => Some("Wireless Controller"),
0xEF => Some("Miscellaneous"),
0xFE => Some("Application Specific"),
0xFF => Some("Vendor Specific"),
_ => None,
}
}
fn protocol_name(&self) -> Option<&str> {
match self.class {
USB_DEVICE_CLASS_WIRELESS_CONTROLLER => match self.sub_class {
0x01 => match self.protocol {
0x01 => Some("Bluetooth"),
0x02 => Some("UWB"),
0x03 => Some("Remote NDIS"),
0x04 => Some("Bluetooth AMP"),
_ => None,
},
_ => None,
},
_ => None,
}
}
fn formatted_class_name(&self) -> String {
self.class_name()
.map(|s| s.to_string())
.unwrap_or_else(|| format!("{:#04X}", self.class))
}
fn formatted_subclass_protocol(&self) -> String {
format!(
"{}/{}{}",
self.sub_class,
self.protocol,
self.protocol_name()
.map(|s| format!(" [{}]", s))
.unwrap_or_else(String::new)
)
}
}
impl From<&DeviceDescriptor> for ClassInfo {
fn from(value: &DeviceDescriptor) -> Self {
Self::new(
value.class_code(),
value.sub_class_code(),
value.protocol_code(),
)
}
}

View File

@@ -0,0 +1,17 @@
// 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
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//! Device drivers
pub(crate) mod rtk;

View File

@@ -0,0 +1,253 @@
// 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
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//! Drivers for Realtek controllers
use nom::{bytes, combinator, error, multi, number, sequence};
/// Realtek firmware file contents
pub struct Firmware {
version: u32,
project_id: u8,
patches: Vec<Patch>,
}
impl Firmware {
/// Parse a `*_fw.bin` file
pub fn parse(input: &[u8]) -> Result<Self, nom::Err<error::Error<&[u8]>>> {
let extension_sig = [0x51, 0x04, 0xFD, 0x77];
let (_rem, (_tag, fw_version, patch_count, payload)) =
combinator::all_consuming(combinator::map_parser(
// ignore the sig suffix
sequence::terminated(
bytes::complete::take(
// underflow will show up as parse failure
input.len().saturating_sub(extension_sig.len()),
),
bytes::complete::tag(extension_sig.as_slice()),
),
sequence::tuple((
bytes::complete::tag(b"Realtech"),
// version
number::complete::le_u32,
// patch count
combinator::map(number::complete::le_u16, |c| c as usize),
// everything else except suffix
combinator::rest,
)),
))(input)?;
// ignore remaining input, since patch offsets are relative to the complete input
let (_rem, (chip_ids, patch_lengths, patch_offsets)) = sequence::tuple((
// chip id
multi::many_m_n(patch_count, patch_count, number::complete::le_u16),
// patch length
multi::many_m_n(patch_count, patch_count, number::complete::le_u16),
// patch offset
multi::many_m_n(patch_count, patch_count, number::complete::le_u32),
))(payload)?;
let patches = chip_ids
.into_iter()
.zip(patch_lengths.into_iter())
.zip(patch_offsets.into_iter())
.map(|((chip_id, patch_length), patch_offset)| {
combinator::map(
sequence::preceded(
bytes::complete::take(patch_offset),
// ignore trailing 4-byte suffix
sequence::terminated(
// patch including svn version, but not suffix
combinator::consumed(sequence::preceded(
// patch before svn version or version suffix
// prefix length underflow will show up as parse failure
bytes::complete::take(patch_length.saturating_sub(8)),
// svn version
number::complete::le_u32,
)),
// dummy suffix, overwritten with firmware version
bytes::complete::take(4_usize),
),
),
|(patch_contents_before_version, svn_version): (&[u8], u32)| {
let mut contents = patch_contents_before_version.to_vec();
// replace what would have been the trailing dummy suffix with fw version
contents.extend_from_slice(&fw_version.to_le_bytes());
Patch {
contents,
svn_version,
chip_id,
}
},
)(input)
.map(|(_rem, output)| output)
})
.collect::<Result<Vec<_>, _>>()?;
// look for project id from the end
let mut offset = payload.len();
let mut project_id: Option<u8> = None;
while offset >= 2 {
// Won't panic, since offset >= 2
let chunk = &payload[offset - 2..offset];
let length: usize = chunk[0].into();
let opcode = chunk[1];
offset -= 2;
if opcode == 0xFF {
break;
}
if length == 0 {
// report what nom likely would have done, if nom was good at parsing backwards
return Err(nom::Err::Error(error::Error::new(
chunk,
error::ErrorKind::Verify,
)));
}
if opcode == 0 && length == 1 {
project_id = offset
.checked_sub(1)
.and_then(|index| payload.get(index))
.copied();
break;
}
offset -= length;
}
match project_id {
Some(project_id) => Ok(Firmware {
project_id,
version: fw_version,
patches,
}),
None => {
// we ran out of file without finding a project id
Err(nom::Err::Error(error::Error::new(
payload,
error::ErrorKind::Eof,
)))
}
}
}
/// Patch version
pub fn version(&self) -> u32 {
self.version
}
/// Project id
pub fn project_id(&self) -> u8 {
self.project_id
}
/// Patches
pub fn patches(&self) -> &[Patch] {
&self.patches
}
}
/// Patch in a [Firmware}
pub struct Patch {
chip_id: u16,
contents: Vec<u8>,
svn_version: u32,
}
impl Patch {
/// Chip id
pub fn chip_id(&self) -> u16 {
self.chip_id
}
/// Contents of the patch, including the 4-byte firmware version suffix
pub fn contents(&self) -> &[u8] {
&self.contents
}
/// SVN version
pub fn svn_version(&self) -> u32 {
self.svn_version
}
}
#[cfg(test)]
mod tests {
use super::*;
use anyhow::anyhow;
use std::{fs, io, path};
#[test]
fn parse_firmware_rtl8723b() -> anyhow::Result<()> {
let fw = Firmware::parse(&firmware_contents("rtl8723b_fw_structure.bin")?)
.map_err(|e| anyhow!("{:?}", e))?;
let fw_version = 0x0E2F9F73;
assert_eq!(fw_version, fw.version());
assert_eq!(0x0001, fw.project_id());
assert_eq!(
vec![(0x0001, 0x00002BBF, 22368,), (0x0002, 0x00002BBF, 22496,),],
patch_summaries(fw, fw_version)
);
Ok(())
}
#[test]
fn parse_firmware_rtl8761bu() -> anyhow::Result<()> {
let fw = Firmware::parse(&firmware_contents("rtl8761bu_fw_structure.bin")?)
.map_err(|e| anyhow!("{:?}", e))?;
let fw_version = 0xDFC6D922;
assert_eq!(fw_version, fw.version());
assert_eq!(0x000E, fw.project_id());
assert_eq!(
vec![(0x0001, 0x00005060, 14048,), (0x0002, 0xD6D525A4, 30204,),],
patch_summaries(fw, fw_version)
);
Ok(())
}
fn firmware_contents(filename: &str) -> io::Result<Vec<u8>> {
fs::read(
path::PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.join("resources/test/firmware/realtek")
.join(filename),
)
}
/// Return a tuple of (chip id, svn version, contents len, contents sha256)
fn patch_summaries(fw: Firmware, fw_version: u32) -> Vec<(u16, u32, usize)> {
fw.patches()
.iter()
.map(|p| {
let contents = p.contents();
let mut dummy_contents = dummy_contents(contents.len());
dummy_contents.extend_from_slice(&p.svn_version().to_le_bytes());
dummy_contents.extend_from_slice(&fw_version.to_le_bytes());
assert_eq!(&dummy_contents, contents);
(p.chip_id(), p.svn_version(), contents.len())
})
.collect::<Vec<_>>()
}
fn dummy_contents(len: usize) -> Vec<u8> {
let mut vec = (len as u32).to_le_bytes().as_slice().repeat(len / 4 + 1);
assert!(vec.len() >= len);
// leave room for svn version and firmware version
vec.truncate(len - 8);
vec
}
}

20
rust/src/internal/mod.rs Normal file
View File

@@ -0,0 +1,20 @@
// 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
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//! It's not clear where to put Rust code that isn't simply a wrapper around Python. Until we have
//! a good answer for what to do there, the idea is to put it in this (non-public) module, and
//! `pub use` it into the relevant areas of the `wrapper` module so that it's still easy for users
//! to discover.
pub(crate) mod drivers;

33
rust/src/lib.rs Normal file
View File

@@ -0,0 +1,33 @@
// 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
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//! Rust API for [Bumble](https://github.com/google/bumble).
//!
//! Bumble is a userspace Bluetooth stack that works with more or less anything that uses HCI. This
//! could be physical Bluetooth USB dongles, netsim, HCI proxied over a network from some device
//! elsewhere, etc.
//!
//! It also does not restrict what you can do with Bluetooth the way that OS Bluetooth APIs
//! typically do, making it good for prototyping, experimentation, test tools, etc.
//!
//! Bumble is primarily written in Python. Rust types that wrap the Python API, which is currently
//! the bulk of the code, are in the [wrapper] module.
#![deny(missing_docs, unsafe_code)]
pub mod wrapper;
pub mod adv;
pub(crate) mod internal;

271
rust/src/main.rs Normal file
View File

@@ -0,0 +1,271 @@
// 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
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//! CLI tools for Bumble
#![deny(missing_docs, unsafe_code)]
use bumble::wrapper::logging::{bumble_env_logging_level, py_logging_basic_config};
use clap::Parser as _;
use pyo3::PyResult;
use std::{fmt, path};
mod cli;
#[pyo3_asyncio::tokio::main]
async fn main() -> PyResult<()> {
env_logger::builder()
.filter_level(log::LevelFilter::Info)
.init();
py_logging_basic_config(bumble_env_logging_level("INFO"))?;
let cli: Cli = Cli::parse();
match cli.subcommand {
Subcommand::Firmware { subcommand: fw } => match fw {
Firmware::Realtek { subcommand: rtk } => match rtk {
Realtek::Download(dl) => {
cli::firmware::rtk::download(dl).await?;
}
Realtek::Drop { transport } => cli::firmware::rtk::drop(&transport).await?,
Realtek::Info { transport, force } => {
cli::firmware::rtk::info(&transport, force).await?;
}
Realtek::Load { transport, force } => {
cli::firmware::rtk::load(&transport, force).await?
}
Realtek::Parse { firmware_path } => cli::firmware::rtk::parse(&firmware_path)?,
},
},
Subcommand::L2cap {
subcommand,
device_config,
transport,
psm,
l2cap_coc_max_credits,
l2cap_coc_mtu,
l2cap_coc_mps,
} => {
cli::l2cap::run(
subcommand,
device_config,
transport,
psm,
l2cap_coc_max_credits,
l2cap_coc_mtu,
l2cap_coc_mps,
)
.await?
}
Subcommand::Usb { subcommand } => match subcommand {
Usb::Probe(probe) => cli::usb::probe(probe.verbose)?,
},
}
Ok(())
}
#[derive(clap::Parser)]
struct Cli {
#[clap(subcommand)]
subcommand: Subcommand,
}
#[derive(clap::Subcommand, Debug, Clone)]
enum Subcommand {
/// Manage device firmware
Firmware {
#[clap(subcommand)]
subcommand: Firmware,
},
/// L2cap client/server operations
L2cap {
#[command(subcommand)]
subcommand: L2cap,
/// Device configuration file.
///
/// See, for instance, `examples/device1.json` in the Python project.
#[arg(long)]
device_config: path::PathBuf,
/// Bumble transport spec.
///
/// <https://google.github.io/bumble/transports/index.html>
#[arg(long)]
transport: String,
/// PSM for L2CAP Connection-oriented Channel.
///
/// Must be in the range [0, 65535].
#[arg(long)]
psm: u16,
/// Maximum L2CAP CoC Credits. When not specified, lets Bumble set the default.
///
/// Must be in the range [1, 65535].
#[arg(long, value_parser = clap::value_parser!(u16).range(1..))]
l2cap_coc_max_credits: Option<u16>,
/// L2CAP CoC MTU. When not specified, lets Bumble set the default.
///
/// Must be in the range [23, 65535].
#[arg(long, value_parser = clap::value_parser!(u16).range(23..))]
l2cap_coc_mtu: Option<u16>,
/// L2CAP CoC MPS. When not specified, lets Bumble set the default.
///
/// Must be in the range [23, 65535].
#[arg(long, value_parser = clap::value_parser!(u16).range(23..))]
l2cap_coc_mps: Option<u16>,
},
/// USB operations
Usb {
#[clap(subcommand)]
subcommand: Usb,
},
}
#[derive(clap::Subcommand, Debug, Clone)]
enum Firmware {
/// Manage Realtek chipset firmware
Realtek {
#[clap(subcommand)]
subcommand: Realtek,
},
}
#[derive(clap::Subcommand, Debug, Clone)]
enum Realtek {
/// Download Realtek firmware
Download(Download),
/// Drop firmware from a USB device
Drop {
/// Bumble transport spec. Must be for a USB device.
///
/// <https://google.github.io/bumble/transports/index.html>
#[arg(long)]
transport: String,
},
/// Show driver info for a USB device
Info {
/// Bumble transport spec. Must be for a USB device.
///
/// <https://google.github.io/bumble/transports/index.html>
#[arg(long)]
transport: String,
/// Try to resolve driver info even if USB info is not available, or if the USB
/// (vendor,product) tuple is not in the list of known compatible RTK USB dongles.
#[arg(long, default_value_t = false)]
force: bool,
},
/// Load firmware onto a USB device
Load {
/// Bumble transport spec. Must be for a USB device.
///
/// <https://google.github.io/bumble/transports/index.html>
#[arg(long)]
transport: String,
/// Load firmware even if the USB info doesn't match.
#[arg(long, default_value_t = false)]
force: bool,
},
/// Parse a firmware file
Parse {
/// Firmware file to parse
firmware_path: path::PathBuf,
},
}
#[derive(clap::Args, Debug, Clone)]
struct Download {
/// Directory to download to. Defaults to an OS-specific path specific to the Bumble tool.
#[arg(long)]
output_dir: Option<path::PathBuf>,
/// Source to download from
#[arg(long, default_value_t = Source::LinuxKernel)]
source: Source,
/// Only download a single image
#[arg(long, value_name = "base name")]
single: Option<String>,
/// Overwrite existing files
#[arg(long, default_value_t = false)]
overwrite: bool,
/// Don't print the parse results for the downloaded file names
#[arg(long)]
no_parse: bool,
}
#[derive(Debug, Clone, clap::ValueEnum)]
enum Source {
LinuxKernel,
RealtekOpensource,
LinuxFromScratch,
}
impl fmt::Display for Source {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Source::LinuxKernel => write!(f, "linux-kernel"),
Source::RealtekOpensource => write!(f, "realtek-opensource"),
Source::LinuxFromScratch => write!(f, "linux-from-scratch"),
}
}
}
#[derive(clap::Subcommand, Debug, Clone)]
enum L2cap {
/// Starts an L2CAP server
Server {
/// TCP host that the l2cap server will connect to.
/// Data is bridged like so:
/// TCP server <-> (TCP client / **L2CAP server**) <-> (L2CAP client / TCP server) <-> TCP client
#[arg(long, default_value = "localhost")]
tcp_host: String,
/// TCP port that the server will connect to.
///
/// Must be in the range [1, 65535].
#[arg(long, default_value_t = 9544)]
tcp_port: u16,
},
/// Starts an L2CAP client
Client {
/// L2cap server address that this l2cap client will connect to.
bluetooth_address: String,
/// TCP host that the l2cap client will bind to and listen for incoming TCP connections.
/// Data is bridged like so:
/// TCP client <-> (TCP server / **L2CAP client**) <-> (L2CAP server / TCP client) <-> TCP server
#[arg(long, default_value = "localhost")]
tcp_host: String,
/// TCP port that the client will connect to.
///
/// Must be in the range [1, 65535].
#[arg(long, default_value_t = 9543)]
tcp_port: u16,
},
}
#[derive(clap::Subcommand, Debug, Clone)]
enum Usb {
/// Probe the USB bus for Bluetooth devices
Probe(Probe),
}
#[derive(clap::Args, Debug, Clone)]
struct Probe {
/// Show additional info for each USB device
#[arg(long, default_value_t = false)]
verbose: bool,
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,21 @@
// 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
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//! Assigned numbers from the Bluetooth spec.
mod company_ids;
mod services;
pub use company_ids::COMPANY_IDS;
pub use services::SERVICE_IDS;

View File

@@ -0,0 +1,82 @@
// 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
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//! Assigned service IDs
use crate::wrapper::core::Uuid16;
use lazy_static::lazy_static;
use std::collections;
lazy_static! {
/// Assigned service IDs
pub static ref SERVICE_IDS: collections::HashMap<Uuid16, &'static str> = [
(0x1800_u16, "Generic Access"),
(0x1801, "Generic Attribute"),
(0x1802, "Immediate Alert"),
(0x1803, "Link Loss"),
(0x1804, "TX Power"),
(0x1805, "Current Time"),
(0x1806, "Reference Time Update"),
(0x1807, "Next DST Change"),
(0x1808, "Glucose"),
(0x1809, "Health Thermometer"),
(0x180A, "Device Information"),
(0x180D, "Heart Rate"),
(0x180E, "Phone Alert Status"),
(0x180F, "Battery"),
(0x1810, "Blood Pressure"),
(0x1811, "Alert Notification"),
(0x1812, "Human Interface Device"),
(0x1813, "Scan Parameters"),
(0x1814, "Running Speed and Cadence"),
(0x1815, "Automation IO"),
(0x1816, "Cycling Speed and Cadence"),
(0x1818, "Cycling Power"),
(0x1819, "Location and Navigation"),
(0x181A, "Environmental Sensing"),
(0x181B, "Body Composition"),
(0x181C, "User Data"),
(0x181D, "Weight Scale"),
(0x181E, "Bond Management"),
(0x181F, "Continuous Glucose Monitoring"),
(0x1820, "Internet Protocol Support"),
(0x1821, "Indoor Positioning"),
(0x1822, "Pulse Oximeter"),
(0x1823, "HTTP Proxy"),
(0x1824, "Transport Discovery"),
(0x1825, "Object Transfer"),
(0x1826, "Fitness Machine"),
(0x1827, "Mesh Provisioning"),
(0x1828, "Mesh Proxy"),
(0x1829, "Reconnection Configuration"),
(0x183A, "Insulin Delivery"),
(0x183B, "Binary Sensor"),
(0x183C, "Emergency Configuration"),
(0x183E, "Physical Activity Monitor"),
(0x1843, "Audio Input Control"),
(0x1844, "Volume Control"),
(0x1845, "Volume Offset Control"),
(0x1846, "Coordinated Set Identification Service"),
(0x1847, "Device Time"),
(0x1848, "Media Control Service"),
(0x1849, "Generic Media Control Service"),
(0x184A, "Constant Tone Extension"),
(0x184B, "Telephone Bearer Service"),
(0x184C, "Generic Telephone Bearer Service"),
(0x184D, "Microphone Control"),
]
.into_iter()
.map(|(num, name)| (Uuid16::from_le_bytes(num.to_le_bytes()), name))
.collect();
}

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