Compare commits

...

143 Commits

Author SHA1 Message Date
Gilles Boccon-Gibod dd930e3bde fix implementation for Windows 2023-04-30 11:42:28 -07:00
Gilles Boccon-Gibod 9af426db45 Merge pull request #177 from google/gbg/pairing-delegate-refactor
refactor PairingDelegate
2023-04-18 15:07:23 -07:00
Gilles Boccon-Gibod 4286b2ab59 address PR comments 2023-04-18 15:05:18 -07:00
Gilles Boccon-Gibod 3442358dea refactor PairingDelegate 2023-04-18 15:04:53 -07:00
Gilles Boccon-Gibod bf3e05ef91 Merge pull request #174 from google/gbg/pairing-fix
fix role state for classic connections
2023-04-11 20:32:29 -07:00
Gilles Boccon-Gibod 5351ab8a42 Merge pull request #176 from google/gbg/connection-defaults
only use 1M parameters by default
2023-04-11 20:26:42 -07:00
Gilles Boccon-Gibod 49b2c13e69 only use 1M parameters by default 2023-04-09 17:57:11 -07:00
Gilles Boccon-Gibod 2c2f512180 add comment to explain the initial role choice 2023-04-07 12:19:28 -07:00
Gilles Boccon-Gibod 859aea5a99 fix role state for classic connections 2023-04-07 10:24:26 -07:00
Lucas Abel 962737a97b Merge pull request #173 from zxzxwu/smp
SMP: Determine initiator by direction instead of role
2023-04-07 09:50:06 -07:00
Josh Wu 85496aaff5 SMP: Determine initiator by direction instead of role
Though in the spec this is not allowed, but in some ambiguous cases such
as SMP over BR/EDR or we want to test how remote devices handle invalid
pairing requests, we still need to allow this behavior, with some logs
to let users know it's invalid.
2023-04-08 00:02:48 +08:00
Lucas Abel a95e601a5c Merge pull request #172 from qiaoccolato/main
LICENCE: recorde colors.py licence
2023-04-05 13:31:13 -07:00
Qiao Yang df218b5370 LICENCE: recorde colors.py licence 2023-04-05 19:02:44 +00:00
Alan Rosenthal 0f737244b5 Merge pull request #169 from AlanRosenthal/alan/remote-values
Add `show remote-values`
2023-04-05 09:00:48 -04:00
Alan Rosenthal a258ba383a Add show remote-values
`gatt_client` caches values read/notifications/indications and displays the most recent value to the user
2023-04-04 18:15:42 +00:00
Gilles Boccon-Gibod c53e1d2480 Merge pull request #171 from google/gbg/keystore-name
use device public or static address for keystore namespace
2023-04-03 17:58:18 -07:00
Gilles Boccon-Gibod 620c135ac4 only instantiate keystore if not already set 2023-04-03 17:52:51 -07:00
Gilles Boccon-Gibod fca73a49a3 use device public or static address for keystore namespace 2023-04-03 12:39:22 -07:00
Alan Rosenthal cf70db84a1 Merge pull request #170 from AlanRosenthal/alan/fix-char-proxy-print
Fix CharacteristicProxy __str__
2023-03-31 17:25:50 -04:00
Alan Rosenthal 7731c41f80 Fix CharacteristicProxy __str__
property was really an int, and needed to be transformed into a `Characteristic.Properties`
2023-03-31 17:06:33 -04:00
Alan Rosenthal 278341cbc0 Merge pull request #167 from AlanRosenthal/alan/properties
Create Characteristic.Property
2023-03-31 16:14:38 -04:00
Alan Rosenthal fb49a87494 Create Characteristic.Property
Move all Characteristic properties into its own `enum.IntFlag` class
2023-03-31 16:09:24 -04:00
Alan Rosenthal eba82b9d9a Merge pull request #164 from AlanRosenthal/alan/local-write
Add `local-write` to bumble-console
2023-03-31 16:07:09 -04:00
Alan Rosenthal 677fc77d3c Merge pull request #163 from AlanRosenthal/alan/local-values
Add `show local-values`
2023-03-31 16:03:52 -04:00
Alan Rosenthal e026de295f Add show local-values
This PR adds a way to display the local gatt characteristics/descriptors values

If no connections, it shows the value of every characteristic/descriptor.
When there's a connection, it shows the value for each specific connection - CCCDs are connection specific

This screen auto-updates every second
2023-03-31 00:20:07 +00:00
Alan Rosenthal 52c15705e9 Add local-write to bumble-console
Add a command to update the local gatt server, and notify/indicate subscribes (if any)
2023-03-30 12:33:32 -04:00
Lucas Abel 45ca0ef071 Merge pull request #166 from google/uael/att-fix
att: fixed use of unknown attribute
2023-03-30 07:12:28 -07:00
uael e0af954baa att: fixed use of unknown attribute 2023-03-30 14:05:43 +00:00
Lucas Abel 044597de66 Merge pull request #161 from google/uael/smp-get-number-type-hint
smp: fix `PairingDelegate.get_number` return type
2023-03-28 11:48:09 -07:00
Lucas Abel fb68fa6a33 Merge pull request #162 from zxzxwu/roleswitch
Add role switch test and assertion in self test
2023-03-28 06:41:03 -07:00
Josh Wu b6fe7460ac Add role switch test and assertion in self test 2023-03-28 12:52:00 +08:00
Lucas Abel 5c59b6ca6d Merge pull request #158 from benquike/main
Fix HCI_PIN_Code_Reply_Command
2023-03-27 17:37:54 -07:00
Hui Peng dcd66743f6 Use delegate.get_string to get pin code 2023-03-27 17:08:26 -07:00
Hui Peng 423a5a95d8 add get_string API in PairingDelegate 2023-03-27 17:02:12 -07:00
Lucas Abel 6f1f185642 Merge pull request #155 from akuker/main
Fix typo in console header
2023-03-27 16:16:27 -07:00
Lucas Abel 8e881fdb18 smp: fix PairingDelegate.get_number return type
This function can return `None` to indicate a negative reply,
update the type hint accordingly.
2023-03-27 22:51:23 +00:00
Lucas Abel 4907022398 Merge pull request #157 from yuyangh/main
Add connection into event emit
2023-03-27 14:38:41 -07:00
Lucas Abel e93f71c035 Add missing Connection import 2023-03-27 14:27:48 -07:00
Alan Rosenthal 94ff80563b Merge pull request #160 from AlanRosenthal/alan/types
Add some missing types to apps/console.py, bumble/gatt_client.py
2023-03-27 15:00:03 -04:00
Lucas Abel 552deab8a7 Add Connection type
Co-authored-by: Alan Rosenthal <1288897+AlanRosenthal@users.noreply.github.com>
2023-03-27 11:53:34 -07:00
Lucas Abel a72beb1b06 Merge pull request #144 from zxzxwu/classic_link
Add Classic Bluetooth link support
2023-03-27 11:41:42 -07:00
Lucas Abel 7e62d4a81a Merge pull request #150 from zxzxwu/roleswitch
Support BR/EDR role switch & change events
2023-03-27 11:41:29 -07:00
Alan Rosenthal a50181e6b8 Add some missing types to apps/console.py, bumble/gatt_client.py
Added via code inspection (not via a tool like pytype)
2023-03-25 16:12:38 +00:00
Josh Wu 9e1358536b Add switch_role 2023-03-25 15:17:50 +08:00
Josh Wu 21d8a0d577 Add Classic Local Link support
Currently supported features:
* Connect
* Accept
* Switch Role
* Disconnect
* ACL data transmittion
2023-03-25 15:11:59 +08:00
Hui Peng a8e61673d0 Fix HCI_PIN_Code_Reply_Command in Device.on_pin_code_request 2023-03-25 03:48:56 +00:00
Hui Peng bd25cf27df Fix a misconfig of HCI_PIN_Code_Reply_Command
The pin_code field is of fixed length of 16 bytes
2023-03-25 03:47:07 +00:00
Alan Rosenthal fdf2da7023 Merge pull request #159 from AlanRosenthal/alan/permissions
Fix typo when parsing device-config's gatt server
2023-03-24 19:33:05 -04:00
Alan Rosenthal dfb6734324 Fix typo when parsing device-config's gatt server
* 'permission' instead of 'permissions'
* Also added a more user friendly error message when Attribute.string_to_permissions fails
```
TypeError: Attribute::permissions error:
Expected a string containing any of the keys, seperated by commas: READABLE,WRITEABLE,READ_REQUIRES_ENCRYPTION,WRITE_REQUIRES_ENCRYPTION,READ_REQUIRES_AUTHENTICATION,WRITE_REQUIRES_AUTHENTICATION,READ_REQUIRES_AUTHORIZATION,WRITE_REQUIRES_AUTHORIZATION
Got: 1
```
```
Exception: Error parsing Device Config's GATT Services. The key 'permission' must be renamed to 'permissions'
```
2023-03-24 16:11:18 -04:00
Yuyang Huang 51ae6a5969 Add connection into event emit 2023-03-24 11:16:10 -07:00
Josh Wu 4fc13585cc Handle BR/EDR connection roles 2023-03-24 15:13:48 +08:00
Tony Kuker c5e5397ed8 Fix typo in console header 2023-03-23 21:44:55 +00:00
Lucas Abel 4c6320f98a Merge pull request #142 from AlanRosenthal/main
Fix small bug with services set via --device-config
2023-03-23 12:47:14 -07:00
Lucas Abel cc0d56ad14 Merge pull request #152 from duohoo/g722_decoder
Add G722 decoder with pure python implementation
2023-03-23 12:45:07 -07:00
Lucas Abel 0019fa8e79 Merge pull request #149 from yuyangh/yuyangh/add_ASHA_event_emit
Add ASHA event emitter
2023-03-23 12:44:42 -07:00
Lucas Abel 7ae1bf8959 Merge pull request #148 from yuyangh/yuyangh/add_audio_status_point
Add ASHA audio status point
2023-03-23 12:43:35 -07:00
Yuyang Huang 9541cb6db0 Add ASHA audio status point 2023-03-23 12:15:10 -07:00
Lucas Abel 1cd13dfc19 Merge pull request #153 from benquike/main
Add 1 bug fix and a few features in bumble
2023-03-23 10:31:02 -07:00
Hui Peng d4346c3c9b delegate the HCI_PIN_Code_Request event on host 2023-03-23 10:14:56 -07:00
Hui Peng afe8765508 Add on_pin_code_request to support legacy BT classic pairing 2023-03-23 10:14:56 -07:00
Hui Peng 41d1772cb5 Add test for HCI_PIN_Code_Request_Reply_Command 2023-03-23 10:14:51 -07:00
Hui Peng 6e9078d60e Add implemenetation of HCI_PIN_Code_Request_Reply_Command 2023-03-23 09:50:50 -07:00
Hui Peng d5c7d0db57 Fix a bug in HCI_Object.dict_from_bytes 2023-03-23 08:57:10 -07:00
Hui Peng b70ebdef73 Allow Device.enable_classic to be configurable 2023-03-23 08:56:32 -07:00
Duo Ho 3af027e234 fix comments 2023-03-23 04:36:02 +00:00
Gilles Boccon-Gibod 6e719ca9fd Merge pull request #147 from google/gbg/btbench
add benchmark tool and doc
2023-03-22 21:13:24 -07:00
Duo Ho 1a580d1c1e Add G722 decoder with pure python implementation 2023-03-23 03:07:45 +00:00
Alan Rosenthal aee7348687 Merge pull request #151 from AlanRosenthal/alan/add-types-via-pytypes
Used pytype to find some missing types / fix small issue
2023-03-22 20:58:25 -04:00
Gilles Boccon-Gibod 864889ccab rename .run to .spawn 2023-03-22 17:26:32 -07:00
Alan Rosenthal fda00dcb28 Used pytype to find some missing types
```
pytype --pythonpath . ./bumble/device.py
```
2023-03-22 14:46:41 +00:00
Yuyang Huang 77e5618ce7 Add ASHA event emitter 2023-03-21 18:00:50 -07:00
Yuyang Huang 6fa857ad13 Add ASHA event emitter 2023-03-21 15:38:29 -07:00
Gilles Boccon-Gibod bc29f327ef address PR comments, take 2. 2023-03-21 15:33:34 -07:00
Gilles Boccon-Gibod 1894b96de4 address PR comments 2023-03-21 15:01:46 -07:00
Gilles Boccon-Gibod c4fb63d35c Merge pull request #146 from google/gbg/snoop-file
add auto-snooping for transports
2023-03-21 09:15:07 -07:00
Gilles Boccon-Gibod 33ae047765 add reversed role example doc 2023-03-20 18:35:22 -07:00
Gilles Boccon-Gibod 1efa2e9d44 add benchmark tool and doc 2023-03-20 18:25:21 -07:00
Gilles Boccon-Gibod aa9af61cbe improve exception messages 2023-03-20 12:14:28 -07:00
Gilles Boccon-Gibod dc3ac3060e add auto-snooping for transports 2023-03-20 11:06:50 -07:00
Alan Rosenthal c34c5fdf17 Fix small bug with services set via --device-config
before:
```
  File "/home/alanrosenthal/code/fitbit/bumble/bumble/gatt.py", line 572, in __str__
    f'Descriptor(handle=0x{self.handle:04X}, '
  File "/home/alanrosenthal/code/fitbit/bumble/bumble/att.py", line 756, in read_value
    self.permissions & self.READ_REQUIRES_ENCRYPTION
TypeError: unsupported operand type(s) for &: 'str' and 'int'
```
2023-03-14 18:16:46 -04:00
Gilles Boccon-Gibod e77723a5f9 Merge pull request #135 from google/gbg/snoop
add snoop support
2023-03-07 09:16:33 -08:00
Gilles Boccon-Gibod fe8cf51432 Merge pull request #139 from google/gbg/hotfix-001
two small hotfixes
2023-03-07 09:16:15 -08:00
Gilles Boccon-Gibod 97a0e115ae two small hotfixes 2023-03-05 20:24:16 -08:00
Lucas Abel 46e7aac77c Merge pull request #138 from rahularya50/aryarahul/fix-att-perms
Add support for ATT permissions on server-side
2023-03-03 16:18:45 -08:00
Rahul Arya 08a6f4fa49 Add support for ATT permissions on server-side 2023-03-03 16:11:33 -08:00
Lucas Abel ca063eda0b Merge pull request #132 from rahularya50/aryarahul/fix-uuid
Fix UUID byte-order in serialization
2023-03-03 15:48:50 -08:00
Rahul Arya c97ba4319f Fix UUID byte-order in serialization 2023-03-03 22:38:21 +00:00
Gilles Boccon-Gibod a5275ade29 add snoop support 2023-03-02 14:34:49 -08:00
Lucas Abel e7b39c4188 Merge pull request #130 from google/uael/self-host-ainsicolors
Effort to make Bumble self hosted into AOSP
2023-02-23 15:31:23 -08:00
uael 0594eaef09 link: make websockets import lazy 2023-02-23 21:06:12 +00:00
uael 05200284d2 a2dp: get rid of construct dependency 2023-02-23 21:01:17 +00:00
uael d21da78aa3 overall: host a minimal copy of ainsicolors 2023-02-23 20:53:06 +00:00
Gilles Boccon-Gibod fbc7cf02a3 Merge pull request #129 from google/gbg/smp-improvements
improve smp compatibility with other OS flows
2023-02-14 19:10:51 -08:00
Gilles Boccon-Gibod a8beb6b1ff remove stale comment 2023-02-14 16:05:46 -08:00
Gilles Boccon-Gibod 2d44de611f make pylint happy 2023-02-14 16:04:20 -08:00
Lucas Abel 9874bb3b37 Merge pull request #128 from google/uael/device-smp-patch
Small patches for device and SMP
2023-02-14 13:15:16 -08:00
uael 6645ad47ee smp: add a small type hint 2023-02-14 21:04:39 +00:00
uael ad27de7717 device: remove "feature" which enable accept to return the same connection has connect 2023-02-14 21:04:39 +00:00
Gilles Boccon-Gibod e6fc63b2d8 improve smp compatibility with other OS flows 2023-02-13 10:53:00 -08:00
Gilles Boccon-Gibod 1321c7da81 Merge pull request #125 from google/gbg/gh-124
fix getting the filename from the keystore option.
2023-02-10 20:17:38 -08:00
Gilles Boccon-Gibod 5a1b03fd91 format 2023-02-08 10:54:27 -08:00
Gilles Boccon-Gibod de47721753 fix typo caused by an earlier refactor. 2023-02-08 09:56:11 -08:00
Gilles Boccon-Gibod 83a76a75d3 fix getting the filename from the keystore option. 2023-02-08 09:40:19 -08:00
Lucas Abel d5b5ef8313 Merge pull request #122 from google/uael/abort-on-fix-invalid-state
utils: fix possible invalide state error while canceling future for `abort_on`
2023-02-06 17:13:34 -08:00
uael 856a8d53cd utils: fix possible invalide state error while canceling future for abort_on 2023-02-06 16:58:23 +00:00
Gilles Boccon-Gibod 177c273a57 Merge pull request #121 from google/gbg/replace-bitstruct
replace bitstruct with construct
2023-02-05 11:33:36 -08:00
Gilles Boccon-Gibod 24a863983d Merge branch 'gbg/replace-bitstruct' of https://github.com/google/bumble into gbg/replace-bitstruct
# Conflicts:
#	bumble/a2dp.py
#	pyproject.toml
2023-02-04 09:31:18 -08:00
Gilles Boccon-Gibod b7ef09d4a3 fix format 2023-02-04 09:26:31 -08:00
Gilles Boccon-Gibod b5b6cd13b8 replace bitstruct with construct 2023-02-04 09:23:13 -08:00
Gilles Boccon-Gibod ef781bc374 replace bitstruct with construct 2023-02-03 19:41:07 -08:00
Lucas Abel 00978c1d63 Merge pull request #118 from google/uael/type-hints
overall: add types hints to the small subset used by avatar
2023-02-02 12:48:40 -08:00
uael b731f6f556 overall: add types hints to the small subset used by avatar 2023-02-02 19:37:55 +00:00
Lucas Abel ed261886e1 Merge pull request #119 from google/uael/fix-ci-packages-version
build: fix version of packages running checks in CI
2023-02-02 11:03:34 -08:00
uael 5e18094c31 build: fix version of packages running checks in CI 2023-02-02 17:23:15 +00:00
Lucas Abel 9a9b4e5bf1 Merge pull request #117 from google/uael/host-fixes
host: fixed `.latency` attribute error
2023-01-27 17:38:11 -08:00
Abel Lucas 895f1618d8 host: fixed .latency attribute error 2023-01-27 23:05:43 +00:00
Gilles Boccon-Gibod 52746e0c68 Merge pull request #116 from google/barbibulle-patch-1
fix libusb-package dependency
2023-01-25 15:59:42 -08:00
Gilles Boccon-Gibod f9b7072423 Update setup.cfg 2023-01-25 15:37:33 -08:00
Gilles Boccon-Gibod fa4be1958f Merge pull request #114 from google/gbg/fix-constant-typo
fix typo in constant name
2023-01-23 08:50:07 -08:00
Gilles Boccon-Gibod f1686d8a9a fix typo in constant name 2023-01-22 19:10:13 -08:00
Gilles Boccon-Gibod 5c6a7f2036 Merge pull request #113 from google/gbg/mypy
add basic support for mypy type checking
2023-01-20 08:08:19 -08:00
Gilles Boccon-Gibod 99758e4b7d add basic support for mypy type checking 2023-01-20 00:20:50 -08:00
Alan Rosenthal 7385de6a69 Merge pull request #95 from AlanRosenthal/alan/fix_show_attributes
Fix `show attributes`
2023-01-19 14:57:22 -05:00
Alan Rosenthal bb297e7516 Fix show attributes
`show attributes` wasn't being populated since `show_attributes()` was never called.

Also updated `show attributes` to match the color and indentation of `show services`
2023-01-19 12:21:37 -05:00
Lucas Abel 8a91c614c7 Merge pull request #109 from qiaoccolato/main
transport: make libusb_package optional
2023-01-18 14:48:05 -08:00
Qiao Yang 70a50a74b7 transport: make libusb_package optional 2023-01-17 15:17:11 -08:00
Gilles Boccon-Gibod 6a16c61c5f Merge pull request #111 from google/gbg/fix-null-address-setting
don't set a random address when it is 00:00:00:00:00:00
2023-01-13 21:35:32 -08:00
Gilles Boccon-Gibod 0a22f2f7c7 use HCI_LE_Rand 2023-01-13 16:59:34 -08:00
Gilles Boccon-Gibod 422b05ad51 don't set a random address when it is 00:00:00:00:00:00 2023-01-13 13:22:27 -08:00
Gilles Boccon-Gibod 16e926a216 Merge pull request #107 from yuyangh/yuyangh/add_ASHA_L2CAP
add ASHA L2CAP and Event Emitter
2023-01-13 11:05:16 -08:00
Gilles Boccon-Gibod e94dc66d0c Merge pull request #110 from aleksandrovrts/hci-socket_fix
Fix bug when use hci-socket transport
2023-01-11 09:35:23 -08:00
Aleksandr Aleksandrov e37c77532b hci_socket.py: fix socket.fileno() call 2023-01-11 16:16:45 +03:00
Gilles Boccon-Gibod 8b9ce03e86 Merge pull request #108 from google/gbg/fix-bluez-vhci
support more commands in controller.py
2023-01-08 14:40:26 -08:00
Gilles Boccon-Gibod 7e854efbbb support more commands in controller.py 2023-01-06 21:51:47 -08:00
Yuyang Huang 64b75be29b add psm parameter for testing support 2023-01-03 16:39:45 -08:00
Yuyang Huang 06018211fe emit event for ASHA l2cap packet 2023-01-03 15:01:32 -08:00
Yuyang Huang e640991608 Merge branch 'google:main' into yuyangh/add_ASHA_L2CAP 2023-01-03 14:58:37 -08:00
Yuyang Huang 1068a6858d improve logging 2022-12-20 13:33:18 -08:00
Lucas Abel 17db5dd4ff Merge pull request #103 from google/uael/device-fixes
Misc device fixes
2022-12-20 12:15:49 -08:00
Abel Lucas ea0a7e2347 device: commit LE connection **before** reading it's PHY 2022-12-20 19:25:43 +00:00
Yuyang Huang 6febd1ba35 add L2CAP CoC to ASHA 2022-12-20 11:15:58 -08:00
Gilles Boccon-Gibod ea6a8d4339 Merge pull request #104 from google/gbg/fix-windll-load
fix libusb loading on Windows
2022-12-20 08:05:57 -08:00
Abel Lucas ce049865a4 device: always prefer R2 for remote name request 2022-12-20 01:48:08 +00:00
98 changed files with 5166 additions and 984 deletions
+6 -1
View File
@@ -71,5 +71,10 @@
"editor.rulers": [88] "editor.rulers": [88]
}, },
"python.formatting.provider": "black", "python.formatting.provider": "black",
"pylint.importStrategy": "useBundled" "pylint.importStrategy": "useBundled",
"python.testing.pytestArgs": [
"."
],
"python.testing.unittestEnabled": false,
"python.testing.pytestEnabled": true
} }
+19
View File
@@ -200,3 +200,22 @@
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and See the License for the specific language governing permissions and
limitations under the License. limitations under the License.
---
Files: bumble/colors.py
Copyright (c) 2012 Giorgos Verigakis <verigak@gmail.com>
Permission to use, copy, modify, and distribute this software for any
purpose with or without fee is hereby granted, provided that the above
copyright notice and this permission notice appear in all copies.
THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
+1209
View File
File diff suppressed because it is too large Load Diff
+251 -36
View File
@@ -24,10 +24,12 @@ import logging
import os import os
import random import random
import re import re
import humanize
from typing import Optional, Union
from collections import OrderedDict from collections import OrderedDict
import click import click
import colors from prettytable import PrettyTable
from prompt_toolkit import Application from prompt_toolkit import Application
from prompt_toolkit.history import FileHistory from prompt_toolkit.history import FileHistory
@@ -53,11 +55,13 @@ from prompt_toolkit.layout import (
from bumble import __version__ from bumble import __version__
import bumble.core import bumble.core
from bumble import colors
from bumble.core import UUID, AdvertisingData, BT_LE_TRANSPORT from bumble.core import UUID, AdvertisingData, BT_LE_TRANSPORT
from bumble.device import ConnectionParametersPreferences, Device, Connection, Peer from bumble.device import ConnectionParametersPreferences, Device, Connection, Peer
from bumble.utils import AsyncRunner from bumble.utils import AsyncRunner
from bumble.transport import open_transport_or_link from bumble.transport import open_transport_or_link
from bumble.gatt import Characteristic from bumble.gatt import Characteristic, Service, CharacteristicDeclaration, Descriptor
from bumble.gatt_client import CharacteristicProxy
from bumble.hci import ( from bumble.hci import (
HCI_Constant, HCI_Constant,
HCI_LE_1M_PHY, HCI_LE_1M_PHY,
@@ -119,9 +123,12 @@ def parse_phys(phys):
# Console App # Console App
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
class ConsoleApp: class ConsoleApp:
connected_peer: Optional[Peer]
def __init__(self): def __init__(self):
self.known_addresses = set() self.known_addresses = set()
self.known_attributes = [] self.known_remote_attributes = []
self.known_local_attributes = []
self.device = None self.device = None
self.connected_peer = None self.connected_peer = None
self.top_tab = 'device' self.top_tab = 'device'
@@ -154,10 +161,12 @@ class ConsoleApp:
'rssi': {'on': None, 'off': None}, 'rssi': {'on': None, 'off': None},
'show': { 'show': {
'scan': None, 'scan': None,
'services': None,
'attributes': None,
'log': None, 'log': None,
'device': None, 'device': None,
'local-services': None,
'remote-services': None,
'local-values': None,
'remote-values': None,
}, },
'filter': { 'filter': {
'address': None, 'address': None,
@@ -168,10 +177,11 @@ class ConsoleApp:
'disconnect': None, 'disconnect': None,
'discover': {'services': None, 'attributes': None}, 'discover': {'services': None, 'attributes': None},
'request-mtu': None, 'request-mtu': None,
'read': LiveCompleter(self.known_attributes), 'read': LiveCompleter(self.known_remote_attributes),
'write': LiveCompleter(self.known_attributes), 'write': LiveCompleter(self.known_remote_attributes),
'subscribe': LiveCompleter(self.known_attributes), 'local-write': LiveCompleter(self.known_local_attributes),
'unsubscribe': LiveCompleter(self.known_attributes), 'subscribe': LiveCompleter(self.known_remote_attributes),
'unsubscribe': LiveCompleter(self.known_remote_attributes),
'set-phy': {'1m': None, '2m': None, 'coded': None}, 'set-phy': {'1m': None, '2m': None, 'coded': None},
'set-default-phy': None, 'set-default-phy': None,
'quit': None, 'quit': None,
@@ -197,12 +207,14 @@ class ConsoleApp:
) )
self.output_max_lines = 20 self.output_max_lines = 20
self.scan_results_text = FormattedTextControl() self.scan_results_text = FormattedTextControl()
self.services_text = FormattedTextControl() self.local_services_text = FormattedTextControl()
self.attributes_text = FormattedTextControl() self.remote_services_text = FormattedTextControl()
self.device_text = FormattedTextControl() self.device_text = FormattedTextControl()
self.log_text = FormattedTextControl( self.log_text = FormattedTextControl(
get_cursor_position=lambda: Point(0, max(0, len(self.log_lines) - 1)) get_cursor_position=lambda: Point(0, max(0, len(self.log_lines) - 1))
) )
self.local_values_text = FormattedTextControl()
self.remote_values_text = FormattedTextControl()
self.log_height = Dimension(min=7, weight=4) self.log_height = Dimension(min=7, weight=4)
self.log_max_lines = 100 self.log_max_lines = 100
self.log_lines = [] self.log_lines = []
@@ -214,12 +226,20 @@ class ConsoleApp:
filter=Condition(lambda: self.top_tab == 'scan'), filter=Condition(lambda: self.top_tab == 'scan'),
), ),
ConditionalContainer( ConditionalContainer(
Frame(Window(self.services_text), title='Services'), Frame(Window(self.local_services_text), title='Local Services'),
filter=Condition(lambda: self.top_tab == 'services'), filter=Condition(lambda: self.top_tab == 'local-services'),
), ),
ConditionalContainer( ConditionalContainer(
Frame(Window(self.attributes_text), title='Attributes'), Frame(Window(self.local_values_text), title='Local Values'),
filter=Condition(lambda: self.top_tab == 'attributes'), filter=Condition(lambda: self.top_tab == 'local-values'),
),
ConditionalContainer(
Frame(Window(self.remote_services_text), title='Remote Services'),
filter=Condition(lambda: self.top_tab == 'remote-services'),
),
ConditionalContainer(
Frame(Window(self.remote_values_text), title='Remote Values'),
filter=Condition(lambda: self.top_tab == 'remote-values'),
), ),
ConditionalContainer( ConditionalContainer(
Frame(Window(self.log_text, height=self.log_height), title='Log'), Frame(Window(self.log_text, height=self.log_height), title='Log'),
@@ -281,6 +301,7 @@ class ConsoleApp:
self.device.listener = DeviceListener(self) self.device.listener = DeviceListener(self)
await self.device.power_on() await self.device.power_on()
self.show_device(self.device) self.show_device(self.device)
self.show_local_services(self.device.gatt_server.attributes)
# Run the UI # Run the UI
await self.ui.run_async() await self.ui.run_async()
@@ -359,32 +380,59 @@ class ConsoleApp:
self.scan_results_text.text = ANSI('\n'.join(lines)) self.scan_results_text.text = ANSI('\n'.join(lines))
self.ui.invalidate() self.ui.invalidate()
def show_services(self, services): def show_remote_services(self, services):
lines = [] lines = []
del self.known_attributes[:] del self.known_remote_attributes[:]
for service in services: for service in services:
lines.append(('ansicyan', str(service) + '\n')) lines.append(("ansicyan", f"{service}\n"))
for characteristic in service.characteristics: for characteristic in service.characteristics:
lines.append(('ansimagenta', ' ' + str(characteristic) + '\n')) lines.append(('ansimagenta', f' {characteristic} + \n'))
self.known_attributes.append( self.known_remote_attributes.append(
f'{service.uuid.to_hex_str()}.{characteristic.uuid.to_hex_str()}' f'{service.uuid.to_hex_str()}.{characteristic.uuid.to_hex_str()}'
) )
self.known_attributes.append(f'*.{characteristic.uuid.to_hex_str()}') self.known_remote_attributes.append(
self.known_attributes.append(f'#{characteristic.handle:X}') f'*.{characteristic.uuid.to_hex_str()}'
)
self.known_remote_attributes.append(f'#{characteristic.handle:X}')
for descriptor in characteristic.descriptors: for descriptor in characteristic.descriptors:
lines.append(('ansigreen', ' ' + str(descriptor) + '\n')) lines.append(("ansigreen", f" {descriptor}\n"))
self.services_text.text = lines self.remote_services_text.text = lines
self.ui.invalidate() self.ui.invalidate()
def show_attributes(self, attributes): def show_local_services(self, attributes):
lines = [] lines = []
del self.known_local_attributes[:]
for attribute in attributes: for attribute in attributes:
lines.append(('ansicyan', f'{attribute}\n')) if isinstance(attribute, Service):
# Save the most recent service for use later
service = attribute
lines.append(("ansicyan", f"{attribute}\n"))
elif isinstance(attribute, Characteristic):
# CharacteristicDeclaration includes all info from Characteristic
# no need to print it twice
continue
elif isinstance(attribute, CharacteristicDeclaration):
# Save the most recent characteristic declaration for use later
characteristic_declaration = attribute
self.known_local_attributes.append(
f'{service.uuid.to_hex_str()}.{attribute.characteristic.uuid.to_hex_str()}'
)
self.known_local_attributes.append(
f'#{attribute.characteristic.handle:X}'
)
lines.append(("ansimagenta", f" {attribute}\n"))
elif isinstance(attribute, Descriptor):
self.known_local_attributes.append(
f'{service.uuid.to_hex_str()}.{characteristic_declaration.characteristic.uuid.to_hex_str()}.{attribute.type.to_hex_str()}'
)
self.known_local_attributes.append(f'#{attribute.handle:X}')
lines.append(("ansigreen", f" {attribute}\n"))
else:
lines.append(("ansiyellow", f"{attribute}\n"))
self.attributes_text.text = lines self.local_services_text.text = lines
self.ui.invalidate() self.ui.invalidate()
def show_device(self, device): def show_device(self, device):
@@ -469,7 +517,7 @@ class ConsoleApp:
await self.connected_peer.discover_descriptors(characteristic) await self.connected_peer.discover_descriptors(characteristic)
self.append_to_output('discovery completed') self.append_to_output('discovery completed')
self.show_services(self.connected_peer.services) self.show_remote_services(self.connected_peer.services)
async def discover_attributes(self): async def discover_attributes(self):
if not self.connected_peer: if not self.connected_peer:
@@ -483,7 +531,9 @@ class ConsoleApp:
self.show_attributes(attributes) self.show_attributes(attributes)
def find_characteristic(self, param): def find_remote_characteristic(self, param) -> Optional[CharacteristicProxy]:
if not self.connected_peer:
return None
parts = param.split('.') parts = param.split('.')
if len(parts) == 2: if len(parts) == 2:
service_uuid = UUID(parts[0]) if parts[0] != '*' else None service_uuid = UUID(parts[0]) if parts[0] != '*' else None
@@ -503,6 +553,38 @@ class ConsoleApp:
return None return None
def find_local_attribute(
self, param
) -> Optional[Union[Characteristic, Descriptor]]:
parts = param.split('.')
if len(parts) == 3:
service_uuid = UUID(parts[0])
characteristic_uuid = UUID(parts[1])
descriptor_uuid = UUID(parts[2])
return self.device.gatt_server.get_descriptor_attribute(
service_uuid, characteristic_uuid, descriptor_uuid
)
if len(parts) == 2:
service_uuid = UUID(parts[0])
characteristic_uuid = UUID(parts[1])
characteristic_attributes = (
self.device.gatt_server.get_characteristic_attributes(
service_uuid, characteristic_uuid
)
)
if characteristic_attributes:
return characteristic_attributes[1]
return None
elif len(parts) == 1:
if parts[0].startswith('#'):
attribute_handle = int(f'{parts[0][1:]}', 16)
attribute = self.device.gatt_server.get_attribute(attribute_handle)
if isinstance(attribute, (Characteristic, Descriptor)):
return attribute
return None
return None
async def rssi_monitor_loop(self): async def rssi_monitor_loop(self):
while True: while True:
if self.monitor_rssi and self.connected_peer: if self.monitor_rssi and self.connected_peer:
@@ -655,10 +737,115 @@ class ConsoleApp:
async def do_show(self, params): async def do_show(self, params):
if params: if params:
if params[0] in {'scan', 'services', 'attributes', 'log', 'device'}: if params[0] in {
'scan',
'log',
'device',
'local-services',
'remote-services',
'local-values',
'remote-values',
}:
self.top_tab = params[0] self.top_tab = params[0]
self.ui.invalidate() self.ui.invalidate()
while self.top_tab == 'local-values':
await self.do_show_local_values()
await asyncio.sleep(1)
while self.top_tab == 'remote-values':
await self.do_show_remote_values()
await asyncio.sleep(1)
async def do_show_local_values(self):
prettytable = PrettyTable()
field_names = ["Service", "Characteristic", "Descriptor"]
# if there's no connections, add a column just for value
if not self.device.connections:
field_names.append("Value")
# if there are connections, add a column for each connection's value
for connection in self.device.connections.values():
field_names.append(f"Connection {connection.handle}")
for attribute in self.device.gatt_server.attributes:
if isinstance(attribute, Characteristic):
service = self.device.gatt_server.get_attribute_group(
attribute.handle, Service
)
if not service:
continue
values = [
attribute.read_value(connection)
for connection in self.device.connections.values()
]
if not values:
values = [attribute.read_value(None)]
prettytable.add_row([f"{service.uuid}", attribute.uuid, ""] + values)
elif isinstance(attribute, Descriptor):
service = self.device.gatt_server.get_attribute_group(
attribute.handle, Service
)
if not service:
continue
characteristic = self.device.gatt_server.get_attribute_group(
attribute.handle, Characteristic
)
if not characteristic:
continue
values = [
attribute.read_value(connection)
for connection in self.device.connections.values()
]
if not values:
values = [attribute.read_value(None)]
# TODO: future optimization: convert CCCD value to human readable string
prettytable.add_row(
[service.uuid, characteristic.uuid, attribute.type] + values
)
prettytable.field_names = field_names
self.local_values_text.text = prettytable.get_string()
self.ui.invalidate()
async def do_show_remote_values(self):
prettytable = PrettyTable(
field_names=[
"Connection",
"Service",
"Characteristic",
"Descriptor",
"Time",
"Value",
]
)
for connection in self.device.connections.values():
for handle, (time, value) in connection.gatt_client.cached_values.items():
row = [connection.handle]
attribute = connection.gatt_client.get_attributes(handle)
if not attribute:
continue
if len(attribute) == 3:
row.extend(
[attribute[0].uuid, attribute[1].uuid, attribute[2].type]
)
elif len(attribute) == 2:
row.extend([attribute[0].uuid, attribute[1].uuid, ""])
elif len(attribute) == 1:
row.extend([attribute[0].uuid, "", ""])
else:
continue
row.extend([humanize.naturaltime(time), value])
prettytable.add_row(row)
self.remote_values_text.text = prettytable.get_string()
self.ui.invalidate()
async def do_get_phy(self, _): async def do_get_phy(self, _):
if not self.connected_peer: if not self.connected_peer:
self.show_error('not connected') self.show_error('not connected')
@@ -701,7 +888,7 @@ class ConsoleApp:
self.show_error('not connected') self.show_error('not connected')
return return
characteristic = self.find_characteristic(params[0]) characteristic = self.find_remote_characteristic(params[0])
if characteristic is None: if characteristic is None:
self.show_error('no such characteristic') self.show_error('no such characteristic')
return return
@@ -726,15 +913,43 @@ class ConsoleApp:
except ValueError: except ValueError:
value = str.encode(params[1]) # must be a string value = str.encode(params[1]) # must be a string
characteristic = self.find_characteristic(params[0]) characteristic = self.find_remote_characteristic(params[0])
if characteristic is None: if characteristic is None:
self.show_error('no such characteristic') self.show_error('no such characteristic')
return return
# use write with response if supported # use write with response if supported
with_response = characteristic.properties & Characteristic.WRITE with_response = characteristic.properties & Characteristic.Properties.WRITE
await characteristic.write_value(value, with_response=with_response) await characteristic.write_value(value, with_response=with_response)
async def do_local_write(self, params):
if len(params) != 2:
self.show_error(
'invalid syntax', 'expected local-write <attribute> <value>'
)
return
if params[1].upper().startswith("0X"):
value = bytes.fromhex(params[1][2:]) # parse as hex string
else:
try:
value = int(params[1]).to_bytes(2, "little") # try as 2 byte integer
except ValueError:
value = str.encode(params[1]) # must be a string
attribute = self.find_local_attribute(params[0])
if not attribute:
self.show_error('invalid syntax', 'unable to find attribute')
return
# send data to any subscribers
if isinstance(attribute, Characteristic):
attribute.write_value(None, value)
if attribute.has_properties(Characteristic.NOTIFY):
await self.device.gatt_server.notify_subscribers(attribute)
if attribute.has_properties(Characteristic.INDICATE):
await self.device.gatt_server.indicate_subscribers(attribute)
async def do_subscribe(self, params): async def do_subscribe(self, params):
if not self.connected_peer: if not self.connected_peer:
self.show_error('not connected') self.show_error('not connected')
@@ -744,7 +959,7 @@ class ConsoleApp:
self.show_error('invalid syntax', 'expected subscribe <attribute>') self.show_error('invalid syntax', 'expected subscribe <attribute>')
return return
characteristic = self.find_characteristic(params[0]) characteristic = self.find_remote_characteristic(params[0])
if characteristic is None: if characteristic is None:
self.show_error('no such characteristic') self.show_error('no such characteristic')
return return
@@ -764,7 +979,7 @@ class ConsoleApp:
self.show_error('invalid syntax', 'expected subscribe <attribute>') self.show_error('invalid syntax', 'expected subscribe <attribute>')
return return
characteristic = self.find_characteristic(params[0]) characteristic = self.find_remote_characteristic(params[0])
if characteristic is None: if characteristic is None:
self.show_error('no such characteristic') self.show_error('no such characteristic')
return return
+17 -6
View File
@@ -19,9 +19,9 @@ import asyncio
import os import os
import logging import logging
import click import click
from colors import color
from bumble.company_ids import COMPANY_IDENTIFIERS from bumble.company_ids import COMPANY_IDENTIFIERS
from bumble.colors import color
from bumble.core import name_or_number from bumble.core import name_or_number
from bumble.hci import ( from bumble.hci import (
map_null_terminated_utf8_string, map_null_terminated_utf8_string,
@@ -30,6 +30,8 @@ from bumble.hci import (
HCI_VERSION_NAMES, HCI_VERSION_NAMES,
LMP_VERSION_NAMES, LMP_VERSION_NAMES,
HCI_Command, HCI_Command,
HCI_Command_Complete_Event,
HCI_Command_Status_Event,
HCI_READ_BD_ADDR_COMMAND, HCI_READ_BD_ADDR_COMMAND,
HCI_Read_BD_ADDR_Command, HCI_Read_BD_ADDR_Command,
HCI_READ_LOCAL_NAME_COMMAND, HCI_READ_LOCAL_NAME_COMMAND,
@@ -45,11 +47,20 @@ from bumble.host import Host
from bumble.transport import open_transport_or_link from bumble.transport import open_transport_or_link
# -----------------------------------------------------------------------------
def command_succeeded(response):
if isinstance(response, HCI_Command_Status_Event):
return response.status == HCI_SUCCESS
if isinstance(response, HCI_Command_Complete_Event):
return response.return_parameters.status == HCI_SUCCESS
return False
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
async def get_classic_info(host): async def get_classic_info(host):
if host.supports_command(HCI_READ_BD_ADDR_COMMAND): if host.supports_command(HCI_READ_BD_ADDR_COMMAND):
response = await host.send_command(HCI_Read_BD_ADDR_Command()) response = await host.send_command(HCI_Read_BD_ADDR_Command())
if response.return_parameters.status == HCI_SUCCESS: if command_succeeded(response):
print() print()
print( print(
color('Classic Address:', 'yellow'), response.return_parameters.bd_addr color('Classic Address:', 'yellow'), response.return_parameters.bd_addr
@@ -57,7 +68,7 @@ async def get_classic_info(host):
if host.supports_command(HCI_READ_LOCAL_NAME_COMMAND): if host.supports_command(HCI_READ_LOCAL_NAME_COMMAND):
response = await host.send_command(HCI_Read_Local_Name_Command()) response = await host.send_command(HCI_Read_Local_Name_Command())
if response.return_parameters.status == HCI_SUCCESS: if command_succeeded(response):
print() print()
print( print(
color('Local Name:', 'yellow'), color('Local Name:', 'yellow'),
@@ -73,7 +84,7 @@ async def get_le_info(host):
response = await host.send_command( response = await host.send_command(
HCI_LE_Read_Number_Of_Supported_Advertising_Sets_Command() HCI_LE_Read_Number_Of_Supported_Advertising_Sets_Command()
) )
if response.return_parameters.status == HCI_SUCCESS: if command_succeeded(response):
print( print(
color('LE Number Of Supported Advertising Sets:', 'yellow'), color('LE Number Of Supported Advertising Sets:', 'yellow'),
response.return_parameters.num_supported_advertising_sets, response.return_parameters.num_supported_advertising_sets,
@@ -84,7 +95,7 @@ async def get_le_info(host):
response = await host.send_command( response = await host.send_command(
HCI_LE_Read_Maximum_Advertising_Data_Length_Command() HCI_LE_Read_Maximum_Advertising_Data_Length_Command()
) )
if response.return_parameters.status == HCI_SUCCESS: if command_succeeded(response):
print( print(
color('LE Maximum Advertising Data Length:', 'yellow'), color('LE Maximum Advertising Data Length:', 'yellow'),
response.return_parameters.max_advertising_data_length, response.return_parameters.max_advertising_data_length,
@@ -93,7 +104,7 @@ async def get_le_info(host):
if host.supports_command(HCI_LE_READ_MAXIMUM_DATA_LENGTH_COMMAND): if host.supports_command(HCI_LE_READ_MAXIMUM_DATA_LENGTH_COMMAND):
response = await host.send_command(HCI_LE_Read_Maximum_Data_Length_Command()) response = await host.send_command(HCI_LE_Read_Maximum_Data_Length_Command())
if response.return_parameters.status == HCI_SUCCESS: if command_succeeded(response):
print( print(
color('Maximum Data Length:', 'yellow'), color('Maximum Data Length:', 'yellow'),
( (
+1 -1
View File
@@ -19,9 +19,9 @@ import asyncio
import os import os
import logging import logging
import click import click
from colors import color
import bumble.core import bumble.core
from bumble.colors import color
from bumble.device import Device, Peer from bumble.device import Device, Peer
from bumble.gatt import show_services from bumble.gatt import show_services
from bumble.transport import open_transport_or_link from bumble.transport import open_transport_or_link
+4 -5
View File
@@ -20,8 +20,8 @@ import os
import struct import struct
import logging import logging
import click import click
from colors import color
from bumble.colors import color
from bumble.device import Device, Peer from bumble.device import Device, Peer
from bumble.core import AdvertisingData from bumble.core import AdvertisingData
from bumble.gatt import Service, Characteristic, CharacteristicValue from bumble.gatt import Service, Characteristic, CharacteristicValue
@@ -230,13 +230,13 @@ class GattlinkNodeBridge(GattlinkL2capEndpoint, Device.Listener):
) )
self.tx_characteristic = Characteristic( self.tx_characteristic = Characteristic(
GG_GATTLINK_TX_CHARACTERISTIC_UUID, GG_GATTLINK_TX_CHARACTERISTIC_UUID,
Characteristic.NOTIFY, Characteristic.Properties.NOTIFY,
Characteristic.READABLE, Characteristic.READABLE,
) )
self.tx_characteristic.on('subscription', self.on_tx_subscription) self.tx_characteristic.on('subscription', self.on_tx_subscription)
self.psm_characteristic = Characteristic( self.psm_characteristic = Characteristic(
GG_GATTLINK_L2CAP_CHANNEL_PSM_CHARACTERISTIC_UUID, GG_GATTLINK_L2CAP_CHANNEL_PSM_CHARACTERISTIC_UUID,
Characteristic.READ | Characteristic.NOTIFY, Characteristic.Properties.READ | Characteristic.Properties.NOTIFY,
Characteristic.READABLE, Characteristic.READABLE,
bytes([psm, 0]), bytes([psm, 0]),
) )
@@ -339,8 +339,7 @@ async def run(
# Create a UDP to TX bridge (receive from TX, send to UDP) # Create a UDP to TX bridge (receive from TX, send to UDP)
bridge.tx_socket, _ = await loop.create_datagram_endpoint( bridge.tx_socket, _ = await loop.create_datagram_endpoint(
# pylint: disable-next=unnecessary-lambda asyncio.DatagramProtocol,
lambda: asyncio.DatagramProtocol(),
remote_addr=(send_host, send_port), remote_addr=(send_host, send_port),
) )
+1 -1
View File
@@ -19,8 +19,8 @@ import asyncio
import logging import logging
import os import os
import click import click
from colors import color
from bumble.colors import color
from bumble.transport import open_transport_or_link from bumble.transport import open_transport_or_link
from bumble.device import Device from bumble.device import Device
from bumble.utils import FlowControlAsyncPipe from bumble.utils import FlowControlAsyncPipe
+2 -1
View File
@@ -23,9 +23,10 @@ import argparse
import uuid import uuid
import os import os
from urllib.parse import urlparse from urllib.parse import urlparse
from colors import color
import websockets import websockets
from bumble.colors import color
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
# Logging # Logging
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
+99 -47
View File
@@ -19,12 +19,12 @@ import asyncio
import os import os
import logging import logging
import click import click
import aioconsole from prompt_toolkit.shortcuts import PromptSession
from colors import color
from bumble.colors import color
from bumble.device import Device, Peer from bumble.device import Device, Peer
from bumble.transport import open_transport_or_link from bumble.transport import open_transport_or_link
from bumble.smp import PairingDelegate, PairingConfig from bumble.pairing import PairingDelegate, PairingConfig
from bumble.smp import error_name as smp_error_name from bumble.smp import error_name as smp_error_name
from bumble.keys import JsonKeyStore from bumble.keys import JsonKeyStore
from bumble.core import ProtocolError from bumble.core import ProtocolError
@@ -42,9 +42,23 @@ from bumble.att import (
) )
# -----------------------------------------------------------------------------
class Waiter:
instance = None
def __init__(self):
self.done = asyncio.get_running_loop().create_future()
def terminate(self):
self.done.set_result(None)
async def wait_until_terminated(self):
return await self.done
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
class Delegate(PairingDelegate): class Delegate(PairingDelegate):
def __init__(self, mode, connection, capability_string, prompt): def __init__(self, mode, connection, capability_string, do_prompt):
super().__init__( super().__init__(
{ {
'keyboard': PairingDelegate.KEYBOARD_INPUT_ONLY, 'keyboard': PairingDelegate.KEYBOARD_INPUT_ONLY,
@@ -58,7 +72,18 @@ class Delegate(PairingDelegate):
self.mode = mode self.mode = mode
self.peer = Peer(connection) self.peer = Peer(connection)
self.peer_name = None self.peer_name = None
self.prompt = prompt self.do_prompt = do_prompt
def print(self, message):
print(color(message, 'yellow'))
async def prompt(self, message):
# Wait a bit to allow some of the log lines to print before we prompt
await asyncio.sleep(1)
session = PromptSession(message)
response = await session.prompt_async()
return response.lower().strip()
async def update_peer_name(self): async def update_peer_name(self):
if self.peer_name is not None: if self.peer_name is not None:
@@ -73,19 +98,15 @@ class Delegate(PairingDelegate):
self.peer_name = '[?]' self.peer_name = '[?]'
async def accept(self): async def accept(self):
if self.prompt: if self.do_prompt:
await self.update_peer_name() await self.update_peer_name()
# Wait a bit to allow some of the log lines to print before we prompt
await asyncio.sleep(1)
# Prompt for acceptance # Prompt for acceptance
print(color('###-----------------------------------', 'yellow')) self.print('###-----------------------------------')
print(color(f'### Pairing request from {self.peer_name}', 'yellow')) self.print(f'### Pairing request from {self.peer_name}')
print(color('###-----------------------------------', 'yellow')) self.print('###-----------------------------------')
while True: while True:
response = await aioconsole.ainput(color('>>> Accept? ', 'yellow')) response = await self.prompt('>>> Accept? ')
response = response.lower().strip()
if response == 'yes': if response == 'yes':
return True return True
@@ -96,23 +117,17 @@ class Delegate(PairingDelegate):
# Accept silently # Accept silently
return True return True
async def compare_numbers(self, number, digits=6): async def compare_numbers(self, number, digits):
await self.update_peer_name() await self.update_peer_name()
# Wait a bit to allow some of the log lines to print before we prompt
await asyncio.sleep(1)
# Prompt for a numeric comparison # Prompt for a numeric comparison
print(color('###-----------------------------------', 'yellow')) self.print('###-----------------------------------')
print(color(f'### Pairing with {self.peer_name}', 'yellow')) self.print(f'### Pairing with {self.peer_name}')
print(color('###-----------------------------------', 'yellow')) self.print('###-----------------------------------')
while True: while True:
response = await aioconsole.ainput( response = await self.prompt(
color( f'>>> Does the other device display {number:0{digits}}? '
f'>>> Does the other device display {number:0{digits}}? ', 'yellow'
)
) )
response = response.lower().strip()
if response == 'yes': if response == 'yes':
return True return True
@@ -123,30 +138,24 @@ class Delegate(PairingDelegate):
async def get_number(self): async def get_number(self):
await self.update_peer_name() await self.update_peer_name()
# Wait a bit to allow some of the log lines to print before we prompt
await asyncio.sleep(1)
# Prompt for a PIN # Prompt for a PIN
while True: while True:
try: try:
print(color('###-----------------------------------', 'yellow')) self.print('###-----------------------------------')
print(color(f'### Pairing with {self.peer_name}', 'yellow')) self.print(f'### Pairing with {self.peer_name}')
print(color('###-----------------------------------', 'yellow')) self.print('###-----------------------------------')
return int(await aioconsole.ainput(color('>>> Enter PIN: ', 'yellow'))) return int(await self.prompt('>>> Enter PIN: '))
except ValueError: except ValueError:
pass pass
async def display_number(self, number, digits=6): async def display_number(self, number, digits):
await self.update_peer_name() await self.update_peer_name()
# Wait a bit to allow some of the log lines to print before we prompt
await asyncio.sleep(1)
# Display a PIN code # Display a PIN code
print(color('###-----------------------------------', 'yellow')) self.print('###-----------------------------------')
print(color(f'### Pairing with {self.peer_name}', 'yellow')) self.print(f'### Pairing with {self.peer_name}')
print(color(f'### PIN: {number:0{digits}}', 'yellow')) self.print(f'### PIN: {number:0{digits}}')
print(color('###-----------------------------------', 'yellow')) self.print('###-----------------------------------')
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
@@ -238,6 +247,7 @@ def on_pairing(keys):
print(color('*** Paired!', 'cyan')) print(color('*** Paired!', 'cyan'))
keys.print(prefix=color('*** ', 'cyan')) keys.print(prefix=color('*** ', 'cyan'))
print(color('***-----------------------------------', 'cyan')) print(color('***-----------------------------------', 'cyan'))
Waiter.instance.terminate()
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
@@ -245,6 +255,7 @@ def on_pairing_failure(reason):
print(color('***-----------------------------------', 'red')) print(color('***-----------------------------------', 'red'))
print(color(f'*** Pairing failed: {smp_error_name(reason)}', 'red')) print(color(f'*** Pairing failed: {smp_error_name(reason)}', 'red'))
print(color('***-----------------------------------', 'red')) print(color('***-----------------------------------', 'red'))
Waiter.instance.terminate()
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
@@ -253,6 +264,7 @@ async def pair(
sc, sc,
mitm, mitm,
bond, bond,
ctkd,
io, io,
prompt, prompt,
request, request,
@@ -262,6 +274,8 @@ async def pair(
hci_transport, hci_transport,
address_or_name, address_or_name,
): ):
Waiter.instance = Waiter()
print('<<< connecting to HCI...') print('<<< connecting to HCI...')
async with await open_transport_or_link(hci_transport) as (hci_source, hci_sink): async with await open_transport_or_link(hci_transport) as (hci_source, hci_sink):
print('<<< connected') print('<<< connected')
@@ -289,7 +303,8 @@ async def pair(
[ [
Characteristic( Characteristic(
'552957FB-CF1F-4A31-9535-E78847E1A714', '552957FB-CF1F-4A31-9535-E78847E1A714',
Characteristic.READ | Characteristic.WRITE, Characteristic.Properties.READ
| Characteristic.Properties.WRITE,
Characteristic.READABLE | Characteristic.WRITEABLE, Characteristic.READABLE | Characteristic.WRITEABLE,
CharacteristicValue( CharacteristicValue(
read=read_with_error, write=write_with_error read=read_with_error, write=write_with_error
@@ -303,6 +318,7 @@ async def pair(
if mode == 'classic': if mode == 'classic':
device.classic_enabled = True device.classic_enabled = True
device.le_enabled = False device.le_enabled = False
device.classic_smp_enabled = ctkd
# Get things going # Get things going
await device.power_on() await device.power_on()
@@ -329,10 +345,27 @@ async def pair(
print(color(f'Pairing failed: {error}', 'red')) print(color(f'Pairing failed: {error}', 'red'))
return return
else: else:
# Advertise so that peers can find us and connect if mode == 'le':
await device.start_advertising(auto_restart=True) # Advertise so that peers can find us and connect
await device.start_advertising(auto_restart=True)
else:
# Become discoverable and connectable
await device.set_discoverable(True)
await device.set_connectable(True)
await hci_source.wait_for_termination() # Run until the user asks to exit
await Waiter.instance.wait_until_terminated()
# -----------------------------------------------------------------------------
class LogHandler(logging.Handler):
def __init__(self):
super().__init__()
self.setFormatter(logging.Formatter('%(levelname)s:%(name)s:%(message)s'))
def emit(self, record):
message = self.format(record)
print(message)
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
@@ -353,6 +386,13 @@ async def pair(
@click.option( @click.option(
'--bond', type=bool, default=True, help='Enable bonding', show_default=True '--bond', type=bool, default=True, help='Enable bonding', show_default=True
) )
@click.option(
'--ctkd',
type=bool,
default=True,
help='Enable CTKD',
show_default=True,
)
@click.option( @click.option(
'--io', '--io',
type=click.Choice( type=click.Choice(
@@ -366,7 +406,11 @@ async def pair(
'--request', is_flag=True, help='Request that the connecting peer initiate pairing' '--request', is_flag=True, help='Request that the connecting peer initiate pairing'
) )
@click.option('--print-keys', is_flag=True, help='Print the bond keys before pairing') @click.option('--print-keys', is_flag=True, help='Print the bond keys before pairing')
@click.option('--keystore-file', help='File in which to store the pairing keys') @click.option(
'--keystore-file',
metavar='<filename>',
help='File in which to store the pairing keys',
)
@click.argument('device-config') @click.argument('device-config')
@click.argument('hci_transport') @click.argument('hci_transport')
@click.argument('address-or-name', required=False) @click.argument('address-or-name', required=False)
@@ -375,6 +419,7 @@ def main(
sc, sc,
mitm, mitm,
bond, bond,
ctkd,
io, io,
prompt, prompt,
request, request,
@@ -384,13 +429,20 @@ def main(
hci_transport, hci_transport,
address_or_name, address_or_name,
): ):
logging.basicConfig(level=os.environ.get('BUMBLE_LOGLEVEL', 'INFO').upper()) # Setup logging
log_handler = LogHandler()
root_logger = logging.getLogger()
root_logger.addHandler(log_handler)
root_logger.setLevel(os.environ.get('BUMBLE_LOGLEVEL', 'INFO').upper())
# Pair
asyncio.run( asyncio.run(
pair( pair(
mode, mode,
sc, sc,
mitm, mitm,
bond, bond,
ctkd,
io, io,
prompt, prompt,
request, request,
+1 -1
View File
@@ -19,8 +19,8 @@ import asyncio
import os import os
import logging import logging
import click import click
from colors import color
from bumble.colors import color
from bumble.device import Device from bumble.device import Device
from bumble.transport import open_transport_or_link from bumble.transport import open_transport_or_link
from bumble.keys import JsonKeyStore from bumble.keys import JsonKeyStore
+1 -1
View File
@@ -17,8 +17,8 @@
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
import struct import struct
import click import click
from colors import color
from bumble.colors import color
from bumble import hci from bumble import hci
from bumble.transport.common import PacketReader from bumble.transport.common import PacketReader
from bumble.helpers import PacketTracer from bumble.helpers import PacketTracer
+1 -1
View File
@@ -30,8 +30,8 @@ import os
import logging import logging
import click import click
import usb1 import usb1
from colors import color
from bumble.colors import color
from bumble.transport.usb import load_libusb from bumble.transport.usb import load_libusb
+47 -12
View File
@@ -18,7 +18,6 @@
import struct import struct
import logging import logging
from collections import namedtuple from collections import namedtuple
import bitstruct
from .company_ids import COMPANY_IDENTIFIERS from .company_ids import COMPANY_IDENTIFIERS
from .sdp import ( from .sdp import (
@@ -258,7 +257,6 @@ class SbcMediaCodecInformation(
A2DP spec - 4.3.2 Codec Specific Information Elements A2DP spec - 4.3.2 Codec Specific Information Elements
''' '''
BIT_FIELDS = 'u4u4u4u2u2u8u8'
SAMPLING_FREQUENCY_BITS = {16000: 1 << 3, 32000: 1 << 2, 44100: 1 << 1, 48000: 1} SAMPLING_FREQUENCY_BITS = {16000: 1 << 3, 32000: 1 << 2, 44100: 1 << 1, 48000: 1}
CHANNEL_MODE_BITS = { CHANNEL_MODE_BITS = {
SBC_MONO_CHANNEL_MODE: 1 << 3, SBC_MONO_CHANNEL_MODE: 1 << 3,
@@ -274,9 +272,22 @@ class SbcMediaCodecInformation(
} }
@staticmethod @staticmethod
def from_bytes(data): def from_bytes(data: bytes) -> 'SbcMediaCodecInformation':
sampling_frequency = (data[0] >> 4) & 0x0F
channel_mode = (data[0] >> 0) & 0x0F
block_length = (data[1] >> 4) & 0x0F
subbands = (data[1] >> 2) & 0x03
allocation_method = (data[1] >> 0) & 0x03
minimum_bitpool_value = (data[2] >> 0) & 0xFF
maximum_bitpool_value = (data[3] >> 0) & 0xFF
return SbcMediaCodecInformation( return SbcMediaCodecInformation(
*bitstruct.unpack(SbcMediaCodecInformation.BIT_FIELDS, data) sampling_frequency,
channel_mode,
block_length,
subbands,
allocation_method,
minimum_bitpool_value,
maximum_bitpool_value,
) )
@classmethod @classmethod
@@ -325,8 +336,17 @@ class SbcMediaCodecInformation(
maximum_bitpool_value=maximum_bitpool_value, maximum_bitpool_value=maximum_bitpool_value,
) )
def __bytes__(self): def __bytes__(self) -> bytes:
return bitstruct.pack(self.BIT_FIELDS, *self) return bytes(
[
(self.sampling_frequency << 4) | self.channel_mode,
(self.block_length << 4)
| (self.subbands << 2)
| self.allocation_method,
self.minimum_bitpool_value,
self.maximum_bitpool_value,
]
)
def __str__(self): def __str__(self):
channel_modes = ['MONO', 'DUAL_CHANNEL', 'STEREO', 'JOINT_STEREO'] channel_modes = ['MONO', 'DUAL_CHANNEL', 'STEREO', 'JOINT_STEREO']
@@ -350,14 +370,13 @@ class SbcMediaCodecInformation(
class AacMediaCodecInformation( class AacMediaCodecInformation(
namedtuple( namedtuple(
'AacMediaCodecInformation', 'AacMediaCodecInformation',
['object_type', 'sampling_frequency', 'channels', 'vbr', 'bitrate'], ['object_type', 'sampling_frequency', 'channels', 'rfa', 'vbr', 'bitrate'],
) )
): ):
''' '''
A2DP spec - 4.5.2 Codec Specific Information Elements A2DP spec - 4.5.2 Codec Specific Information Elements
''' '''
BIT_FIELDS = 'u8u12u2p2u1u23'
OBJECT_TYPE_BITS = { OBJECT_TYPE_BITS = {
MPEG_2_AAC_LC_OBJECT_TYPE: 1 << 7, MPEG_2_AAC_LC_OBJECT_TYPE: 1 << 7,
MPEG_4_AAC_LC_OBJECT_TYPE: 1 << 6, MPEG_4_AAC_LC_OBJECT_TYPE: 1 << 6,
@@ -381,9 +400,15 @@ class AacMediaCodecInformation(
CHANNELS_BITS = {1: 1 << 1, 2: 1} CHANNELS_BITS = {1: 1 << 1, 2: 1}
@staticmethod @staticmethod
def from_bytes(data): def from_bytes(data: bytes) -> 'AacMediaCodecInformation':
object_type = data[0]
sampling_frequency = (data[1] << 4) | ((data[2] >> 4) & 0x0F)
channels = (data[2] >> 2) & 0x03
rfa = 0
vbr = (data[3] >> 7) & 0x01
bitrate = ((data[3] & 0x7F) << 16) | (data[4] << 8) | data[5]
return AacMediaCodecInformation( return AacMediaCodecInformation(
*bitstruct.unpack(AacMediaCodecInformation.BIT_FIELDS, data) object_type, sampling_frequency, channels, rfa, vbr, bitrate
) )
@classmethod @classmethod
@@ -394,6 +419,7 @@ class AacMediaCodecInformation(
object_type=cls.OBJECT_TYPE_BITS[object_type], object_type=cls.OBJECT_TYPE_BITS[object_type],
sampling_frequency=cls.SAMPLING_FREQUENCY_BITS[sampling_frequency], sampling_frequency=cls.SAMPLING_FREQUENCY_BITS[sampling_frequency],
channels=cls.CHANNELS_BITS[channels], channels=cls.CHANNELS_BITS[channels],
rfa=0,
vbr=vbr, vbr=vbr,
bitrate=bitrate, bitrate=bitrate,
) )
@@ -410,8 +436,17 @@ class AacMediaCodecInformation(
bitrate=bitrate, bitrate=bitrate,
) )
def __bytes__(self): def __bytes__(self) -> bytes:
return bitstruct.pack(self.BIT_FIELDS, *self) return bytes(
[
self.object_type & 0xFF,
(self.sampling_frequency >> 4) & 0xFF,
(((self.sampling_frequency & 0x0F) << 4) | (self.channels << 2)) & 0xFF,
((self.vbr << 7) | ((self.bitrate >> 16) & 0x7F)) & 0xFF,
((self.bitrate >> 8) & 0xFF) & 0xFF,
self.bitrate & 0xFF,
]
)
def __str__(self): def __str__(self):
object_types = [ object_types = [
+84 -11
View File
@@ -22,13 +22,18 @@
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
# Imports # Imports
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
from __future__ import annotations
import functools
import struct import struct
from colors import color
from pyee import EventEmitter from pyee import EventEmitter
from typing import Dict, Type, TYPE_CHECKING
from bumble.core import UUID, name_or_number from bumble.core import UUID, name_or_number, get_dict_key_by_value, ProtocolError
from bumble.hci import HCI_Object, key_with_value from bumble.hci import HCI_Object, key_with_value, HCI_Constant
from bumble.colors import color
if TYPE_CHECKING:
from bumble.device import Connection
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
# Constants # Constants
@@ -180,13 +185,18 @@ UUID_2_FIELD_SPEC = lambda x, y: UUID.parse_uuid_2(x, y) # noqa: E731
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
# Exceptions # Exceptions
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
class ATT_Error(Exception): class ATT_Error(ProtocolError):
def __init__(self, error_code, att_handle=0x0000): def __init__(self, error_code, att_handle=0x0000, message=''):
self.error_code = error_code super().__init__(
error_code,
error_namespace='att',
error_name=ATT_PDU.error_name(error_code),
)
self.att_handle = att_handle self.att_handle = att_handle
self.message = message
def __str__(self): def __str__(self):
return f'ATT_Error({ATT_PDU.error_name(self.error_code)})' return f'ATT_Error(error={self.error_name}, handle={self.att_handle:04X}): {self.message}'
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
@@ -197,7 +207,7 @@ class ATT_PDU:
See Bluetooth spec @ Vol 3, Part F - 3.3 ATTRIBUTE PDU See Bluetooth spec @ Vol 3, Part F - 3.3 ATTRIBUTE PDU
''' '''
pdu_classes = {} pdu_classes: Dict[int, Type[ATT_PDU]] = {}
op_code = 0 op_code = 0
name = None name = None
@@ -721,11 +731,38 @@ class Attribute(EventEmitter):
READ_REQUIRES_AUTHORIZATION = 0x40 READ_REQUIRES_AUTHORIZATION = 0x40
WRITE_REQUIRES_AUTHORIZATION = 0x80 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',
}
@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
def __init__(self, attribute_type, permissions, value=b''): def __init__(self, attribute_type, permissions, value=b''):
EventEmitter.__init__(self) EventEmitter.__init__(self)
self.handle = 0 self.handle = 0
self.end_group_handle = 0 self.end_group_handle = 0
self.permissions = permissions if isinstance(permissions, str):
self.permissions = self.string_to_permissions(permissions)
else:
self.permissions = permissions
# Convert the type to a UUID object if it isn't already # Convert the type to a UUID object if it isn't already
if isinstance(attribute_type, str): if isinstance(attribute_type, str):
@@ -747,7 +784,25 @@ class Attribute(EventEmitter):
def decode_value(self, value_bytes): def decode_value(self, value_bytes):
return value_bytes return value_bytes
def read_value(self, connection): def read_value(self, connection: Connection):
if (
self.permissions & self.READ_REQUIRES_ENCRYPTION
) 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:
raise ATT_Error(
error_code=ATT_INSUFFICIENT_AUTHENTICATION_ERROR, att_handle=self.handle
)
if self.permissions & self.READ_REQUIRES_AUTHORIZATION:
# TODO: handle authorization better
raise ATT_Error(
error_code=ATT_INSUFFICIENT_AUTHORIZATION_ERROR, att_handle=self.handle
)
if read := getattr(self.value, 'read', None): if read := getattr(self.value, 'read', None):
try: try:
value = read(connection) # pylint: disable=not-callable value = read(connection) # pylint: disable=not-callable
@@ -760,7 +815,25 @@ class Attribute(EventEmitter):
return self.encode_value(value) return self.encode_value(value)
def write_value(self, connection, value_bytes): def write_value(self, connection: Connection, value_bytes):
if (
self.permissions & self.WRITE_REQUIRES_ENCRYPTION
) and not connection.encryption:
raise ATT_Error(
error_code=ATT_INSUFFICIENT_ENCRYPTION_ERROR, att_handle=self.handle
)
if (
self.permissions & self.WRITE_REQUIRES_AUTHENTICATION
) and not connection.authenticated:
raise ATT_Error(
error_code=ATT_INSUFFICIENT_AUTHENTICATION_ERROR, att_handle=self.handle
)
if self.permissions & self.WRITE_REQUIRES_AUTHORIZATION:
# TODO: handle authorization better
raise ATT_Error(
error_code=ATT_INSUFFICIENT_AUTHORIZATION_ERROR, att_handle=self.handle
)
value = self.decode_value(value_bytes) value = self.decode_value(value_bytes)
if write := getattr(self.value, 'write', None): if write := getattr(self.value, 'write', None):
+5 -2
View File
@@ -15,12 +15,13 @@
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
# Imports # Imports
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
from __future__ import annotations
import asyncio import asyncio
import struct import struct
import time import time
import logging import logging
from colors import color
from pyee import EventEmitter from pyee import EventEmitter
from typing import Dict, Type
from .core import ( from .core import (
BT_ADVANCED_AUDIO_DISTRIBUTION_SERVICE, BT_ADVANCED_AUDIO_DISTRIBUTION_SERVICE,
@@ -38,6 +39,7 @@ from .a2dp import (
VendorSpecificMediaCodecInformation, VendorSpecificMediaCodecInformation,
) )
from . import sdp from . import sdp
from .colors import color
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
# Logging # Logging
@@ -627,7 +629,8 @@ class Message: # pylint:disable=attribute-defined-outside-init
RESPONSE_REJECT: 'RESPONSE_REJECT', RESPONSE_REJECT: 'RESPONSE_REJECT',
} }
subclasses = {} # Subclasses, by signal identifier and message type # Subclasses, by signal identifier and message type
subclasses: Dict[int, Dict[int, Type[Message]]] = {}
@staticmethod @staticmethod
def message_type_name(message_type): def message_type_name(message_type):
+103
View File
@@ -0,0 +1,103 @@
# Copyright (c) 2012 Giorgos Verigakis <verigak@gmail.com>
#
# Permission to use, copy, modify, and distribute this software for any
# purpose with or without fee is hereby granted, provided that the above
# copyright notice and this permission notice appear in all copies.
#
# THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
# WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
# MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
# ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
# WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
# ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
# OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
from functools import partial
from typing import List, Optional, Union
# ANSI color names. There is also a "default"
COLORS = ('black', 'red', 'green', 'yellow', 'blue', 'magenta', 'cyan', 'white')
# ANSI style names
STYLES = (
'none',
'bold',
'faint',
'italic',
'underline',
'blink',
'blink2',
'negative',
'concealed',
'crossed',
)
ColorSpec = Union[str, int]
def _join(*values: ColorSpec) -> str:
return ';'.join(str(v) for v in values)
def _color_code(spec: ColorSpec, base: int) -> str:
if isinstance(spec, str):
spec = spec.strip().lower()
if spec == 'default':
return _join(base + 9)
elif spec in COLORS:
return _join(base + COLORS.index(spec))
elif isinstance(spec, int) and 0 <= spec <= 255:
return _join(base + 8, 5, spec)
else:
raise ValueError('Invalid color spec "%s"' % spec)
def color(
s: str,
fg: Optional[ColorSpec] = None,
bg: Optional[ColorSpec] = None,
style: Optional[str] = None,
) -> str:
codes: List[ColorSpec] = []
if fg:
codes.append(_color_code(fg, 30))
if bg:
codes.append(_color_code(bg, 40))
if style:
for style_part in style.split('+'):
if style_part in STYLES:
codes.append(STYLES.index(style_part))
else:
raise ValueError('Invalid style "%s"' % style_part)
if codes:
return '\x1b[{0}m{1}\x1b[0m'.format(_join(*codes), s)
else:
return s
# Foreground color shortcuts
black = partial(color, fg='black')
red = partial(color, fg='red')
green = partial(color, fg='green')
yellow = partial(color, fg='yellow')
blue = partial(color, fg='blue')
magenta = partial(color, fg='magenta')
cyan = partial(color, fg='cyan')
white = partial(color, fg='white')
# Style shortcuts
bold = partial(color, style='bold')
none = partial(color, style='none')
faint = partial(color, style='faint')
italic = partial(color, style='italic')
underline = partial(color, style='underline')
blink = partial(color, style='blink')
blink2 = partial(color, style='blink2')
negative = partial(color, style='negative')
concealed = partial(color, style='concealed')
crossed = partial(color, style='crossed')
+251 -42
View File
@@ -20,8 +20,13 @@ import asyncio
import itertools import itertools
import random import random
import struct import struct
from colors import color from bumble.colors import color
from bumble.core import BT_CENTRAL_ROLE, BT_PERIPHERAL_ROLE from bumble.core import (
BT_CENTRAL_ROLE,
BT_PERIPHERAL_ROLE,
BT_LE_TRANSPORT,
BT_BR_EDR_TRANSPORT,
)
from bumble.hci import ( from bumble.hci import (
HCI_ACL_DATA_PACKET, HCI_ACL_DATA_PACKET,
@@ -29,26 +34,31 @@ from bumble.hci import (
HCI_COMMAND_PACKET, HCI_COMMAND_PACKET,
HCI_COMMAND_STATUS_PENDING, HCI_COMMAND_STATUS_PENDING,
HCI_CONNECTION_TIMEOUT_ERROR, HCI_CONNECTION_TIMEOUT_ERROR,
HCI_CONTROLLER_BUSY_ERROR,
HCI_EVENT_PACKET, HCI_EVENT_PACKET,
HCI_INVALID_HCI_COMMAND_PARAMETERS_ERROR, HCI_INVALID_HCI_COMMAND_PARAMETERS_ERROR,
HCI_LE_1M_PHY, HCI_LE_1M_PHY,
HCI_SUCCESS, HCI_SUCCESS,
HCI_UNKNOWN_HCI_COMMAND_ERROR, HCI_UNKNOWN_HCI_COMMAND_ERROR,
HCI_REMOTE_USER_TERMINATED_CONNECTION_ERROR,
HCI_VERSION_BLUETOOTH_CORE_5_0, HCI_VERSION_BLUETOOTH_CORE_5_0,
Address, Address,
HCI_AclDataPacket, HCI_AclDataPacket,
HCI_AclDataPacketAssembler, HCI_AclDataPacketAssembler,
HCI_Command_Complete_Event, HCI_Command_Complete_Event,
HCI_Command_Status_Event, HCI_Command_Status_Event,
HCI_Connection_Complete_Event,
HCI_Connection_Request_Event,
HCI_Disconnection_Complete_Event, HCI_Disconnection_Complete_Event,
HCI_Encryption_Change_Event, HCI_Encryption_Change_Event,
HCI_LE_Advertising_Report_Event, HCI_LE_Advertising_Report_Event,
HCI_LE_Connection_Complete_Event, HCI_LE_Connection_Complete_Event,
HCI_LE_Read_Remote_Features_Complete_Event, HCI_LE_Read_Remote_Features_Complete_Event,
HCI_Number_Of_Completed_Packets_Event, HCI_Number_Of_Completed_Packets_Event,
HCI_Object,
HCI_Packet, HCI_Packet,
HCI_Role_Change_Event,
) )
from typing import Optional, Union, Dict
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
@@ -66,13 +76,14 @@ class DataObject:
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
class Connection: class Connection:
def __init__(self, controller, handle, role, peer_address, link): def __init__(self, controller, handle, role, peer_address, link, transport):
self.controller = controller self.controller = controller
self.handle = handle self.handle = handle
self.role = role self.role = role
self.peer_address = peer_address self.peer_address = peer_address
self.link = link self.link = link
self.assembler = HCI_AclDataPacketAssembler(self.on_acl_pdu) 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):
self.assembler.feed_packet(packet) self.assembler.feed_packet(packet)
@@ -83,23 +94,33 @@ class Connection:
def on_acl_pdu(self, data): def on_acl_pdu(self, data):
if self.link: if self.link:
self.link.send_acl_data( self.link.send_acl_data(
self.controller.random_address, self.peer_address, data self.controller, self.peer_address, self.transport, data
) )
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
class Controller: class Controller:
def __init__(self, name, host_source=None, host_sink=None, link=None): def __init__(
self,
name,
host_source=None,
host_sink=None,
link=None,
public_address: Optional[Union[bytes, str, Address]] = None,
):
self.name = name self.name = name
self.hci_sink = None self.hci_sink = None
self.link = link self.link = link
self.central_connections = ( self.central_connections: Dict[
{} Address, Connection
) # Connections where this controller is the central ] = {} # Connections where this controller is the central
self.peripheral_connections = ( self.peripheral_connections: Dict[
{} Address, Connection
) # Connections where this controller is the peripheral ] = {} # Connections where this controller is the peripheral
self.classic_connections: Dict[
Address, Connection
] = {} # Connections in BR/EDR
self.hci_version = HCI_VERSION_BLUETOOTH_CORE_5_0 self.hci_version = HCI_VERSION_BLUETOOTH_CORE_5_0
self.hci_revision = 0 self.hci_revision = 0
@@ -149,7 +170,14 @@ class Controller:
self.advertising_timer_handle = None self.advertising_timer_handle = None
self._random_address = Address('00:00:00:00:00:00') self._random_address = Address('00:00:00:00:00:00')
self._public_address = None if isinstance(public_address, Address):
self._public_address = public_address
elif public_address is not None:
self._public_address = Address(
public_address, Address.PUBLIC_DEVICE_ADDRESS
)
else:
self._public_address = Address('00:00:00:00:00:00')
# Set the source and sink interfaces # Set the source and sink interfaces
if host_source: if host_source:
@@ -272,7 +300,9 @@ class Controller:
handle = 0 handle = 0
max_handle = 0 max_handle = 0
for connection in itertools.chain( for connection in itertools.chain(
self.central_connections.values(), self.peripheral_connections.values() self.central_connections.values(),
self.peripheral_connections.values(),
self.classic_connections.values(),
): ):
max_handle = max(max_handle, connection.handle) max_handle = max(max_handle, connection.handle)
if connection.handle == handle: if connection.handle == handle:
@@ -280,14 +310,19 @@ class Controller:
handle = max_handle + 1 handle = max_handle + 1
return handle return handle
def find_connection_by_address(self, address): def find_le_connection_by_address(self, address):
return self.central_connections.get(address) or self.peripheral_connections.get( return self.central_connections.get(address) or self.peripheral_connections.get(
address address
) )
def find_classic_connection_by_address(self, address):
return self.classic_connections.get(address)
def find_connection_by_handle(self, handle): def find_connection_by_handle(self, handle):
for connection in itertools.chain( for connection in itertools.chain(
self.central_connections.values(), self.peripheral_connections.values() self.central_connections.values(),
self.peripheral_connections.values(),
self.classic_connections.values(),
): ):
if connection.handle == handle: if connection.handle == handle:
return connection return connection
@@ -299,6 +334,12 @@ class Controller:
return connection return connection
return None return None
def find_classic_connection_by_handle(self, handle):
for connection in self.classic_connections.values():
if connection.handle == handle:
return connection
return None
def on_link_central_connected(self, central_address): def on_link_central_connected(self, central_address):
''' '''
Called when an incoming connection occurs from a central on the link Called when an incoming connection occurs from a central on the link
@@ -311,7 +352,12 @@ class Controller:
if connection is None: if connection is None:
connection_handle = self.allocate_connection_handle() connection_handle = self.allocate_connection_handle()
connection = Connection( connection = Connection(
self, connection_handle, BT_PERIPHERAL_ROLE, peer_address, self.link self,
connection_handle,
BT_PERIPHERAL_ROLE,
peer_address,
self.link,
BT_LE_TRANSPORT,
) )
self.peripheral_connections[peer_address] = connection self.peripheral_connections[peer_address] = connection
logger.debug(f'New PERIPHERAL connection handle: 0x{connection_handle:04X}') logger.debug(f'New PERIPHERAL connection handle: 0x{connection_handle:04X}')
@@ -365,7 +411,12 @@ class Controller:
if connection is None: if connection is None:
connection_handle = self.allocate_connection_handle() connection_handle = self.allocate_connection_handle()
connection = Connection( connection = Connection(
self, connection_handle, BT_CENTRAL_ROLE, peer_address, self.link self,
connection_handle,
BT_CENTRAL_ROLE,
peer_address,
self.link,
BT_LE_TRANSPORT,
) )
self.central_connections[peer_address] = connection self.central_connections[peer_address] = connection
logger.debug( logger.debug(
@@ -433,16 +484,19 @@ class Controller:
def on_link_encrypted(self, peer_address, _rand, _ediv, _ltk): def on_link_encrypted(self, peer_address, _rand, _ediv, _ltk):
# For now, just setup the encryption without asking the host # For now, just setup the encryption without asking the host
if connection := self.find_connection_by_address(peer_address): if connection := self.find_le_connection_by_address(peer_address):
self.send_hci_packet( self.send_hci_packet(
HCI_Encryption_Change_Event( HCI_Encryption_Change_Event(
status=0, connection_handle=connection.handle, encryption_enabled=1 status=0, connection_handle=connection.handle, encryption_enabled=1
) )
) )
def on_link_acl_data(self, sender_address, data): def on_link_acl_data(self, sender_address, transport, data):
# Look for the connection to which this data belongs # Look for the connection to which this data belongs
connection = self.find_connection_by_address(sender_address) if transport == BT_LE_TRANSPORT:
connection = self.find_le_connection_by_address(sender_address)
else:
connection = self.find_classic_connection_by_address(sender_address)
if connection is None: if connection is None:
logger.warning(f'!!! no connection for {sender_address}') logger.warning(f'!!! no connection for {sender_address}')
return return
@@ -458,8 +512,8 @@ class Controller:
return return
# Send a scan report # Send a scan report
report = HCI_Object( report = HCI_LE_Advertising_Report_Event.Report(
HCI_LE_Advertising_Report_Event.REPORT_FIELDS, HCI_LE_Advertising_Report_Event.Report.FIELDS,
event_type=HCI_LE_Advertising_Report_Event.ADV_IND, event_type=HCI_LE_Advertising_Report_Event.ADV_IND,
address_type=sender_address.address_type, address_type=sender_address.address_type,
address=sender_address, address=sender_address,
@@ -469,8 +523,8 @@ class Controller:
self.send_hci_packet(HCI_LE_Advertising_Report_Event([report])) self.send_hci_packet(HCI_LE_Advertising_Report_Event([report]))
# Simulate a scan response # Simulate a scan response
report = HCI_Object( report = HCI_LE_Advertising_Report_Event.Report(
HCI_LE_Advertising_Report_Event.REPORT_FIELDS, HCI_LE_Advertising_Report_Event.Report.FIELDS,
event_type=HCI_LE_Advertising_Report_Event.SCAN_RSP, event_type=HCI_LE_Advertising_Report_Event.SCAN_RSP,
address_type=sender_address.address_type, address_type=sender_address.address_type,
address=sender_address, address=sender_address,
@@ -479,6 +533,87 @@ class Controller:
) )
self.send_hci_packet(HCI_LE_Advertising_Report_Event([report])) self.send_hci_packet(HCI_LE_Advertising_Report_Event([report]))
############################################################
# Classic link connections
############################################################
def on_classic_connection_request(self, peer_address, link_type):
self.send_hci_packet(
HCI_Connection_Request_Event(
bd_addr=peer_address,
class_of_device=0,
link_type=link_type,
)
)
def on_classic_connection_complete(self, peer_address, status):
if status == HCI_SUCCESS:
# Allocate (or reuse) a connection handle
peer_address = peer_address
connection = self.classic_connections.get(peer_address)
if connection is None:
connection_handle = self.allocate_connection_handle()
connection = Connection(
controller=self,
handle=connection_handle,
# Role doesn't matter in Classic because they are managed by HCI_Role_Change and HCI_Role_Discovery
role=BT_CENTRAL_ROLE,
peer_address=peer_address,
link=self.link,
transport=BT_BR_EDR_TRANSPORT,
)
self.classic_connections[peer_address] = connection
logger.debug(
f'New CLASSIC connection handle: 0x{connection_handle:04X}'
)
else:
connection_handle = connection.handle
self.send_hci_packet(
HCI_Connection_Complete_Event(
status=status,
connection_handle=connection_handle,
bd_addr=peer_address,
encryption_enabled=False,
link_type=HCI_Connection_Complete_Event.ACL_LINK_TYPE,
)
)
else:
connection = None
self.send_hci_packet(
HCI_Connection_Complete_Event(
status=status,
connection_handle=0,
bd_addr=peer_address,
encryption_enabled=False,
link_type=HCI_Connection_Complete_Event.ACL_LINK_TYPE,
)
)
def on_classic_disconnected(self, peer_address, reason):
# Send a disconnection complete event
if connection := self.classic_connections.get(peer_address):
self.send_hci_packet(
HCI_Disconnection_Complete_Event(
status=HCI_SUCCESS,
connection_handle=connection.handle,
reason=reason,
)
)
# Remove the connection
del self.classic_connections[peer_address]
else:
logger.warning(f'!!! No classic connection found for {peer_address}')
def on_classic_role_change(self, peer_address, new_role):
self.send_hci_packet(
HCI_Role_Change_Event(
status=HCI_SUCCESS,
bd_addr=peer_address,
new_role=new_role,
)
)
############################################################ ############################################################
# Advertising support # Advertising support
############################################################ ############################################################
@@ -522,7 +657,31 @@ class Controller:
See Bluetooth spec Vol 2, Part E - 7.1.5 Create Connection command See Bluetooth spec Vol 2, Part E - 7.1.5 Create Connection command
''' '''
# TODO: classic mode not supported yet if self.link is None:
return
logger.debug(f'Connection request to {command.bd_addr}')
# Check that we don't already have a pending connection
if self.link.get_pending_connection():
self.send_hci_packet(
HCI_Command_Status_Event(
status=HCI_CONTROLLER_BUSY_ERROR,
num_hci_command_packets=1,
command_opcode=command.op_code,
)
)
return
self.link.classic_connect(self, command.bd_addr)
# Say that the connection is pending
self.send_hci_packet(
HCI_Command_Status_Event(
status=HCI_COMMAND_STATUS_PENDING,
num_hci_command_packets=1,
command_opcode=command.op_code,
)
)
def on_hci_disconnect_command(self, command): def on_hci_disconnect_command(self, command):
''' '''
@@ -538,19 +697,57 @@ class Controller:
) )
# Notify the link of the disconnection # Notify the link of the disconnection
if not ( handle = command.connection_handle
connection := self.find_central_connection_by_handle( if connection := self.find_central_connection_by_handle(handle):
command.connection_handle if self.link:
) self.link.disconnect(
): self.random_address, connection.peer_address, command
logger.warning('connection not found') )
return else:
# Remove the connection
del self.central_connections[connection.peer_address]
elif connection := self.find_classic_connection_by_handle(handle):
if self.link:
self.link.classic_disconnect(
self,
connection.peer_address,
HCI_REMOTE_USER_TERMINATED_CONNECTION_ERROR,
)
else:
# Remove the connection
del self.classic_connections[connection.peer_address]
if self.link: def on_hci_accept_connection_request_command(self, command):
self.link.disconnect(self.random_address, connection.peer_address, command) '''
else: See Bluetooth spec Vol 2, Part E - 7.1.8 Accept Connection Request command
# Remove the connection '''
del self.central_connections[connection.peer_address]
if self.link is None:
return
self.send_hci_packet(
HCI_Command_Status_Event(
status=HCI_SUCCESS,
num_hci_command_packets=1,
command_opcode=command.op_code,
)
)
self.link.classic_accept_connection(self, command.bd_addr, command.role)
def on_hci_switch_role_command(self, command):
'''
See Bluetooth spec Vol 2, Part E - 7.2.8 Switch Role command
'''
if self.link is None:
return
self.send_hci_packet(
HCI_Command_Status_Event(
status=HCI_SUCCESS,
num_hci_command_packets=1,
command_opcode=command.op_code,
)
)
self.link.classic_switch_role(self, command.bd_addr, command.role)
def on_hci_set_event_mask_command(self, command): def on_hci_set_event_mask_command(self, command):
''' '''
@@ -628,6 +825,12 @@ class Controller:
ret = HCI_INVALID_HCI_COMMAND_PARAMETERS_ERROR ret = HCI_INVALID_HCI_COMMAND_PARAMETERS_ERROR
return bytes([ret]) return bytes([ret])
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
'''
return bytes([HCI_SUCCESS])
def on_hci_write_simple_pairing_mode_command(self, _command): 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 2, Part E - 7.3.59 Write Simple Pairing Mode Command
@@ -738,10 +941,10 @@ class Controller:
self.advertising_parameters = command self.advertising_parameters = command
return bytes([HCI_SUCCESS]) return bytes([HCI_SUCCESS])
def on_hci_le_read_advertising_channel_tx_power_command(self, _command): 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 Channel Tx Power See Bluetooth spec Vol 2, Part E - 7.8.6 LE Read Advertising Physical Channel
Command Tx Power Command
''' '''
return bytes([HCI_SUCCESS, self.advertising_channel_tx_power]) return bytes([HCI_SUCCESS, self.advertising_channel_tx_power])
@@ -1008,7 +1211,7 @@ class Controller:
def on_hci_le_read_phy_command(self, command): 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 2, Part E - 7.8.47 LE Read PHY Command
''' '''
return struct.pack( return struct.pack(
'<BHBB', '<BHBB',
@@ -1028,3 +1231,9 @@ class Controller:
'rx_phys': command.rx_phys, 'rx_phys': command.rx_phys,
} }
return bytes([HCI_SUCCESS]) return bytes([HCI_SUCCESS])
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
'''
return struct.pack('<BBB', HCI_SUCCESS, 0, 0)
+49 -29
View File
@@ -15,7 +15,9 @@
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
# Imports # Imports
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
from __future__ import annotations
import struct import struct
from typing import List, Optional, Tuple, Union, cast
from .company_ids import COMPANY_IDENTIFIERS from .company_ids import COMPANY_IDENTIFIERS
@@ -142,10 +144,13 @@ class ConnectionError(BaseError): # pylint: disable=redefined-builtin
class UUID: class UUID:
''' '''
See Bluetooth spec Vol 3, Part B - 2.5.1 UUID See Bluetooth spec Vol 3, Part B - 2.5.1 UUID
Note that this class expects and works in little-endian byte-order throughout.
The exception is when interacting with strings, which are in big-endian byte-order.
''' '''
BASE_UUID = bytes.fromhex('00001000800000805F9B34FB') BASE_UUID = bytes.fromhex('00001000800000805F9B34FB')[::-1] # little-endian
UUIDS = [] # Registry of all instances created UUIDS: List[UUID] = [] # Registry of all instances created
def __init__(self, uuid_str_or_int, name=None): def __init__(self, uuid_str_or_int, name=None):
if isinstance(uuid_str_or_int, int): if isinstance(uuid_str_or_int, int):
@@ -180,7 +185,7 @@ class UUID:
return self return self
@classmethod @classmethod
def from_bytes(cls, uuid_bytes, name=None): def from_bytes(cls, uuid_bytes: bytes, name: Optional[str] = None) -> UUID:
if len(uuid_bytes) in (2, 4, 16): if len(uuid_bytes) in (2, 4, 16):
self = cls.__new__(cls) self = cls.__new__(cls)
self.uuid_bytes = uuid_bytes self.uuid_bytes = uuid_bytes
@@ -207,13 +212,20 @@ class UUID:
return offset + 2, cls.from_bytes(uuid_as_bytes[offset : offset + 2]) return offset + 2, cls.from_bytes(uuid_as_bytes[offset : offset + 2])
def to_bytes(self, force_128=False): def to_bytes(self, force_128=False):
if len(self.uuid_bytes) == 16 or not force_128: '''
Serialize UUID in little-endian byte-order
'''
if not force_128:
return self.uuid_bytes return self.uuid_bytes
if len(self.uuid_bytes) == 4: if len(self.uuid_bytes) == 2:
return self.uuid_bytes + UUID.BASE_UUID return self.BASE_UUID + self.uuid_bytes + bytes([0, 0])
elif len(self.uuid_bytes) == 4:
return self.uuid_bytes + bytes([0, 0]) + UUID.BASE_UUID return self.BASE_UUID + self.uuid_bytes
elif len(self.uuid_bytes) == 16:
return self.uuid_bytes
else:
assert False, "unreachable"
def to_pdu_bytes(self): def to_pdu_bytes(self):
''' '''
@@ -224,7 +236,7 @@ class UUID:
''' '''
return self.to_bytes(force_128=(len(self.uuid_bytes) == 4)) return self.to_bytes(force_128=(len(self.uuid_bytes) == 4))
def to_hex_str(self): def to_hex_str(self) -> str:
if len(self.uuid_bytes) == 2 or len(self.uuid_bytes) == 4: if len(self.uuid_bytes) == 2 or len(self.uuid_bytes) == 4:
return bytes(reversed(self.uuid_bytes)).hex().upper() return bytes(reversed(self.uuid_bytes)).hex().upper()
@@ -606,6 +618,11 @@ class DeviceClass:
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
# Advertising Data # Advertising Data
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
AdvertisingObject = Union[
List[UUID], Tuple[UUID, bytes], bytes, str, int, Tuple[int, int], Tuple[int, bytes]
]
class AdvertisingData: class AdvertisingData:
# fmt: off # fmt: off
# pylint: disable=line-too-long # pylint: disable=line-too-long
@@ -721,10 +738,12 @@ class AdvertisingData:
BR_EDR_CONTROLLER_FLAG = 0x08 BR_EDR_CONTROLLER_FLAG = 0x08
BR_EDR_HOST_FLAG = 0x10 BR_EDR_HOST_FLAG = 0x10
ad_structures: List[Tuple[int, bytes]]
# fmt: on # fmt: on
# pylint: enable=line-too-long # pylint: enable=line-too-long
def __init__(self, ad_structures=None): def __init__(self, ad_structures: Optional[List[Tuple[int, bytes]]] = None) -> None:
if ad_structures is None: if ad_structures is None:
ad_structures = [] ad_structures = []
self.ad_structures = ad_structures[:] self.ad_structures = ad_structures[:]
@@ -751,7 +770,7 @@ class AdvertisingData:
return ','.join(bit_flags_to_strings(flags, flag_names)) return ','.join(bit_flags_to_strings(flags, flag_names))
@staticmethod @staticmethod
def uuid_list_to_objects(ad_data, uuid_size): def uuid_list_to_objects(ad_data: bytes, uuid_size: int) -> List[UUID]:
uuids = [] uuids = []
offset = 0 offset = 0
while (uuid_size * (offset + 1)) <= len(ad_data): while (uuid_size * (offset + 1)) <= len(ad_data):
@@ -828,7 +847,7 @@ class AdvertisingData:
# pylint: disable=too-many-return-statements # pylint: disable=too-many-return-statements
@staticmethod @staticmethod
def ad_data_to_object(ad_type, ad_data): def ad_data_to_object(ad_type: int, ad_data: bytes) -> AdvertisingObject:
if ad_type in ( if ad_type in (
AdvertisingData.COMPLETE_LIST_OF_16_BIT_SERVICE_CLASS_UUIDS, AdvertisingData.COMPLETE_LIST_OF_16_BIT_SERVICE_CLASS_UUIDS,
AdvertisingData.INCOMPLETE_LIST_OF_16_BIT_SERVICE_CLASS_UUIDS, AdvertisingData.INCOMPLETE_LIST_OF_16_BIT_SERVICE_CLASS_UUIDS,
@@ -867,22 +886,22 @@ class AdvertisingData:
return ad_data.decode("utf-8") return ad_data.decode("utf-8")
if ad_type in (AdvertisingData.TX_POWER_LEVEL, AdvertisingData.FLAGS): if ad_type in (AdvertisingData.TX_POWER_LEVEL, AdvertisingData.FLAGS):
return ad_data[0] return cast(int, struct.unpack('B', ad_data)[0])
if ad_type in ( if ad_type in (
AdvertisingData.APPEARANCE, AdvertisingData.APPEARANCE,
AdvertisingData.ADVERTISING_INTERVAL, AdvertisingData.ADVERTISING_INTERVAL,
): ):
return struct.unpack('<H', ad_data)[0] return cast(int, struct.unpack('<H', ad_data)[0])
if ad_type == AdvertisingData.CLASS_OF_DEVICE: if ad_type == AdvertisingData.CLASS_OF_DEVICE:
return struct.unpack('<I', bytes([*ad_data, 0]))[0] return cast(int, struct.unpack('<I', bytes([*ad_data, 0]))[0])
if ad_type == AdvertisingData.PERIPHERAL_CONNECTION_INTERVAL_RANGE: if ad_type == AdvertisingData.PERIPHERAL_CONNECTION_INTERVAL_RANGE:
return struct.unpack('<HH', ad_data) return cast(Tuple[int, int], struct.unpack('<HH', ad_data))
if ad_type == AdvertisingData.MANUFACTURER_SPECIFIC_DATA: if ad_type == AdvertisingData.MANUFACTURER_SPECIFIC_DATA:
return (struct.unpack_from('<H', ad_data, 0)[0], ad_data[2:]) return (cast(int, struct.unpack_from('<H', ad_data, 0)[0]), ad_data[2:])
return ad_data return ad_data
@@ -897,26 +916,27 @@ class AdvertisingData:
self.ad_structures.append((ad_type, ad_data)) self.ad_structures.append((ad_type, ad_data))
offset += length offset += length
def get(self, type_id, return_all=False, raw=False): def get_all(self, type_id: int, raw: bool = False) -> List[AdvertisingObject]:
''' '''
Get Advertising Data Structure(s) with a given type Get Advertising Data Structure(s) with a given type
If return_all is True, returns a (possibly empty) list of matches, Returns a (possibly empty) list of matches.
else returns the first entry, or None if no structure matches.
''' '''
def process_ad_data(ad_data): def process_ad_data(ad_data: bytes) -> AdvertisingObject:
return ad_data if raw else self.ad_data_to_object(type_id, ad_data) return ad_data if raw else self.ad_data_to_object(type_id, ad_data)
if return_all: return [process_ad_data(ad[1]) for ad in self.ad_structures if ad[0] == type_id]
return [
process_ad_data(ad[1]) for ad in self.ad_structures if ad[0] == type_id
]
return next( def get(self, type_id: int, raw: bool = False) -> Optional[AdvertisingObject]:
(process_ad_data(ad[1]) for ad in self.ad_structures if ad[0] == type_id), '''
None, Get Advertising Data Structure(s) with a given type
)
Returns the first entry, or None if no structure matches.
'''
all = self.get_all(type_id, raw=raw)
return all[0] if all else None
def __bytes__(self): def __bytes__(self):
return b''.join( return b''.join(
+416
View File
@@ -0,0 +1,416 @@
# 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.
# -----------------------------------------------------------------------------
# Constants
# -----------------------------------------------------------------------------
# fmt: off
WL = [-60, -30, 58, 172, 334, 538, 1198, 3042]
RL42 = [0, 7, 6, 5, 4, 3, 2, 1, 7, 6, 5, 4, 3, 2, 1, 0]
ILB = [
2048,
2093,
2139,
2186,
2233,
2282,
2332,
2383,
2435,
2489,
2543,
2599,
2656,
2714,
2774,
2834,
2896,
2960,
3025,
3091,
3158,
3228,
3298,
3371,
3444,
3520,
3597,
3676,
3756,
3838,
3922,
4008,
]
WH = [0, -214, 798]
RH2 = [2, 1, 2, 1]
# Values in QM2/QM4/QM6 left shift three bits than original g722 specification.
QM2 = [-7408, -1616, 7408, 1616]
QM4 = [
0,
-20456,
-12896,
-8968,
-6288,
-4240,
-2584,
-1200,
20456,
12896,
8968,
6288,
4240,
2584,
1200,
0,
]
QM6 = [
-136,
-136,
-136,
-136,
-24808,
-21904,
-19008,
-16704,
-14984,
-13512,
-12280,
-11192,
-10232,
-9360,
-8576,
-7856,
-7192,
-6576,
-6000,
-5456,
-4944,
-4464,
-4008,
-3576,
-3168,
-2776,
-2400,
-2032,
-1688,
-1360,
-1040,
-728,
24808,
21904,
19008,
16704,
14984,
13512,
12280,
11192,
10232,
9360,
8576,
7856,
7192,
6576,
6000,
5456,
4944,
4464,
4008,
3576,
3168,
2776,
2400,
2032,
1688,
1360,
1040,
728,
432,
136,
-432,
-136,
]
QMF_COEFFS = [3, -11, 12, 32, -210, 951, 3876, -805, 362, -156, 53, -11]
# fmt: on
# -----------------------------------------------------------------------------
# Classes
# -----------------------------------------------------------------------------
class G722Decoder(object):
"""G.722 decoder with bitrate 64kbit/s.
For the Blocks in the sub-band decoders, please refer to the G.722
specification for the required information. G722 specification:
https://www.itu.int/rec/T-REC-G.722-201209-I
"""
def __init__(self):
self._x = [0] * 24
self._band = [Band(), Band()]
# The initial value in BLOCK 3L
self._band[0].det = 32
# The initial value in BLOCK 3H
self._band[1].det = 8
def decode_frame(self, encoded_data) -> bytearray:
result_array = bytearray(len(encoded_data) * 4)
self.g722_decode(result_array, encoded_data)
return result_array
def g722_decode(self, result_array, encoded_data) -> int:
"""Decode the data frame using g722 decoder."""
result_length = 0
for code in encoded_data:
higher_bits = (code >> 6) & 0x03
lower_bits = code & 0x3F
rlow = self.lower_sub_band_decoder(lower_bits)
rhigh = self.higher_sub_band_decoder(higher_bits)
# Apply the receive QMF
self._x[:22] = self._x[2:]
self._x[22] = rlow + rhigh
self._x[23] = rlow - rhigh
xout2 = sum(self._x[2 * i] * QMF_COEFFS[i] for i in range(12))
xout1 = sum(self._x[2 * i + 1] * QMF_COEFFS[11 - i] for i in range(12))
result_length = self.update_decoded_result(
xout1, result_length, result_array
)
result_length = self.update_decoded_result(
xout2, result_length, result_array
)
return result_length
def update_decoded_result(self, xout, byte_length, byte_array) -> int:
result = (int)(xout >> 11)
bytes_result = result.to_bytes(2, 'little', signed=True)
byte_array[byte_length] = bytes_result[0]
byte_array[byte_length + 1] = bytes_result[1]
return byte_length + 2
def lower_sub_band_decoder(self, lower_bits) -> int:
"""Lower sub-band decoder for last six bits."""
# Block 5L
# INVQBL
wd1 = lower_bits
wd2 = QM6[wd1]
wd1 >>= 2
wd2 = (self._band[0].det * wd2) >> 15
# RECONS
rlow = self._band[0].s + wd2
# Block 6L
# LIMIT
if rlow > 16383:
rlow = 16383
elif rlow < -16384:
rlow = -16384
# Block 2L
# INVQAL
wd2 = QM4[wd1]
dlowt = (self._band[0].det * wd2) >> 15
# Block 3L
# LOGSCL
wd2 = RL42[wd1]
wd1 = (self._band[0].nb * 127) >> 7
wd1 += WL[wd2]
if wd1 < 0:
wd1 = 0
elif wd1 > 18432:
wd1 = 18432
self._band[0].nb = wd1
# SCALEL
wd1 = (self._band[0].nb >> 6) & 31
wd2 = 8 - (self._band[0].nb >> 11)
if wd2 < 0:
wd3 = ILB[wd1] << -wd2
else:
wd3 = ILB[wd1] >> wd2
self._band[0].det = wd3 << 2
# Block 4L
self._band[0].block4(dlowt)
return rlow
def higher_sub_band_decoder(self, higher_bits) -> int:
"""Higher sub-band decoder for first two bits."""
# Block 2H
# INVQAH
wd2 = QM2[higher_bits]
dhigh = (self._band[1].det * wd2) >> 15
# Block 5H
# RECONS
rhigh = dhigh + self._band[1].s
# Block 6H
# LIMIT
if rhigh > 16383:
rhigh = 16383
elif rhigh < -16384:
rhigh = -16384
# Block 3H
# LOGSCH
wd2 = RH2[higher_bits]
wd1 = (self._band[1].nb * 127) >> 7
wd1 += WH[wd2]
if wd1 < 0:
wd1 = 0
elif wd1 > 22528:
wd1 = 22528
self._band[1].nb = wd1
# SCALEH
wd1 = (self._band[1].nb >> 6) & 31
wd2 = 10 - (self._band[1].nb >> 11)
if wd2 < 0:
wd3 = ILB[wd1] << -wd2
else:
wd3 = ILB[wd1] >> wd2
self._band[1].det = wd3 << 2
# Block 4H
self._band[1].block4(dhigh)
return rhigh
# -----------------------------------------------------------------------------
class Band(object):
"""Structure for G722 decode proccessing."""
s: int = 0
nb: int = 0
det: int = 0
def __init__(self):
self._sp = 0
self._sz = 0
self._r = [0] * 3
self._a = [0] * 3
self._ap = [0] * 3
self._p = [0] * 3
self._d = [0] * 7
self._b = [0] * 7
self._bp = [0] * 7
self._sg = [0] * 7
def saturate(self, amp: int) -> int:
if amp > 32767:
return 32767
elif amp < -32768:
return -32768
else:
return amp
def block4(self, d: int) -> None:
"""Block4 for both lower and higher sub-band decoder."""
wd1 = 0
wd2 = 0
wd3 = 0
# RECONS
self._d[0] = d
self._r[0] = self.saturate(self.s + d)
# PARREC
self._p[0] = self.saturate(self._sz + d)
# UPPOL2
for i in range(3):
self._sg[i] = (self._p[i]) >> 15
wd1 = self.saturate((self._a[1]) << 2)
wd2 = -wd1 if self._sg[0] == self._sg[1] else wd1
if wd2 > 32767:
wd2 = 32767
wd3 = 128 if self._sg[0] == self._sg[2] else -128
wd3 += wd2 >> 7
wd3 += (self._a[2] * 32512) >> 15
if wd3 > 12288:
wd3 = 12288
elif wd3 < -12288:
wd3 = -12288
self._ap[2] = wd3
# UPPOL1
self._sg[0] = (self._p[0]) >> 15
self._sg[1] = (self._p[1]) >> 15
wd1 = 192 if self._sg[0] == self._sg[1] else -192
wd2 = (self._a[1] * 32640) >> 15
self._ap[1] = self.saturate(wd1 + wd2)
wd3 = self.saturate(15360 - self._ap[2])
if self._ap[1] > wd3:
self._ap[1] = wd3
elif self._ap[1] < -wd3:
self._ap[1] = -wd3
# UPZERO
wd1 = 0 if d == 0 else 128
self._sg[0] = d >> 15
for i in range(1, 7):
self._sg[i] = (self._d[i]) >> 15
wd2 = wd1 if self._sg[i] == self._sg[0] else -wd1
wd3 = (self._b[i] * 32640) >> 15
self._bp[i] = self.saturate(wd2 + wd3)
# DELAYA
for i in range(6, 0, -1):
self._d[i] = self._d[i - 1]
self._b[i] = self._bp[i]
for i in range(2, 0, -1):
self._r[i] = self._r[i - 1]
self._p[i] = self._p[i - 1]
self._a[i] = self._ap[i]
# FILTEP
self._sp = 0
for i in range(1, 3):
wd1 = self.saturate(self._r[i] + self._r[i])
self._sp += (self._a[i] * wd1) >> 15
self._sp = self.saturate(self._sp)
# FILTEZ
self._sz = 0
for i in range(6, 0, -1):
wd1 = self.saturate(self._d[i] + self._d[i])
self._sz += (self._b[i] * wd1) >> 15
self._sz = self.saturate(self._sz)
# PREDIC
self.s = self.saturate(self._sp + self._sz)
+471 -250
View File
File diff suppressed because it is too large Load Diff
+2 -2
View File
@@ -41,14 +41,14 @@ class GenericAccessService(Service):
def __init__(self, device_name, appearance=(0, 0)): def __init__(self, device_name, appearance=(0, 0)):
device_name_characteristic = Characteristic( device_name_characteristic = Characteristic(
GATT_DEVICE_NAME_CHARACTERISTIC, GATT_DEVICE_NAME_CHARACTERISTIC,
Characteristic.READ, Characteristic.Properties.READ,
Characteristic.READABLE, Characteristic.READABLE,
device_name.encode('utf-8')[:248], device_name.encode('utf-8')[:248],
) )
appearance_characteristic = Characteristic( appearance_characteristic = Characteristic(
GATT_APPEARANCE_CHARACTERISTIC, GATT_APPEARANCE_CHARACTERISTIC,
Characteristic.READ, Characteristic.Properties.READ,
Characteristic.READABLE, Characteristic.READABLE,
struct.pack('<H', (appearance[0] << 6) | appearance[1]), struct.pack('<H', (appearance[0] << 6) | appearance[1]),
) )
+62 -50
View File
@@ -28,9 +28,9 @@ import enum
import functools import functools
import logging import logging
import struct import struct
from typing import Sequence from typing import Optional, Sequence, List
from colors import color
from .colors import color
from .core import UUID, get_dict_key_by_value from .core import UUID, get_dict_key_by_value
from .att import Attribute from .att import Attribute
@@ -204,6 +204,8 @@ class Service(Attribute):
See Vol 3, Part G - 3.1 SERVICE DEFINITION See Vol 3, Part G - 3.1 SERVICE DEFINITION
''' '''
uuid: UUID
def __init__(self, uuid, characteristics: list[Characteristic], primary=True): def __init__(self, uuid, characteristics: list[Characteristic], primary=True):
# Convert the uuid to a UUID object if it isn't already # Convert the uuid to a UUID object if it isn't already
if isinstance(uuid, str): if isinstance(uuid, str):
@@ -217,11 +219,11 @@ class Service(Attribute):
uuid.to_pdu_bytes(), uuid.to_pdu_bytes(),
) )
self.uuid = uuid self.uuid = uuid
self.included_services = [] # self.included_services = []
self.characteristics = characteristics[:] self.characteristics = characteristics[:]
self.primary = primary self.primary = primary
def get_advertising_data(self): def get_advertising_data(self) -> Optional[bytes]:
""" """
Get Service specific advertising data Get Service specific advertising data
Defined by each Service, default value is empty Defined by each Service, default value is empty
@@ -257,63 +259,68 @@ class Characteristic(Attribute):
See Vol 3, Part G - 3.3 CHARACTERISTIC DEFINITION See Vol 3, Part G - 3.3 CHARACTERISTIC DEFINITION
''' '''
# Property flags uuid: UUID
BROADCAST = 0x01 properties: Characteristic.Properties
READ = 0x02
WRITE_WITHOUT_RESPONSE = 0x04
WRITE = 0x08
NOTIFY = 0x10
INDICATE = 0x20
AUTHENTICATED_SIGNED_WRITES = 0x40
EXTENDED_PROPERTIES = 0x80
PROPERTY_NAMES = { class Properties(enum.IntFlag):
BROADCAST: 'BROADCAST', """Property flags"""
READ: 'READ',
WRITE_WITHOUT_RESPONSE: 'WRITE_WITHOUT_RESPONSE',
WRITE: 'WRITE',
NOTIFY: 'NOTIFY',
INDICATE: 'INDICATE',
AUTHENTICATED_SIGNED_WRITES: 'AUTHENTICATED_SIGNED_WRITES',
EXTENDED_PROPERTIES: 'EXTENDED_PROPERTIES',
}
@staticmethod BROADCAST = 0x01
def property_name(property_int): READ = 0x02
return Characteristic.PROPERTY_NAMES.get(property_int, '') WRITE_WITHOUT_RESPONSE = 0x04
WRITE = 0x08
NOTIFY = 0x10
INDICATE = 0x20
AUTHENTICATED_SIGNED_WRITES = 0x40
EXTENDED_PROPERTIES = 0x80
@staticmethod @staticmethod
def properties_as_string(properties): def from_string(properties_str: str) -> Characteristic.Properties:
return ','.join( property_names: List[str] = []
[ for property in Characteristic.Properties:
Characteristic.property_name(p) if property.name is None:
for p in Characteristic.PROPERTY_NAMES raise TypeError()
if properties & p property_names.append(property.name)
]
)
@staticmethod def string_to_property(property_string) -> Characteristic.Properties:
def string_to_properties(properties_str: str): for property in zip(Characteristic.Properties, property_names):
return functools.reduce( if property_string == property[1]:
lambda x, y: x | get_dict_key_by_value(Characteristic.PROPERTY_NAMES, y), return property[0]
properties_str.split(","), raise TypeError(f"Unable to convert {property_string} to Property")
0,
) try:
return functools.reduce(
lambda x, y: x | string_to_property(y),
properties_str.split(","),
Characteristic.Properties(0),
)
except TypeError:
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}"
)
# For backwards compatibility these are defined here
# For new code, please use Characteristic.Properties.X
BROADCAST = Properties.BROADCAST
READ = Properties.READ
WRITE_WITHOUT_RESPONSE = Properties.WRITE_WITHOUT_RESPONSE
WRITE = Properties.WRITE
NOTIFY = Properties.NOTIFY
INDICATE = Properties.INDICATE
AUTHENTICATED_SIGNED_WRITES = Properties.AUTHENTICATED_SIGNED_WRITES
EXTENDED_PROPERTIES = Properties.EXTENDED_PROPERTIES
def __init__( def __init__(
self, self,
uuid, uuid,
properties, properties: Characteristic.Properties,
permissions, permissions,
value=b'', value=b'',
descriptors: Sequence[Descriptor] = (), descriptors: Sequence[Descriptor] = (),
): ):
super().__init__(uuid, permissions, value) super().__init__(uuid, permissions, value)
self.uuid = self.type self.uuid = self.type
if isinstance(properties, str): self.properties = properties
self.properties = Characteristic.string_to_properties(properties)
else:
self.properties = properties
self.descriptors = descriptors self.descriptors = descriptors
def get_descriptor(self, descriptor_type): def get_descriptor(self, descriptor_type):
@@ -323,12 +330,15 @@ class Characteristic(Attribute):
return None return None
def has_properties(self, properties: Characteristic.Properties) -> bool:
return self.properties & properties == properties
def __str__(self): def __str__(self):
return ( return (
f'Characteristic(handle=0x{self.handle:04X}, ' f'Characteristic(handle=0x{self.handle:04X}, '
f'end=0x{self.end_group_handle:04X}, ' f'end=0x{self.end_group_handle:04X}, '
f'uuid={self.uuid}, ' f'uuid={self.uuid}, '
f'properties={Characteristic.properties_as_string(self.properties)})' f'{self.properties!s})'
) )
@@ -338,6 +348,8 @@ class CharacteristicDeclaration(Attribute):
See Vol 3, Part G - 3.3.1 CHARACTERISTIC DECLARATION See Vol 3, Part G - 3.3.1 CHARACTERISTIC DECLARATION
''' '''
characteristic: Characteristic
def __init__(self, characteristic, value_handle): def __init__(self, characteristic, value_handle):
declaration_bytes = ( declaration_bytes = (
struct.pack('<BH', characteristic.properties, value_handle) struct.pack('<BH', characteristic.properties, value_handle)
@@ -353,8 +365,8 @@ class CharacteristicDeclaration(Attribute):
return ( return (
f'CharacteristicDeclaration(handle=0x{self.handle:04X}, ' f'CharacteristicDeclaration(handle=0x{self.handle:04X}, '
f'value_handle=0x{self.value_handle:04X}, ' f'value_handle=0x{self.value_handle:04X}, '
f'uuid={self.characteristic.uuid}, properties=' f'uuid={self.characteristic.uuid}, '
f'{Characteristic.properties_as_string(self.characteristic.properties)})' f'{self.characteristic.properties!s})'
) )
+96 -21
View File
@@ -23,13 +23,16 @@
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
# Imports # Imports
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
from __future__ import annotations
import asyncio import asyncio
import logging import logging
import struct import struct
from datetime import datetime
from typing import List, Optional, Dict, Tuple, Callable, Union, Any
from colors import color
from pyee import EventEmitter from pyee import EventEmitter
from .colors import color
from .hci import HCI_Constant from .hci import HCI_Constant
from .att import ( from .att import (
ATT_ATTRIBUTE_NOT_FOUND_ERROR, ATT_ATTRIBUTE_NOT_FOUND_ERROR,
@@ -50,6 +53,7 @@ from .att import (
ATT_Read_Request, ATT_Read_Request,
ATT_Write_Command, ATT_Write_Command,
ATT_Write_Request, ATT_Write_Request,
ATT_Error,
) )
from . import core from . import core
from .core import UUID, InvalidStateError, ProtocolError from .core import UUID, InvalidStateError, ProtocolError
@@ -73,6 +77,8 @@ logger = logging.getLogger(__name__)
# Proxies # Proxies
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
class AttributeProxy(EventEmitter): class AttributeProxy(EventEmitter):
client: Client
def __init__(self, client, handle, end_group_handle, attribute_type): def __init__(self, client, handle, end_group_handle, attribute_type):
EventEmitter.__init__(self) EventEmitter.__init__(self)
self.client = client self.client = client
@@ -101,6 +107,9 @@ class AttributeProxy(EventEmitter):
class ServiceProxy(AttributeProxy): class ServiceProxy(AttributeProxy):
uuid: UUID
characteristics: List[CharacteristicProxy]
@staticmethod @staticmethod
def from_client(service_class, client, service_uuid): def from_client(service_class, client, service_uuid):
# The service and its characteristics are considered to have already been # The service and its characteristics are considered to have already been
@@ -130,10 +139,21 @@ class ServiceProxy(AttributeProxy):
class CharacteristicProxy(AttributeProxy): class CharacteristicProxy(AttributeProxy):
def __init__(self, client, handle, end_group_handle, uuid, properties): properties: Characteristic.Properties
descriptors: List[DescriptorProxy]
subscribers: Dict[Any, Callable]
def __init__(
self,
client,
handle,
end_group_handle,
uuid,
properties: int,
):
super().__init__(client, handle, end_group_handle, uuid) super().__init__(client, handle, end_group_handle, uuid)
self.uuid = uuid self.uuid = uuid
self.properties = properties self.properties = Characteristic.Properties(properties)
self.descriptors = [] self.descriptors = []
self.descriptors_discovered = False self.descriptors_discovered = False
self.subscribers = {} # Map from subscriber to proxy subscriber self.subscribers = {} # Map from subscriber to proxy subscriber
@@ -148,7 +168,9 @@ class CharacteristicProxy(AttributeProxy):
async def discover_descriptors(self): async def discover_descriptors(self):
return await self.client.discover_descriptors(self) return await self.client.discover_descriptors(self)
async def subscribe(self, subscriber=None, prefer_notify=True): async def subscribe(
self, subscriber: Optional[Callable] = None, prefer_notify=True
):
if subscriber is not None: if subscriber is not None:
if subscriber in self.subscribers: if subscriber in self.subscribers:
# We already have a proxy subscriber # We already have a proxy subscriber
@@ -175,7 +197,7 @@ class CharacteristicProxy(AttributeProxy):
return ( return (
f'Characteristic(handle=0x{self.handle:04X}, ' f'Characteristic(handle=0x{self.handle:04X}, '
f'uuid={self.uuid}, ' f'uuid={self.uuid}, '
f'properties={Characteristic.properties_as_string(self.properties)})' f'{self.properties!s})'
) )
@@ -201,6 +223,9 @@ class ProfileServiceProxy:
# GATT Client # GATT Client
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
class Client: class Client:
services: List[ServiceProxy]
cached_values: Dict[int, Tuple[datetime, bytes]]
def __init__(self, connection): def __init__(self, connection):
self.connection = connection self.connection = connection
self.mtu_exchange_done = False self.mtu_exchange_done = False
@@ -212,6 +237,7 @@ class Client:
) # Notification subscribers, by attribute handle ) # Notification subscribers, by attribute handle
self.indication_subscribers = {} # Indication subscribers, by attribute handle self.indication_subscribers = {} # Indication subscribers, by attribute handle
self.services = [] self.services = []
self.cached_values = {}
def send_gatt_pdu(self, pdu): def send_gatt_pdu(self, pdu):
self.connection.send_l2cap_pdu(ATT_CID, pdu) self.connection.send_l2cap_pdu(ATT_CID, pdu)
@@ -296,6 +322,35 @@ class Client:
if c.uuid == uuid if c.uuid == uuid
] ]
def get_attribute_grouping(
self, attribute_handle: int
) -> Optional[
Union[
ServiceProxy,
Tuple[ServiceProxy, CharacteristicProxy],
Tuple[ServiceProxy, CharacteristicProxy, DescriptorProxy],
]
]:
"""
Get the attribute(s) associated with an attribute handle
"""
for service in self.services:
if service.handle == attribute_handle:
return service
if service.handle <= attribute_handle <= service.end_group_handle:
for characteristic in service.characteristics:
if characteristic.handle == attribute_handle:
return (service, characteristic)
if (
characteristic.handle
<= attribute_handle
<= characteristic.end_group_handle
):
for descriptor in characteristic.descriptors:
if descriptor.handle == attribute_handle:
return (service, characteristic, descriptor)
return None
def on_service_discovered(self, service): def on_service_discovered(self, service):
'''Add a service to the service list if it wasn't already there''' '''Add a service to the service list if it wasn't already there'''
already_known = False already_known = False
@@ -306,7 +361,7 @@ class Client:
if not already_known: if not already_known:
self.services.append(service) self.services.append(service)
async def discover_services(self, uuids=None): async def discover_services(self, uuids=None) -> List[ServiceProxy]:
''' '''
See Vol 3, Part G - 4.4.1 Discover All Primary Services See Vol 3, Part G - 4.4.1 Discover All Primary Services
''' '''
@@ -332,8 +387,10 @@ class Client:
'!!! unexpected error while discovering services: ' '!!! unexpected error while discovering services: '
f'{HCI_Constant.error_name(response.error_code)}' f'{HCI_Constant.error_name(response.error_code)}'
) )
# TODO raise appropriate exception raise ATT_Error(
return error_code=response.error_code,
message='Unexpected error while discovering services',
)
break break
for ( for (
@@ -349,7 +406,7 @@ class Client:
logger.warning( logger.warning(
f'bogus handle values: {attribute_handle} {end_group_handle}' f'bogus handle values: {attribute_handle} {end_group_handle}'
) )
return return []
# Create a service proxy for this service # Create a service proxy for this service
service = ServiceProxy( service = ServiceProxy(
@@ -452,7 +509,9 @@ class Client:
# TODO # TODO
return [] return []
async def discover_characteristics(self, uuids, service): async def discover_characteristics(
self, uuids, service: Optional[ServiceProxy]
) -> List[CharacteristicProxy]:
''' '''
See Vol 3, Part G - 4.6.1 Discover All Characteristics of a Service and 4.6.2 See Vol 3, Part G - 4.6.1 Discover All Characteristics of a Service and 4.6.2
Discover Characteristics by UUID Discover Characteristics by UUID
@@ -465,12 +524,12 @@ class Client:
services = [service] if service else self.services services = [service] if service else self.services
# Perform characteristic discovery for each service # Perform characteristic discovery for each service
discovered_characteristics = [] discovered_characteristics: List[CharacteristicProxy] = []
for service in services: for service in services:
starting_handle = service.handle starting_handle = service.handle
ending_handle = service.end_group_handle ending_handle = service.end_group_handle
characteristics = [] characteristics: List[CharacteristicProxy] = []
while starting_handle <= ending_handle: while starting_handle <= ending_handle:
response = await self.send_request( response = await self.send_request(
ATT_Read_By_Type_Request( ATT_Read_By_Type_Request(
@@ -491,8 +550,10 @@ class Client:
'!!! unexpected error while discovering characteristics: ' '!!! unexpected error while discovering characteristics: '
f'{HCI_Constant.error_name(response.error_code)}' f'{HCI_Constant.error_name(response.error_code)}'
) )
# TODO raise appropriate exception raise ATT_Error(
return error_code=response.error_code,
message='Unexpected error while discovering characteristics',
)
break break
# Stop if for some reason the list was empty # Stop if for some reason the list was empty
@@ -535,8 +596,11 @@ class Client:
return discovered_characteristics return discovered_characteristics
async def discover_descriptors( async def discover_descriptors(
self, characteristic=None, start_handle=None, end_handle=None self,
): characteristic: Optional[CharacteristicProxy] = None,
start_handle=None,
end_handle=None,
) -> List[DescriptorProxy]:
''' '''
See Vol 3, Part G - 4.7.1 Discover All Characteristic Descriptors See Vol 3, Part G - 4.7.1 Discover All Characteristic Descriptors
''' '''
@@ -549,7 +613,7 @@ class Client:
else: else:
return [] return []
descriptors = [] descriptors: List[DescriptorProxy] = []
while starting_handle <= ending_handle: while starting_handle <= ending_handle:
response = await self.send_request( response = await self.send_request(
ATT_Find_Information_Request( ATT_Find_Information_Request(
@@ -656,8 +720,8 @@ class Client:
return return
if ( if (
characteristic.properties & Characteristic.NOTIFY characteristic.properties & Characteristic.Properties.NOTIFY
and characteristic.properties & Characteristic.INDICATE and characteristic.properties & Characteristic.Properties.INDICATE
): ):
if prefer_notify: if prefer_notify:
bits = ClientCharacteristicConfigurationBits.NOTIFICATION bits = ClientCharacteristicConfigurationBits.NOTIFICATION
@@ -665,10 +729,10 @@ class Client:
else: else:
bits = ClientCharacteristicConfigurationBits.INDICATION bits = ClientCharacteristicConfigurationBits.INDICATION
subscribers = self.indication_subscribers subscribers = self.indication_subscribers
elif characteristic.properties & Characteristic.NOTIFY: elif characteristic.properties & Characteristic.Properties.NOTIFY:
bits = ClientCharacteristicConfigurationBits.NOTIFICATION bits = ClientCharacteristicConfigurationBits.NOTIFICATION
subscribers = self.notification_subscribers subscribers = self.notification_subscribers
elif characteristic.properties & Characteristic.INDICATE: elif characteristic.properties & Characteristic.Properties.INDICATE:
bits = ClientCharacteristicConfigurationBits.INDICATION bits = ClientCharacteristicConfigurationBits.INDICATION
subscribers = self.indication_subscribers subscribers = self.indication_subscribers
else: else:
@@ -778,6 +842,7 @@ class Client:
offset += len(part) offset += len(part)
self.cache_value(attribute_handle, attribute_value)
# Return the value as bytes # Return the value as bytes
return attribute_value return attribute_value
@@ -912,6 +977,8 @@ class Client:
) )
if not subscribers: if not subscribers:
logger.warning('!!! received notification with no subscriber') logger.warning('!!! received notification with no subscriber')
self.cache_value(notification.attribute_handle, notification.attribute_value)
for subscriber in subscribers: for subscriber in subscribers:
if callable(subscriber): if callable(subscriber):
subscriber(notification.attribute_value) subscriber(notification.attribute_value)
@@ -923,6 +990,8 @@ class Client:
subscribers = self.indication_subscribers.get(indication.attribute_handle, []) subscribers = self.indication_subscribers.get(indication.attribute_handle, [])
if not subscribers: if not subscribers:
logger.warning('!!! received indication with no subscriber') logger.warning('!!! received indication with no subscriber')
self.cache_value(indication.attribute_handle, indication.attribute_value)
for subscriber in subscribers: for subscriber in subscribers:
if callable(subscriber): if callable(subscriber):
subscriber(indication.attribute_value) subscriber(indication.attribute_value)
@@ -931,3 +1000,9 @@ class Client:
# Confirm that we received the indication # Confirm that we received the indication
self.send_confirmation(ATT_Handle_Value_Confirmation()) self.send_confirmation(ATT_Handle_Value_Confirmation())
def cache_value(self, attribute_handle: int, value: bytes):
self.cached_values[attribute_handle] = (
datetime.now(),
value,
)
+84 -37
View File
@@ -27,10 +27,10 @@ import asyncio
import logging import logging
from collections import defaultdict from collections import defaultdict
import struct import struct
from typing import Tuple, Optional from typing import List, Tuple, Optional, TypeVar, Type
from pyee import EventEmitter from pyee import EventEmitter
from colors import color
from .colors import color
from .core import UUID from .core import UUID
from .att import ( from .att import (
ATT_ATTRIBUTE_NOT_FOUND_ERROR, ATT_ATTRIBUTE_NOT_FOUND_ERROR,
@@ -61,7 +61,6 @@ from .att import (
from .gatt import ( from .gatt import (
GATT_CHARACTERISTIC_ATTRIBUTE_TYPE, GATT_CHARACTERISTIC_ATTRIBUTE_TYPE,
GATT_CLIENT_CHARACTERISTIC_CONFIGURATION_DESCRIPTOR, GATT_CLIENT_CHARACTERISTIC_CONFIGURATION_DESCRIPTOR,
GATT_INCLUDE_ATTRIBUTE_TYPE,
GATT_MAX_ATTRIBUTE_VALUE_SIZE, GATT_MAX_ATTRIBUTE_VALUE_SIZE,
GATT_PRIMARY_SERVICE_ATTRIBUTE_TYPE, GATT_PRIMARY_SERVICE_ATTRIBUTE_TYPE,
GATT_REQUEST_TIMEOUT, GATT_REQUEST_TIMEOUT,
@@ -90,6 +89,8 @@ GATT_SERVER_DEFAULT_MAX_MTU = 517
# GATT Server # GATT Server
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
class Server(EventEmitter): class Server(EventEmitter):
attributes: List[Attribute]
def __init__(self, device): def __init__(self, device):
super().__init__() super().__init__()
self.device = device self.device = device
@@ -134,12 +135,28 @@ class Server(EventEmitter):
return attribute return attribute
return None return None
AttributeGroupType = TypeVar('AttributeGroupType', Service, Characteristic)
def get_attribute_group(
self, handle: int, group_type: Type[AttributeGroupType]
) -> Optional[AttributeGroupType]:
return next(
(
attribute
for attribute in self.attributes
if isinstance(attribute, group_type)
and attribute.handle <= handle <= attribute.end_group_handle
),
None,
)
def get_service_attribute(self, service_uuid: UUID) -> Optional[Service]: def get_service_attribute(self, service_uuid: UUID) -> Optional[Service]:
return next( return next(
( (
attribute attribute
for attribute in self.attributes for attribute in self.attributes
if attribute.type == GATT_PRIMARY_SERVICE_ATTRIBUTE_TYPE if attribute.type == GATT_PRIMARY_SERVICE_ATTRIBUTE_TYPE
and isinstance(attribute, Service)
and attribute.uuid == service_uuid and attribute.uuid == service_uuid
), ),
None, None,
@@ -226,7 +243,10 @@ class Server(EventEmitter):
# unless there is one already # unless there is one already
if ( if (
characteristic.properties characteristic.properties
& (Characteristic.NOTIFY | Characteristic.INDICATE) & (
Characteristic.Properties.NOTIFY
| Characteristic.Properties.INDICATE
)
and characteristic.get_descriptor( and characteristic.get_descriptor(
GATT_CLIENT_CHARACTERISTIC_CONFIGURATION_DESCRIPTOR GATT_CLIENT_CHARACTERISTIC_CONFIGURATION_DESCRIPTOR
) )
@@ -540,8 +560,6 @@ class Server(EventEmitter):
if attribute.handle >= request.starting_handle if attribute.handle >= request.starting_handle
and attribute.handle <= request.ending_handle and attribute.handle <= request.ending_handle
): ):
# TODO: check permissions
this_uuid_size = len(attribute.type.to_pdu_bytes()) this_uuid_size = len(attribute.type.to_pdu_bytes())
if attributes: if attributes:
@@ -635,6 +653,13 @@ class Server(EventEmitter):
''' '''
pdu_space_available = connection.att_mtu - 2 pdu_space_available = connection.att_mtu - 2
response = ATT_Error_Response(
request_opcode_in_error=request.op_code,
attribute_handle_in_error=request.starting_handle,
error_code=ATT_ATTRIBUTE_NOT_FOUND_ERROR,
)
attributes = [] attributes = []
for attribute in ( for attribute in (
attribute attribute
@@ -644,10 +669,21 @@ class Server(EventEmitter):
and attribute.handle <= request.ending_handle and attribute.handle <= request.ending_handle
and pdu_space_available and pdu_space_available
): ):
# TODO: check permissions
try:
attribute_value = attribute.read_value(connection)
except ATT_Error as error:
# If the first attribute is unreadable, return an error
# Otherwise return attributes up to this point
if not attributes:
response = ATT_Error_Response(
request_opcode_in_error=request.op_code,
attribute_handle_in_error=attribute.handle,
error_code=error.error_code,
)
break
# Check the attribute value size # Check the attribute value size
attribute_value = attribute.read_value(connection)
max_attribute_size = min(connection.att_mtu - 4, 253) max_attribute_size = min(connection.att_mtu - 4, 253)
if len(attribute_value) > max_attribute_size: if len(attribute_value) > max_attribute_size:
# We need to truncate # We need to truncate
@@ -673,11 +709,7 @@ class Server(EventEmitter):
length=entry_size, attribute_data_list=b''.join(attribute_data_list) length=entry_size, attribute_data_list=b''.join(attribute_data_list)
) )
else: else:
response = ATT_Error_Response( logging.debug(f"not found {request}")
request_opcode_in_error=request.op_code,
attribute_handle_in_error=request.starting_handle,
error_code=ATT_ATTRIBUTE_NOT_FOUND_ERROR,
)
self.send_response(connection, response) self.send_response(connection, response)
@@ -687,10 +719,17 @@ class Server(EventEmitter):
''' '''
if attribute := self.get_attribute(request.attribute_handle): if attribute := self.get_attribute(request.attribute_handle):
# TODO: check permissions try:
value = attribute.read_value(connection) value = attribute.read_value(connection)
value_size = min(connection.att_mtu - 1, len(value)) except ATT_Error as error:
response = ATT_Read_Response(attribute_value=value[:value_size]) response = ATT_Error_Response(
request_opcode_in_error=request.op_code,
attribute_handle_in_error=request.attribute_handle,
error_code=error.error_code,
)
else:
value_size = min(connection.att_mtu - 1, len(value))
response = ATT_Read_Response(attribute_value=value[:value_size])
else: else:
response = ATT_Error_Response( response = ATT_Error_Response(
request_opcode_in_error=request.op_code, request_opcode_in_error=request.op_code,
@@ -705,29 +744,36 @@ class Server(EventEmitter):
''' '''
if attribute := self.get_attribute(request.attribute_handle): if attribute := self.get_attribute(request.attribute_handle):
# TODO: check permissions try:
value = attribute.read_value(connection) value = attribute.read_value(connection)
if request.value_offset > len(value): except ATT_Error as error:
response = ATT_Error_Response( response = ATT_Error_Response(
request_opcode_in_error=request.op_code, request_opcode_in_error=request.op_code,
attribute_handle_in_error=request.attribute_handle, attribute_handle_in_error=request.attribute_handle,
error_code=ATT_INVALID_OFFSET_ERROR, error_code=error.error_code,
)
elif len(value) <= connection.att_mtu - 1:
response = ATT_Error_Response(
request_opcode_in_error=request.op_code,
attribute_handle_in_error=request.attribute_handle,
error_code=ATT_ATTRIBUTE_NOT_LONG_ERROR,
) )
else: else:
part_size = min( if request.value_offset > len(value):
connection.att_mtu - 1, len(value) - request.value_offset response = ATT_Error_Response(
) request_opcode_in_error=request.op_code,
response = ATT_Read_Blob_Response( attribute_handle_in_error=request.attribute_handle,
part_attribute_value=value[ error_code=ATT_INVALID_OFFSET_ERROR,
request.value_offset : request.value_offset + part_size )
] elif len(value) <= connection.att_mtu - 1:
) response = ATT_Error_Response(
request_opcode_in_error=request.op_code,
attribute_handle_in_error=request.attribute_handle,
error_code=ATT_ATTRIBUTE_NOT_LONG_ERROR,
)
else:
part_size = min(
connection.att_mtu - 1, len(value) - request.value_offset
)
response = ATT_Read_Blob_Response(
part_attribute_value=value[
request.value_offset : request.value_offset + part_size
]
)
else: else:
response = ATT_Error_Response( response = ATT_Error_Response(
request_opcode_in_error=request.op_code, request_opcode_in_error=request.op_code,
@@ -743,7 +789,6 @@ class Server(EventEmitter):
if request.attribute_group_type not in ( if request.attribute_group_type not in (
GATT_PRIMARY_SERVICE_ATTRIBUTE_TYPE, GATT_PRIMARY_SERVICE_ATTRIBUTE_TYPE,
GATT_SECONDARY_SERVICE_ATTRIBUTE_TYPE, GATT_SECONDARY_SERVICE_ATTRIBUTE_TYPE,
GATT_INCLUDE_ATTRIBUTE_TYPE,
): ):
response = ATT_Error_Response( response = ATT_Error_Response(
request_opcode_in_error=request.op_code, request_opcode_in_error=request.op_code,
@@ -763,8 +808,10 @@ class Server(EventEmitter):
and attribute.handle <= request.ending_handle and attribute.handle <= request.ending_handle
and pdu_space_available and pdu_space_available
): ):
# Check the attribute value size # No need to catch permission errors here, since these attributes
# must all be world-readable
attribute_value = attribute.read_value(connection) attribute_value = attribute.read_value(connection)
# Check the attribute value size
max_attribute_size = min(connection.att_mtu - 6, 251) max_attribute_size = min(connection.att_mtu - 6, 251)
if len(attribute_value) > max_attribute_size: if len(attribute_value) > max_attribute_size:
# We need to truncate # We need to truncate
+59 -9
View File
@@ -15,12 +15,14 @@
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
# Imports # Imports
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
from __future__ import annotations
import struct import struct
import collections import collections
import logging import logging
import functools import functools
from colors import color from typing import Dict, Type, Union
from .colors import color
from .core import ( from .core import (
BT_BR_EDR_TRANSPORT, BT_BR_EDR_TRANSPORT,
AdvertisingData, AdvertisingData,
@@ -1419,7 +1421,11 @@ class HCI_Constant:
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
class HCI_Error(ProtocolError): class HCI_Error(ProtocolError):
def __init__(self, error_code): def __init__(self, error_code):
super().__init__(error_code, 'hci', HCI_Constant.error_name(error_code)) super().__init__(
error_code,
error_namespace='hci',
error_name=HCI_Constant.error_name(error_code),
)
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
@@ -1485,7 +1491,7 @@ class HCI_Object:
elif field_type == -2: elif field_type == -2:
# 16-bit signed # 16-bit signed
field_value = struct.unpack_from('<h', data, offset)[0] field_value = struct.unpack_from('<h', data, offset)[0]
offset += 1 offset += 2
elif field_type == 3: elif field_type == 3:
# 24-bit unsigned # 24-bit unsigned
padded = data[offset : offset + 3] + bytes([0]) padded = data[offset : offset + 3] + bytes([0])
@@ -1638,8 +1644,8 @@ class HCI_Object:
# Map the value if needed # Map the value if needed
if value_mappers: if value_mappers:
value_mapper = value_mappers.get(key, value_mapper) value_mapper = value_mappers.get(key, value_mapper)
if value_mapper is not None: if value_mapper is not None:
value = value_mapper(value) value = value_mapper(value)
# Get the string representation of the value # Get the string representation of the value
value_str = HCI_Object.format_field_value( value_str = HCI_Object.format_field_value(
@@ -1690,6 +1696,11 @@ class Address:
RANDOM_IDENTITY_ADDRESS: 'RANDOM_IDENTITY_ADDRESS', RANDOM_IDENTITY_ADDRESS: 'RANDOM_IDENTITY_ADDRESS',
} }
# Type declarations
NIL: Address
ANY: Address
ANY_RANDOM: Address
# pylint: disable-next=unnecessary-lambda # pylint: disable-next=unnecessary-lambda
ADDRESS_TYPE_SPEC = {'size': 1, 'mapper': lambda x: Address.address_type_name(x)} ADDRESS_TYPE_SPEC = {'size': 1, 'mapper': lambda x: Address.address_type_name(x)}
@@ -1722,7 +1733,9 @@ class Address:
address_type = data[offset - 1] address_type = data[offset - 1]
return Address.parse_address_with_type(data, offset, address_type) return Address.parse_address_with_type(data, offset, address_type)
def __init__(self, address, address_type=RANDOM_DEVICE_ADDRESS): def __init__(
self, address: Union[bytes, str], address_type: int = RANDOM_DEVICE_ADDRESS
):
''' '''
Initialize an instance. `address` may be a byte array in little-endian Initialize an instance. `address` may be a byte array in little-endian
format, or a hex string in big-endian format (with optional ':' format, or a hex string in big-endian format (with optional ':'
@@ -1807,6 +1820,7 @@ class Address:
# Predefined address values # Predefined address values
Address.NIL = Address(b"\xff\xff\xff\xff\xff\xff", Address.PUBLIC_DEVICE_ADDRESS) Address.NIL = Address(b"\xff\xff\xff\xff\xff\xff", Address.PUBLIC_DEVICE_ADDRESS)
Address.ANY = Address(b"\x00\x00\x00\x00\x00\x00", Address.PUBLIC_DEVICE_ADDRESS) Address.ANY = Address(b"\x00\x00\x00\x00\x00\x00", Address.PUBLIC_DEVICE_ADDRESS)
Address.ANY_RANDOM = Address(b"\x00\x00\x00\x00\x00\x00", Address.RANDOM_DEVICE_ADDRESS)
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
class OwnAddressType: class OwnAddressType:
@@ -1836,6 +1850,8 @@ class HCI_Packet:
Abstract Base class for HCI packets Abstract Base class for HCI packets
''' '''
hci_packet_type: int
@staticmethod @staticmethod
def from_bytes(packet): def from_bytes(packet):
packet_type = packet[0] packet_type = packet[0]
@@ -1854,6 +1870,9 @@ class HCI_Packet:
def __init__(self, name): def __init__(self, name):
self.name = name self.name = name
def __bytes__(self) -> bytes:
raise NotImplementedError
def __repr__(self) -> str: def __repr__(self) -> str:
return self.name return self.name
@@ -1865,6 +1884,9 @@ class HCI_CustomPacket(HCI_Packet):
self.hci_packet_type = payload[0] self.hci_packet_type = payload[0]
self.payload = payload self.payload = payload
def __bytes__(self) -> bytes:
return self.payload
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
class HCI_Command(HCI_Packet): class HCI_Command(HCI_Packet):
@@ -1873,7 +1895,7 @@ class HCI_Command(HCI_Packet):
''' '''
hci_packet_type = HCI_COMMAND_PACKET hci_packet_type = HCI_COMMAND_PACKET
command_classes = {} command_classes: Dict[int, Type[HCI_Command]] = {}
@staticmethod @staticmethod
def command(fields=(), return_parameters_fields=()): def command(fields=(), return_parameters_fields=()):
@@ -2075,6 +2097,24 @@ class HCI_Link_Key_Request_Negative_Reply_Command(HCI_Command):
''' '''
# -----------------------------------------------------------------------------
@HCI_Command.command(
fields=[
('bd_addr', Address.parse_address),
('pin_code_length', 1),
('pin_code', 16),
],
return_parameters_fields=[
('status', STATUS_SPEC),
('bd_addr', Address.parse_address),
],
)
class HCI_PIN_Code_Request_Reply_Command(HCI_Command):
'''
See Bluetooth spec @ 7.1.12 PIN Code Request Reply Command
'''
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
@HCI_Command.command( @HCI_Command.command(
fields=[('bd_addr', Address.parse_address)], fields=[('bd_addr', Address.parse_address)],
@@ -3104,6 +3144,16 @@ class HCI_LE_Read_Remote_Features_Command(HCI_Command):
''' '''
# -----------------------------------------------------------------------------
@HCI_Command.command(
return_parameters_fields=[("status", STATUS_SPEC), ("random_number", 8)]
)
class HCI_LE_Rand_Command(HCI_Command):
"""
See Bluetooth spec @ 7.8.23 LE Rand Command
"""
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
@HCI_Command.command( @HCI_Command.command(
[ [
@@ -4009,8 +4059,8 @@ class HCI_Event(HCI_Packet):
''' '''
hci_packet_type = HCI_EVENT_PACKET hci_packet_type = HCI_EVENT_PACKET
event_classes = {} event_classes: Dict[int, Type[HCI_Event]] = {}
meta_event_classes = {} meta_event_classes: Dict[int, Type[HCI_LE_Meta_Event]] = {}
@staticmethod @staticmethod
def event(fields=()): def event(fields=()):
+1 -1
View File
@@ -16,8 +16,8 @@
# Imports # Imports
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
import logging import logging
from colors import color
from .colors import color
from .att import ATT_CID, ATT_PDU from .att import ATT_CID, ATT_PDU
from .smp import SMP_CID, SMP_Command from .smp import SMP_CID, SMP_Command
from .core import name_or_number from .core import name_or_number
+2 -1
View File
@@ -18,7 +18,8 @@
import logging import logging
import asyncio import asyncio
import collections import collections
from colors import color
from .colors import color
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
+47 -24
View File
@@ -20,11 +20,14 @@ import collections
import logging import logging
import struct import struct
from colors import color from bumble.colors import color
from bumble.l2cap import L2CAP_PDU from bumble.l2cap import L2CAP_PDU
from bumble.snoop import Snooper
from typing import Optional
from .hci import ( from .hci import (
Address,
HCI_ACL_DATA_PACKET, HCI_ACL_DATA_PACKET,
HCI_COMMAND_COMPLETE_EVENT, HCI_COMMAND_COMPLETE_EVENT,
HCI_COMMAND_PACKET, HCI_COMMAND_PACKET,
@@ -53,7 +56,6 @@ from .hci import (
HCI_LE_Write_Suggested_Default_Data_Length_Command, HCI_LE_Write_Suggested_Default_Data_Length_Command,
HCI_Link_Key_Request_Negative_Reply_Command, HCI_Link_Key_Request_Negative_Reply_Command,
HCI_Link_Key_Request_Reply_Command, HCI_Link_Key_Request_Reply_Command,
HCI_PIN_Code_Request_Negative_Reply_Command,
HCI_Packet, HCI_Packet,
HCI_Read_Buffer_Size_Command, HCI_Read_Buffer_Size_Command,
HCI_Read_Local_Supported_Commands_Command, HCI_Read_Local_Supported_Commands_Command,
@@ -92,10 +94,9 @@ HOST_HC_TOTAL_NUM_ACL_DATA_PACKETS = 1
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
class Connection: class Connection:
def __init__(self, host, handle, role, peer_address, transport): def __init__(self, host, handle, peer_address, transport):
self.host = host self.host = host
self.handle = handle self.handle = handle
self.role = role
self.peer_address = peer_address self.peer_address = peer_address
self.assembler = HCI_AclDataPacketAssembler(self.on_acl_pdu) self.assembler = HCI_AclDataPacketAssembler(self.on_acl_pdu)
self.transport = transport self.transport = transport
@@ -134,6 +135,7 @@ class Host(AbortableEventEmitter):
self.long_term_key_provider = None self.long_term_key_provider = None
self.link_key_provider = None self.link_key_provider = None
self.pairing_io_capability_provider = None # Classic only self.pairing_io_capability_provider = None # Classic only
self.snooper = None
# Connect to the source and sink if specified # Connect to the source and sink if specified
if controller_source: if controller_source:
@@ -141,7 +143,25 @@ class Host(AbortableEventEmitter):
if controller_sink: if controller_sink:
self.set_packet_sink(controller_sink) self.set_packet_sink(controller_sink)
async def flush(self): def find_connection_by_bd_addr(
self,
bd_addr: Address,
transport: Optional[int] = None,
check_address_type: bool = False,
) -> Optional[Connection]:
for connection in self.connections.values():
if connection.peer_address.to_bytes() == bd_addr.to_bytes():
if (
check_address_type
and connection.peer_address.address_type != bd_addr.address_type
):
continue
if transport is None or connection.transport == transport:
return connection
return None
async def flush(self) -> None:
# Make sure no command is pending # Make sure no command is pending
await self.command_semaphore.acquire() await self.command_semaphore.acquire()
@@ -274,6 +294,9 @@ class Host(AbortableEventEmitter):
self.hci_sink = sink self.hci_sink = sink
def send_hci_packet(self, packet): def send_hci_packet(self, packet):
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(packet.to_bytes())
async def send_command(self, command, check_result=False): async def send_command(self, command, check_result=False):
@@ -372,8 +395,8 @@ class Host(AbortableEventEmitter):
def supports_command(self, command): def supports_command(self, command):
# Find the support flag position for this command # Find the support flag position for this command
for (octet, flags) in enumerate(HCI_SUPPORTED_COMMANDS_FLAGS): for octet, flags in enumerate(HCI_SUPPORTED_COMMANDS_FLAGS):
for (flag_position, value) in enumerate(flags): for flag_position, value in enumerate(flags):
if value == command: if value == command:
# Check if the flag is set # Check if the flag is set
if octet < len(self.local_supported_commands) and flag_position < 8: if octet < len(self.local_supported_commands) and flag_position < 8:
@@ -386,7 +409,7 @@ class Host(AbortableEventEmitter):
@property @property
def supported_commands(self): def supported_commands(self):
commands = [] commands = []
for (octet, flags) in enumerate(self.local_supported_commands): for octet, flags in enumerate(self.local_supported_commands):
if octet < len(HCI_SUPPORTED_COMMANDS_FLAGS): if octet < len(HCI_SUPPORTED_COMMANDS_FLAGS):
for flag in range(8): for flag in range(8):
if flags & (1 << flag) != 0: if flags & (1 << flag) != 0:
@@ -420,6 +443,9 @@ class Host(AbortableEventEmitter):
def on_hci_packet(self, packet): def on_hci_packet(self, packet):
logger.debug(f'{color("### CONTROLLER -> HOST", "green")}: {packet}') 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 the packet is a command, invoke the handler for this packet
if packet.hci_packet_type == HCI_COMMAND_PACKET: if packet.hci_packet_type == HCI_COMMAND_PACKET:
self.on_hci_command_packet(packet) self.on_hci_command_packet(packet)
@@ -507,7 +533,7 @@ class Host(AbortableEventEmitter):
if event.status == HCI_SUCCESS: if event.status == HCI_SUCCESS:
# Create/update the connection # Create/update the connection
logger.debug( logger.debug(
f'### CONNECTION: [0x{event.connection_handle:04X}] ' f'### LE CONNECTION: [0x{event.connection_handle:04X}] '
f'{event.peer_address} as {HCI_Constant.role_name(event.role)}' f'{event.peer_address} as {HCI_Constant.role_name(event.role)}'
) )
@@ -516,7 +542,6 @@ class Host(AbortableEventEmitter):
connection = Connection( connection = Connection(
self, self,
event.connection_handle, event.connection_handle,
event.role,
event.peer_address, event.peer_address,
BT_LE_TRANSPORT, BT_LE_TRANSPORT,
) )
@@ -533,7 +558,6 @@ class Host(AbortableEventEmitter):
event.connection_handle, event.connection_handle,
BT_LE_TRANSPORT, BT_LE_TRANSPORT,
event.peer_address, event.peer_address,
None,
event.role, event.role,
connection_parameters, connection_parameters,
) )
@@ -562,7 +586,6 @@ class Host(AbortableEventEmitter):
connection = Connection( connection = Connection(
self, self,
event.connection_handle, event.connection_handle,
BT_CENTRAL_ROLE,
event.bd_addr, event.bd_addr,
BT_BR_EDR_TRANSPORT, BT_BR_EDR_TRANSPORT,
) )
@@ -575,7 +598,6 @@ class Host(AbortableEventEmitter):
BT_BR_EDR_TRANSPORT, BT_BR_EDR_TRANSPORT,
event.bd_addr, event.bd_addr,
None, None,
BT_CENTRAL_ROLE,
None, None,
) )
else: else:
@@ -595,8 +617,7 @@ class Host(AbortableEventEmitter):
if event.status == HCI_SUCCESS: if event.status == HCI_SUCCESS:
logger.debug( logger.debug(
f'### DISCONNECTION: [0x{event.connection_handle:04X}] ' f'### DISCONNECTION: [0x{event.connection_handle:04X}] '
f'{connection.peer_address} as ' f'{connection.peer_address} '
f'{HCI_Constant.role_name(connection.role)}, '
f'reason={event.reason}' f'reason={event.reason}'
) )
del self.connections[event.connection_handle] del self.connections[event.connection_handle]
@@ -660,7 +681,7 @@ class Host(AbortableEventEmitter):
connection_handle=event.connection_handle, connection_handle=event.connection_handle,
interval_min=event.interval_min, interval_min=event.interval_min,
interval_max=event.interval_max, interval_max=event.interval_max,
latency=event.latency, max_latency=event.max_latency,
timeout=event.timeout, timeout=event.timeout,
min_ce_length=0, min_ce_length=0,
max_ce_length=0, max_ce_length=0,
@@ -712,12 +733,13 @@ class Host(AbortableEventEmitter):
f'role change for {event.bd_addr}: ' f'role change for {event.bd_addr}: '
f'{HCI_Constant.role_name(event.new_role)}' f'{HCI_Constant.role_name(event.new_role)}'
) )
# TODO: lookup the connection and update the role self.emit('role_change', event.bd_addr, event.new_role)
else: else:
logger.debug( logger.debug(
f'role change for {event.bd_addr} failed: ' f'role change for {event.bd_addr} failed: '
f'{HCI_Constant.error_name(event.status)}' f'{HCI_Constant.error_name(event.status)}'
) )
self.emit('role_change_failure', event.bd_addr, event.status)
def on_hci_le_data_length_change_event(self, event): def on_hci_le_data_length_change_event(self, event):
self.emit( self.emit(
@@ -787,11 +809,7 @@ class Host(AbortableEventEmitter):
) )
def on_hci_pin_code_request_event(self, event): def on_hci_pin_code_request_event(self, event):
# For now, just refuse all requests self.emit('pin_code_request', event.bd_addr)
# TODO: delegate the decision
self.send_command_sync(
HCI_PIN_Code_Request_Negative_Reply_Command(bd_addr=event.bd_addr)
)
def on_hci_link_key_request_event(self, event): def on_hci_link_key_request_event(self, event):
async def send_link_key(): async def send_link_key():
@@ -821,7 +839,12 @@ class Host(AbortableEventEmitter):
self.emit('authentication_io_capability_request', event.bd_addr) self.emit('authentication_io_capability_request', event.bd_addr)
def on_hci_io_capability_response_event(self, event): def on_hci_io_capability_response_event(self, event):
pass self.emit(
'authentication_io_capability_response',
event.bd_addr,
event.io_capability,
event.authentication_requirements,
)
def on_hci_user_confirmation_request_event(self, event): def on_hci_user_confirmation_request_event(self, event):
self.emit( self.emit(
+32 -15
View File
@@ -20,14 +20,19 @@
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
# Imports # Imports
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
from __future__ import annotations
import asyncio import asyncio
import logging import logging
import os import os
import json import json
from colors import color from typing import TYPE_CHECKING, Optional
from .colors import color
from .hci import Address from .hci import Address
if TYPE_CHECKING:
from .device import Device
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
# Logging # Logging
@@ -129,7 +134,7 @@ class PairingKeys:
for (key_property, key_value) in value.items(): for (key_property, key_value) in value.items():
print(f'{prefix} {color(key_property, "green")}: {key_value}') print(f'{prefix} {color(key_property, "green")}: {key_value}')
else: else:
print(f'{prefix}{color(property, "cyan")}: {value}') print(f'{prefix}{color(container_property, "cyan")}: {value}')
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
@@ -172,13 +177,13 @@ class KeyStore:
separator = '\n' separator = '\n'
@staticmethod @staticmethod
def create_for_device(device_config): def create_for_device(device: Device) -> Optional[KeyStore]:
if device_config.keystore is None: if device.config.keystore is None:
return None return None
keystore_type = device_config.keystore.split(':', 1)[0] keystore_type = device.config.keystore.split(':', 1)[0]
if keystore_type == 'JsonKeyStore': if keystore_type == 'JsonKeyStore':
return JsonKeyStore.from_device_config(device_config) return JsonKeyStore.from_device(device)
return None return None
@@ -203,7 +208,9 @@ class JsonKeyStore(KeyStore):
self.directory_name = os.path.join( self.directory_name = os.path.join(
appdirs.user_data_dir(self.APP_NAME, self.APP_AUTHOR), self.KEYS_DIR appdirs.user_data_dir(self.APP_NAME, self.APP_AUTHOR), self.KEYS_DIR
) )
json_filename = f'{self.namespace}.json'.lower().replace(':', '-') json_filename = (
f'{self.namespace}.json'.lower().replace(':', '-').replace('/p', '-p')
)
self.filename = os.path.join(self.directory_name, json_filename) self.filename = os.path.join(self.directory_name, json_filename)
else: else:
self.filename = filename self.filename = filename
@@ -212,11 +219,21 @@ class JsonKeyStore(KeyStore):
logger.debug(f'JSON keystore: {self.filename}') logger.debug(f'JSON keystore: {self.filename}')
@staticmethod @staticmethod
def from_device_config(device_config): def from_device(device: Device) -> Optional[JsonKeyStore]:
params = device_config.keystore.split(':', 1)[1:] if not device.config.keystore:
namespace = str(device_config.address) return None
params = device.config.keystore.split(':', 1)[1:]
# Use a namespace based on the device address
if device.public_address not in (Address.ANY, Address.ANY_RANDOM):
namespace = str(device.public_address)
elif device.random_address != Address.ANY_RANDOM:
namespace = str(device.random_address)
else:
namespace = JsonKeyStore.DEFAULT_NAMESPACE
if params: if params:
filename = params[1] filename = params[0]
else: else:
filename = None filename = None
@@ -240,9 +257,9 @@ class JsonKeyStore(KeyStore):
json.dump(db, output, sort_keys=True, indent=4) json.dump(db, output, sort_keys=True, indent=4)
# Atomically replace the previous file # Atomically replace the previous file
os.rename(temp_filename, self.filename) os.replace(temp_filename, self.filename)
async def delete(self, name): async def delete(self, name: str) -> None:
db = await self.load() db = await self.load()
namespace = db.get(self.namespace) namespace = db.get(self.namespace)
@@ -256,7 +273,7 @@ class JsonKeyStore(KeyStore):
db = await self.load() db = await self.load()
namespace = db.setdefault(self.namespace, {}) namespace = db.setdefault(self.namespace, {})
namespace[name] = keys.to_dict() namespace.setdefault(name, {}).update(keys.to_dict())
await self.save(db) await self.save(db)
@@ -278,7 +295,7 @@ class JsonKeyStore(KeyStore):
await self.save(db) await self.save(db)
async def get(self, name): async def get(self, name: str) -> Optional[PairingKeys]:
db = await self.load() db = await self.load()
namespace = db.get(self.namespace) namespace = db.get(self.namespace)
+20 -5
View File
@@ -15,14 +15,16 @@
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
# Imports # Imports
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
from __future__ import annotations
import asyncio import asyncio
import logging import logging
import struct import struct
from collections import deque from collections import deque
from colors import color
from pyee import EventEmitter from pyee import EventEmitter
from typing import Dict, Type
from .colors import color
from .core import BT_CENTRAL_ROLE, InvalidStateError, ProtocolError from .core import BT_CENTRAL_ROLE, InvalidStateError, ProtocolError
from .hci import ( from .hci import (
HCI_LE_Connection_Update_Command, HCI_LE_Connection_Update_Command,
@@ -184,7 +186,7 @@ class L2CAP_Control_Frame:
See Bluetooth spec @ Vol 3, Part A - 4 SIGNALING PACKET FORMATS See Bluetooth spec @ Vol 3, Part A - 4 SIGNALING PACKET FORMATS
''' '''
classes = {} classes: Dict[int, Type[L2CAP_Control_Frame]] = {}
code = 0 code = 0
name = None name = None
@@ -383,7 +385,7 @@ class L2CAP_Connection_Response(L2CAP_Control_Frame):
CONNECTION_SUCCESSFUL = 0x0000 CONNECTION_SUCCESSFUL = 0x0000
CONNECTION_PENDING = 0x0001 CONNECTION_PENDING = 0x0001
CONNECTION_REFUSED_LE_PSM_NOT_SUPPORTED = 0x0002 CONNECTION_REFUSED_PSM_NOT_SUPPORTED = 0x0002
CONNECTION_REFUSED_SECURITY_BLOCK = 0x0003 CONNECTION_REFUSED_SECURITY_BLOCK = 0x0003
CONNECTION_REFUSED_NO_RESOURCES_AVAILABLE = 0x0004 CONNECTION_REFUSED_NO_RESOURCES_AVAILABLE = 0x0004
CONNECTION_REFUSED_INVALID_SOURCE_CID = 0x0006 CONNECTION_REFUSED_INVALID_SOURCE_CID = 0x0006
@@ -394,7 +396,7 @@ class L2CAP_Connection_Response(L2CAP_Control_Frame):
RESULT_NAMES = { RESULT_NAMES = {
CONNECTION_SUCCESSFUL: 'CONNECTION_SUCCESSFUL', CONNECTION_SUCCESSFUL: 'CONNECTION_SUCCESSFUL',
CONNECTION_PENDING: 'CONNECTION_PENDING', CONNECTION_PENDING: 'CONNECTION_PENDING',
CONNECTION_REFUSED_LE_PSM_NOT_SUPPORTED: 'CONNECTION_REFUSED_LE_PSM_NOT_SUPPORTED', CONNECTION_REFUSED_PSM_NOT_SUPPORTED: 'CONNECTION_REFUSED_PSM_NOT_SUPPORTED',
CONNECTION_REFUSED_SECURITY_BLOCK: 'CONNECTION_REFUSED_SECURITY_BLOCK', CONNECTION_REFUSED_SECURITY_BLOCK: 'CONNECTION_REFUSED_SECURITY_BLOCK',
CONNECTION_REFUSED_NO_RESOURCES_AVAILABLE: 'CONNECTION_REFUSED_NO_RESOURCES_AVAILABLE', CONNECTION_REFUSED_NO_RESOURCES_AVAILABLE: 'CONNECTION_REFUSED_NO_RESOURCES_AVAILABLE',
CONNECTION_REFUSED_INVALID_SOURCE_CID: 'CONNECTION_REFUSED_INVALID_SOURCE_CID', CONNECTION_REFUSED_INVALID_SOURCE_CID: 'CONNECTION_REFUSED_INVALID_SOURCE_CID',
@@ -794,6 +796,11 @@ class Channel(EventEmitter):
self.disconnection_result = asyncio.get_running_loop().create_future() self.disconnection_result = asyncio.get_running_loop().create_future()
return await self.disconnection_result return await self.disconnection_result
def abort(self):
if self.state == self.OPEN:
self.change_state(self.CLOSED)
self.emit('close')
def send_configure_request(self): def send_configure_request(self):
options = L2CAP_Control_Frame.encode_configuration_options( options = L2CAP_Control_Frame.encode_configuration_options(
[ [
@@ -1103,6 +1110,10 @@ class LeConnectionOrientedChannel(EventEmitter):
self.disconnection_result = asyncio.get_running_loop().create_future() self.disconnection_result = asyncio.get_running_loop().create_future()
return await self.disconnection_result return await self.disconnection_result
def abort(self):
if self.state == self.CONNECTED:
self.change_state(self.DISCONNECTED)
def on_pdu(self, pdu): def on_pdu(self, pdu):
if self.sink is None: if self.sink is None:
logger.warning('received pdu without a sink') logger.warning('received pdu without a sink')
@@ -1490,8 +1501,12 @@ class ChannelManager:
def on_disconnection(self, connection_handle, _reason): def on_disconnection(self, connection_handle, _reason):
logger.debug(f'disconnection from {connection_handle}, cleaning up channels') logger.debug(f'disconnection from {connection_handle}, cleaning up channels')
if connection_handle in self.channels: if connection_handle in self.channels:
for _, channel in self.channels[connection_handle].items():
channel.abort()
del self.channels[connection_handle] del self.channels[connection_handle]
if connection_handle in self.le_coc_channels: if connection_handle in self.le_coc_channels:
for _, channel in self.le_coc_channels[connection_handle].items():
channel.abort()
del self.le_coc_channels[connection_handle] del self.le_coc_channels[connection_handle]
if connection_handle in self.identifiers: if connection_handle in self.identifiers:
del self.identifiers[connection_handle] del self.identifiers[connection_handle]
@@ -1619,7 +1634,7 @@ class ChannelManager:
destination_cid=request.source_cid, destination_cid=request.source_cid,
source_cid=0, source_cid=0,
# pylint: disable=line-too-long # pylint: disable=line-too-long
result=L2CAP_Connection_Response.CONNECTION_REFUSED_LE_PSM_NOT_SUPPORTED, result=L2CAP_Connection_Response.CONNECTION_REFUSED_PSM_NOT_SUPPORTED,
status=0x0000, status=0x0000,
), ),
) )
+121 -9
View File
@@ -19,14 +19,15 @@ import logging
import asyncio import asyncio
from functools import partial from functools import partial
from colors import color from bumble.core import BT_PERIPHERAL_ROLE, BT_BR_EDR_TRANSPORT, BT_LE_TRANSPORT
import websockets from bumble.colors import color
from bumble.hci import ( from bumble.hci import (
Address, Address,
HCI_SUCCESS, HCI_SUCCESS,
HCI_CONNECTION_ACCEPT_TIMEOUT_ERROR, HCI_CONNECTION_ACCEPT_TIMEOUT_ERROR,
HCI_CONNECTION_TIMEOUT_ERROR, HCI_CONNECTION_TIMEOUT_ERROR,
HCI_PAGE_TIMEOUT_ERROR,
HCI_Connection_Complete_Event,
) )
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
@@ -59,6 +60,11 @@ class LocalLink:
def __init__(self): def __init__(self):
self.controllers = set() self.controllers = set()
self.pending_connection = None self.pending_connection = None
self.pending_classic_connection = None
############################################################
# Common utils
############################################################
def add_controller(self, controller): def add_controller(self, controller):
logger.debug(f'new controller: {controller}') logger.debug(f'new controller: {controller}')
@@ -73,22 +79,39 @@ class LocalLink:
return controller return controller
return None return None
def on_address_changed(self, controller): def find_classic_controller(self, address):
pass for controller in self.controllers:
if controller.public_address == address:
return controller
return None
def get_pending_connection(self): def get_pending_connection(self):
return self.pending_connection return self.pending_connection
############################################################
# LE handlers
############################################################
def on_address_changed(self, controller):
pass
def send_advertising_data(self, sender_address, data): def send_advertising_data(self, sender_address, data):
# Send the advertising data to all controllers, except the sender # Send the advertising data to all controllers, except the sender
for controller in self.controllers: for controller in self.controllers:
if controller.random_address != sender_address: if controller.random_address != sender_address:
controller.on_link_advertising_data(sender_address, data) controller.on_link_advertising_data(sender_address, data)
def send_acl_data(self, sender_address, destination_address, data): def send_acl_data(self, sender_controller, destination_address, transport, data):
# Send the data to the first controller with a matching address # Send the data to the first controller with a matching address
if controller := self.find_controller(destination_address): if transport == BT_LE_TRANSPORT:
controller.on_link_acl_data(sender_address, data) destination_controller = self.find_controller(destination_address)
source_address = sender_controller.random_address
elif transport == BT_BR_EDR_TRANSPORT:
destination_controller = self.find_classic_controller(destination_address)
source_address = sender_controller.public_address
if destination_controller is not None:
destination_controller.on_link_acl_data(source_address, transport, data)
def on_connection_complete(self): def on_connection_complete(self):
# Check that we expect this call # Check that we expect this call
@@ -165,6 +188,89 @@ class LocalLink:
if peripheral_controller := self.find_controller(peripheral_address): if peripheral_controller := self.find_controller(peripheral_address):
peripheral_controller.on_link_encrypted(central_address, rand, ediv, ltk) peripheral_controller.on_link_encrypted(central_address, rand, ediv, ltk)
############################################################
# Classic handlers
############################################################
def classic_connect(self, initiator_controller, responder_address):
logger.debug(
f'[Classic] {initiator_controller.public_address} connects to {responder_address}'
)
responder_controller = self.find_classic_controller(responder_address)
if responder_controller is None:
initiator_controller.on_classic_connection_complete(
responder_address, HCI_PAGE_TIMEOUT_ERROR
)
return
self.pending_classic_connection = (initiator_controller, responder_controller)
responder_controller.on_classic_connection_request(
initiator_controller.public_address,
HCI_Connection_Complete_Event.ACL_LINK_TYPE,
)
def classic_accept_connection(
self, responder_controller, initiator_address, responder_role
):
logger.debug(
f'[Classic] {responder_controller.public_address} accepts to connect {initiator_address}'
)
initiator_controller = self.find_classic_controller(initiator_address)
if initiator_controller is None:
responder_controller.on_classic_connection_complete(
responder_controller.public_address, HCI_PAGE_TIMEOUT_ERROR
)
return
async def task():
if responder_role != BT_PERIPHERAL_ROLE:
initiator_controller.on_classic_role_change(
responder_controller.public_address, int(not (responder_role))
)
initiator_controller.on_classic_connection_complete(
responder_controller.public_address, HCI_SUCCESS
)
asyncio.create_task(task())
responder_controller.on_classic_role_change(
initiator_controller.public_address, responder_role
)
responder_controller.on_classic_connection_complete(
initiator_controller.public_address, HCI_SUCCESS
)
self.pending_classic_connection = None
def classic_disconnect(self, initiator_controller, responder_address, reason):
logger.debug(
f'[Classic] {initiator_controller.public_address} disconnects {responder_address}'
)
responder_controller = self.find_classic_controller(responder_address)
async def task():
initiator_controller.on_classic_disconnected(responder_address, reason)
asyncio.create_task(task())
responder_controller.on_classic_disconnected(
initiator_controller.public_address, reason
)
def classic_switch_role(
self, initiator_controller, responder_address, initiator_new_role
):
responder_controller = self.find_classic_controller(responder_address)
if responder_controller is None:
return
async def task():
initiator_controller.on_classic_role_change(
responder_address, initiator_new_role
)
asyncio.create_task(task())
responder_controller.on_classic_role_change(
initiator_controller.public_address, int(not (initiator_new_role))
)
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
class RemoteLink: class RemoteLink:
@@ -202,6 +308,9 @@ class RemoteLink:
def get_pending_connection(self): def get_pending_connection(self):
return self.pending_connection return self.pending_connection
def get_pending_classic_connection(self):
return self.pending_classic_connection
async def wait_until_connected(self): async def wait_until_connected(self):
await self.websocket await self.websocket
@@ -220,6 +329,8 @@ class RemoteLink:
) )
async def run_connection(self): async def run_connection(self):
import websockets # lazy import
# Connect to the relay # Connect to the relay
logger.debug(f'connecting to {self.uri}') logger.debug(f'connecting to {self.uri}')
# pylint: disable-next=no-member # pylint: disable-next=no-member
@@ -366,7 +477,8 @@ class RemoteLink:
async def send_acl_data_to_relay(self, peer_address, data): async def send_acl_data_to_relay(self, peer_address, data):
await self.send_targeted_message(peer_address, f'acl:{data.hex()}') await self.send_targeted_message(peer_address, f'acl:{data.hex()}')
def send_acl_data(self, _, peer_address, data): def send_acl_data(self, _, peer_address, _transport, data):
# TODO: handle different transport
self.execute(partial(self.send_acl_data_to_relay, peer_address, data)) self.execute(partial(self.send_acl_data_to_relay, peer_address, data))
async def send_connection_request_to_relay(self, peer_address): async def send_connection_request_to_relay(self, peer_address):
+184
View File
@@ -0,0 +1,184 @@
# 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 enum
from typing import Optional, Tuple
from .hci import (
HCI_NO_INPUT_NO_OUTPUT_IO_CAPABILITY,
HCI_DISPLAY_ONLY_IO_CAPABILITY,
HCI_DISPLAY_YES_NO_IO_CAPABILITY,
HCI_KEYBOARD_ONLY_IO_CAPABILITY,
)
from .smp import (
SMP_NO_INPUT_NO_OUTPUT_IO_CAPABILITY,
SMP_KEYBOARD_ONLY_IO_CAPABILITY,
SMP_DISPLAY_ONLY_IO_CAPABILITY,
SMP_DISPLAY_YES_NO_IO_CAPABILITY,
SMP_KEYBOARD_DISPLAY_IO_CAPABILITY,
SMP_ENC_KEY_DISTRIBUTION_FLAG,
SMP_ID_KEY_DISTRIBUTION_FLAG,
SMP_SIGN_KEY_DISTRIBUTION_FLAG,
SMP_LINK_KEY_DISTRIBUTION_FLAG,
)
# -----------------------------------------------------------------------------
class PairingDelegate:
"""Abstract base class for Pairing Delegates."""
# I/O Capabilities.
# These are defined abstractly, and can be mapped to specific Classic pairing
# and/or SMP constants.
class IoCapability(enum.IntEnum):
NO_OUTPUT_NO_INPUT = SMP_NO_INPUT_NO_OUTPUT_IO_CAPABILITY
KEYBOARD_INPUT_ONLY = SMP_KEYBOARD_ONLY_IO_CAPABILITY
DISPLAY_OUTPUT_ONLY = SMP_DISPLAY_ONLY_IO_CAPABILITY
DISPLAY_OUTPUT_AND_YES_NO_INPUT = SMP_DISPLAY_YES_NO_IO_CAPABILITY
DISPLAY_OUTPUT_AND_KEYBOARD_INPUT = SMP_KEYBOARD_DISPLAY_IO_CAPABILITY
# Direct names for backward compatibility.
NO_OUTPUT_NO_INPUT = IoCapability.NO_OUTPUT_NO_INPUT
KEYBOARD_INPUT_ONLY = IoCapability.KEYBOARD_INPUT_ONLY
DISPLAY_OUTPUT_ONLY = IoCapability.DISPLAY_OUTPUT_ONLY
DISPLAY_OUTPUT_AND_YES_NO_INPUT = IoCapability.DISPLAY_OUTPUT_AND_YES_NO_INPUT
DISPLAY_OUTPUT_AND_KEYBOARD_INPUT = IoCapability.DISPLAY_OUTPUT_AND_KEYBOARD_INPUT
# Key Distribution [LE only]
class KeyDistribution(enum.IntFlag):
DISTRIBUTE_ENCRYPTION_KEY = SMP_ENC_KEY_DISTRIBUTION_FLAG
DISTRIBUTE_IDENTITY_KEY = SMP_ID_KEY_DISTRIBUTION_FLAG
DISTRIBUTE_SIGNING_KEY = SMP_SIGN_KEY_DISTRIBUTION_FLAG
DISTRIBUTE_LINK_KEY = SMP_LINK_KEY_DISTRIBUTION_FLAG
DEFAULT_KEY_DISTRIBUTION: int = (
SMP_ENC_KEY_DISTRIBUTION_FLAG | SMP_ID_KEY_DISTRIBUTION_FLAG
)
# Default mapping from abstract to Classic I/O capabilities.
# Subclasses may override this if they prefer a different mapping.
CLASSIC_IO_CAPABILITIES_MAP = {
NO_OUTPUT_NO_INPUT: HCI_NO_INPUT_NO_OUTPUT_IO_CAPABILITY,
KEYBOARD_INPUT_ONLY: HCI_KEYBOARD_ONLY_IO_CAPABILITY,
DISPLAY_OUTPUT_ONLY: HCI_DISPLAY_ONLY_IO_CAPABILITY,
DISPLAY_OUTPUT_AND_YES_NO_INPUT: HCI_DISPLAY_YES_NO_IO_CAPABILITY,
DISPLAY_OUTPUT_AND_KEYBOARD_INPUT: HCI_DISPLAY_YES_NO_IO_CAPABILITY,
}
io_capability: IoCapability
local_initiator_key_distribution: KeyDistribution
local_responder_key_distribution: KeyDistribution
def __init__(
self,
io_capability=NO_OUTPUT_NO_INPUT,
local_initiator_key_distribution=DEFAULT_KEY_DISTRIBUTION,
local_responder_key_distribution=DEFAULT_KEY_DISTRIBUTION,
) -> None:
self.io_capability = io_capability
self.local_initiator_key_distribution = local_initiator_key_distribution
self.local_responder_key_distribution = local_responder_key_distribution
@property
def classic_io_capability(self) -> int:
"""Map the abstract I/O capability to a Classic constant."""
# pylint: disable=line-too-long
return self.CLASSIC_IO_CAPABILITIES_MAP.get(
self.io_capability, HCI_NO_INPUT_NO_OUTPUT_IO_CAPABILITY
)
@property
def smp_io_capability(self) -> int:
"""Map the abstract I/O capability to an SMP constant."""
# This is just a 1-1 direct mapping
return self.io_capability
async def accept(self) -> bool:
"""Accept or reject a Pairing request."""
return True
async def confirm(self) -> bool:
"""Respond yes or no to a Pairing confirmation question."""
return True
# pylint: disable-next=unused-argument
async def compare_numbers(self, number: int, digits: int) -> bool:
"""Compare two numbers."""
return True
async def get_number(self) -> Optional[int]:
"""
Return an optional number as an answer to a passkey request.
Returning `None` will result in a negative reply.
"""
return 0
async def get_string(self, max_length) -> Optional[str]:
"""
Return a string whose utf-8 encoding is up to max_length bytes.
"""
return None
# pylint: disable-next=unused-argument
async def display_number(self, number: int, digits: int) -> None:
"""Display a number."""
# [LE only]
async def key_distribution_response(
self, peer_initiator_key_distribution: int, peer_responder_key_distribution: int
) -> Tuple[int, int]:
"""
Return the key distribution response in an SMP protocol context.
NOTE: since it is only used by the SMP protocol, this method's input and output
are directly as integers, using the SMP constants, rather than the abstract
KeyDistribution enums.
"""
return (
int(
peer_initiator_key_distribution & self.local_initiator_key_distribution
),
int(
peer_responder_key_distribution & self.local_responder_key_distribution
),
)
# -----------------------------------------------------------------------------
class PairingConfig:
"""Configuration for the Pairing protocol."""
def __init__(
self,
sc: bool = True,
mitm: bool = True,
bonding: bool = True,
delegate: Optional[PairingDelegate] = None,
) -> None:
self.sc = sc
self.mitm = mitm
self.bonding = bonding
self.delegate = delegate or PairingDelegate()
def __str__(self) -> str:
return (
f'PairingConfig(sc={self.sc}, '
f'mitm={self.mitm}, bonding={self.bonding}, '
f'delegate[{self.delegate.io_capability}])'
)
+45 -14
View File
@@ -20,6 +20,7 @@ import struct
import logging import logging
from typing import List from typing import List
from ..core import AdvertisingData from ..core import AdvertisingData
from ..device import Device, Connection
from ..gatt import ( from ..gatt import (
GATT_ASHA_SERVICE, GATT_ASHA_SERVICE,
GATT_ASHA_READ_ONLY_PROPERTIES_CHARACTERISTIC, GATT_ASHA_READ_ONLY_PROPERTIES_CHARACTERISTIC,
@@ -31,6 +32,7 @@ from ..gatt import (
Characteristic, Characteristic,
CharacteristicValue, CharacteristicValue,
) )
from ..utils import AsyncRunner
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
# Logging # Logging
@@ -50,16 +52,20 @@ class AshaService(TemplateService):
SUPPORTED_CODEC_ID = [0x02, 0x01] # Codec IDs [G.722 at 16 kHz] SUPPORTED_CODEC_ID = [0x02, 0x01] # Codec IDs [G.722 at 16 kHz]
RENDER_DELAY = [00, 00] RENDER_DELAY = [00, 00]
def __init__(self, capability: int, hisyncid: List[int]): def __init__(self, capability: int, hisyncid: List[int], device: Device, psm=0):
self.hisyncid = hisyncid self.hisyncid = hisyncid
self.capability = capability # Device Capabilities [Left, Monaural] self.capability = capability # Device Capabilities [Left, Monaural]
self.device = device
self.audio_out_data = b''
self.psm = psm # a non-zero psm is mainly for testing purpose
# Handler for volume control # Handler for volume control
def on_volume_write(_connection, value): def on_volume_write(connection, value):
logger.info(f'--- VOLUME Write:{value[0]}') logger.info(f'--- VOLUME Write:{value[0]}')
self.emit('volume', connection, value[0])
# Handler for audio control commands # Handler for audio control commands
def on_audio_control_point_write(_connection, value): def on_audio_control_point_write(connection: Connection, value):
logger.info(f'--- AUDIO CONTROL POINT Write:{value.hex()}') logger.info(f'--- AUDIO CONTROL POINT Write:{value.hex()}')
opcode = value[0] opcode = value[0]
if opcode == AshaService.OPCODE_START: if opcode == AshaService.OPCODE_START:
@@ -71,18 +77,33 @@ class AshaService(TemplateService):
f'volume={value[3]}, ' f'volume={value[3]}, '
f'otherstate={value[4]}' f'otherstate={value[4]}'
) )
self.emit(
'start',
connection,
{
'codec': value[1],
'audiotype': value[2],
'volume': value[3],
'otherstate': value[4],
},
)
elif opcode == AshaService.OPCODE_STOP: elif opcode == AshaService.OPCODE_STOP:
logger.info('### STOP') logger.info('### STOP')
self.emit('stop', connection)
elif opcode == AshaService.OPCODE_STATUS: elif opcode == AshaService.OPCODE_STATUS:
logger.info(f'### STATUS: connected={value[1]}') logger.info(f'### STATUS: connected={value[1]}')
# TODO Respond with a status # OPCODE_STATUS does not need audio status point update
# asyncio.create_task(device.notify_subscribers(audio_status_characteristic, if opcode != AshaService.OPCODE_STATUS:
# force=True)) AsyncRunner.spawn(
device.notify_subscribers(
self.audio_status_characteristic, force=True
)
)
self.read_only_properties_characteristic = Characteristic( self.read_only_properties_characteristic = Characteristic(
GATT_ASHA_READ_ONLY_PROPERTIES_CHARACTERISTIC, GATT_ASHA_READ_ONLY_PROPERTIES_CHARACTERISTIC,
Characteristic.READ, Characteristic.Properties.READ,
Characteristic.READABLE, Characteristic.READABLE,
bytes( bytes(
[ [
@@ -99,29 +120,39 @@ class AshaService(TemplateService):
self.audio_control_point_characteristic = Characteristic( self.audio_control_point_characteristic = Characteristic(
GATT_ASHA_AUDIO_CONTROL_POINT_CHARACTERISTIC, GATT_ASHA_AUDIO_CONTROL_POINT_CHARACTERISTIC,
Characteristic.WRITE | Characteristic.WRITE_WITHOUT_RESPONSE, Characteristic.Properties.WRITE
| Characteristic.Properties.WRITE_WITHOUT_RESPONSE,
Characteristic.WRITEABLE, Characteristic.WRITEABLE,
CharacteristicValue(write=on_audio_control_point_write), CharacteristicValue(write=on_audio_control_point_write),
) )
self.audio_status_characteristic = Characteristic( self.audio_status_characteristic = Characteristic(
GATT_ASHA_AUDIO_STATUS_CHARACTERISTIC, GATT_ASHA_AUDIO_STATUS_CHARACTERISTIC,
Characteristic.READ | Characteristic.NOTIFY, Characteristic.Properties.READ | Characteristic.Properties.NOTIFY,
Characteristic.READABLE, Characteristic.READABLE,
bytes([0]), bytes([0]),
) )
self.volume_characteristic = Characteristic( self.volume_characteristic = Characteristic(
GATT_ASHA_VOLUME_CHARACTERISTIC, GATT_ASHA_VOLUME_CHARACTERISTIC,
Characteristic.WRITE_WITHOUT_RESPONSE, Characteristic.Properties.WRITE_WITHOUT_RESPONSE,
Characteristic.WRITEABLE, Characteristic.WRITEABLE,
CharacteristicValue(write=on_volume_write), CharacteristicValue(write=on_volume_write),
) )
# TODO add real psm value # Register an L2CAP CoC server
self.psm = 0x0080 def on_coc(channel):
# self.psm = device.register_l2cap_channel_server(0, on_coc, 8) def on_data(data):
logging.debug(f'<<< data received:{data}')
self.emit('data', channel.connection, data)
self.audio_out_data += data
channel.sink = on_data
# let the server find a free PSM
self.psm = self.device.register_l2cap_channel_server(self.psm, on_coc, 8)
self.le_psm_out_characteristic = Characteristic( self.le_psm_out_characteristic = Characteristic(
GATT_ASHA_LE_PSM_OUT_CHARACTERISTIC, GATT_ASHA_LE_PSM_OUT_CHARACTERISTIC,
Characteristic.READ, Characteristic.Properties.READ,
Characteristic.READABLE, Characteristic.READABLE,
struct.pack('<H', self.psm), struct.pack('<H', self.psm),
) )
+1 -1
View File
@@ -36,7 +36,7 @@ class BatteryService(TemplateService):
self.battery_level_characteristic = PackedCharacteristicAdapter( self.battery_level_characteristic = PackedCharacteristicAdapter(
Characteristic( Characteristic(
GATT_BATTERY_LEVEL_CHARACTERISTIC, GATT_BATTERY_LEVEL_CHARACTERISTIC,
Characteristic.READ | Characteristic.NOTIFY, Characteristic.Properties.READ | Characteristic.Properties.NOTIFY,
Characteristic.READABLE, Characteristic.READABLE,
CharacteristicValue(read=read_battery_level), CharacteristicValue(read=read_battery_level),
), ),
+14 -12
View File
@@ -17,7 +17,7 @@
# Imports # Imports
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
import struct import struct
from typing import Tuple from typing import Optional, Tuple
from ..gatt_client import ProfileServiceProxy from ..gatt_client import ProfileServiceProxy
from ..gatt import ( from ..gatt import (
@@ -52,18 +52,20 @@ class DeviceInformationService(TemplateService):
def __init__( def __init__(
self, self,
manufacturer_name: str = None, manufacturer_name: Optional[str] = None,
model_number: str = None, model_number: Optional[str] = None,
serial_number: str = None, serial_number: Optional[str] = None,
hardware_revision: str = None, hardware_revision: Optional[str] = None,
firmware_revision: str = None, firmware_revision: Optional[str] = None,
software_revision: str = None, software_revision: Optional[str] = None,
system_id: Tuple[int, int] = None, # (OUI, Manufacturer ID) system_id: Optional[Tuple[int, int]] = None, # (OUI, Manufacturer ID)
ieee_regulatory_certification_data_list: bytes = None ieee_regulatory_certification_data_list: Optional[bytes] = None
# TODO: pnp_id # TODO: pnp_id
): ):
characteristics = [ characteristics = [
Characteristic(uuid, Characteristic.READ, Characteristic.READABLE, field) Characteristic(
uuid, Characteristic.Properties.READ, Characteristic.READABLE, field
)
for (field, uuid) in ( for (field, uuid) in (
(manufacturer_name, GATT_MANUFACTURER_NAME_STRING_CHARACTERISTIC), (manufacturer_name, GATT_MANUFACTURER_NAME_STRING_CHARACTERISTIC),
(model_number, GATT_MODEL_NUMBER_STRING_CHARACTERISTIC), (model_number, GATT_MODEL_NUMBER_STRING_CHARACTERISTIC),
@@ -79,7 +81,7 @@ class DeviceInformationService(TemplateService):
characteristics.append( characteristics.append(
Characteristic( Characteristic(
GATT_SYSTEM_ID_CHARACTERISTIC, GATT_SYSTEM_ID_CHARACTERISTIC,
Characteristic.READ, Characteristic.Properties.READ,
Characteristic.READABLE, Characteristic.READABLE,
self.pack_system_id(*system_id), self.pack_system_id(*system_id),
) )
@@ -89,7 +91,7 @@ class DeviceInformationService(TemplateService):
characteristics.append( characteristics.append(
Characteristic( Characteristic(
GATT_REGULATORY_CERTIFICATION_DATA_LIST_CHARACTERISTIC, GATT_REGULATORY_CERTIFICATION_DATA_LIST_CHARACTERISTIC,
Characteristic.READ, Characteristic.Properties.READ,
Characteristic.READABLE, Characteristic.READABLE,
ieee_regulatory_certification_data_list, ieee_regulatory_certification_data_list,
) )
+3 -3
View File
@@ -152,7 +152,7 @@ class HeartRateService(TemplateService):
self.heart_rate_measurement_characteristic = DelegatedCharacteristicAdapter( self.heart_rate_measurement_characteristic = DelegatedCharacteristicAdapter(
Characteristic( Characteristic(
GATT_HEART_RATE_MEASUREMENT_CHARACTERISTIC, GATT_HEART_RATE_MEASUREMENT_CHARACTERISTIC,
Characteristic.NOTIFY, Characteristic.Properties.NOTIFY,
0, 0,
CharacteristicValue(read=read_heart_rate_measurement), CharacteristicValue(read=read_heart_rate_measurement),
), ),
@@ -164,7 +164,7 @@ class HeartRateService(TemplateService):
if body_sensor_location is not None: if body_sensor_location is not None:
self.body_sensor_location_characteristic = Characteristic( self.body_sensor_location_characteristic = Characteristic(
GATT_BODY_SENSOR_LOCATION_CHARACTERISTIC, GATT_BODY_SENSOR_LOCATION_CHARACTERISTIC,
Characteristic.READ, Characteristic.Properties.READ,
Characteristic.READABLE, Characteristic.READABLE,
bytes([int(body_sensor_location)]), bytes([int(body_sensor_location)]),
) )
@@ -182,7 +182,7 @@ class HeartRateService(TemplateService):
self.heart_rate_control_point_characteristic = PackedCharacteristicAdapter( self.heart_rate_control_point_characteristic = PackedCharacteristicAdapter(
Characteristic( Characteristic(
GATT_HEART_RATE_CONTROL_POINT_CHARACTERISTIC, GATT_HEART_RATE_CONTROL_POINT_CHARACTERISTIC,
Characteristic.WRITE, Characteristic.Properties.WRITE,
Characteristic.WRITEABLE, Characteristic.WRITEABLE,
CharacteristicValue(write=write_heart_rate_control_point_value), CharacteristicValue(write=write_heart_rate_control_point_value),
), ),
View File
View File
+22 -12
View File
@@ -18,10 +18,10 @@
import logging import logging
import asyncio import asyncio
from colors import color
from pyee import EventEmitter from pyee import EventEmitter
from . import core from . import core
from .colors import color
from .core import BT_BR_EDR_TRANSPORT, InvalidStateError, ProtocolError from .core import BT_BR_EDR_TRANSPORT, InvalidStateError, ProtocolError
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
@@ -439,7 +439,7 @@ class DLC(EventEmitter):
logger.debug( logger.debug(
f'<<< Credits [{self.dlci}]: ' f'<<< Credits [{self.dlci}]: '
f'received {credits}, total={self.tx_credits}' f'received {received_credits}, total={self.tx_credits}'
) )
data = data[1:] data = data[1:]
@@ -852,17 +852,27 @@ class Server(EventEmitter):
# Register ourselves with the L2CAP channel manager # Register ourselves with the L2CAP channel manager
device.register_l2cap_server(RFCOMM_PSM, self.on_connection) device.register_l2cap_server(RFCOMM_PSM, self.on_connection)
def listen(self, acceptor): def listen(self, acceptor, channel=0):
# Find a free channel number if channel:
for channel in range( if channel in self.acceptors:
RFCOMM_DYNAMIC_CHANNEL_NUMBER_START, RFCOMM_DYNAMIC_CHANNEL_NUMBER_END + 1 # Busy
): return 0
if channel not in self.acceptors: else:
self.acceptors[channel] = acceptor # Find a free channel number
return channel for candidate in range(
RFCOMM_DYNAMIC_CHANNEL_NUMBER_START,
RFCOMM_DYNAMIC_CHANNEL_NUMBER_END + 1,
):
if candidate not in self.acceptors:
channel = candidate
break
# All channels used... if channel == 0:
return 0 # All channels used...
return 0
self.acceptors[channel] = acceptor
return channel
def on_connection(self, l2cap_channel): def on_connection(self, l2cap_channel):
logger.debug(f'+++ new L2CAP connection: {l2cap_channel}') logger.debug(f'+++ new L2CAP connection: {l2cap_channel}')
+21 -20
View File
@@ -15,12 +15,13 @@
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
# Imports # Imports
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
from __future__ import annotations
import logging import logging
import struct import struct
from colors import color from typing import Dict, List, Type
import colors
from . import core from . import core
from .colors import color
from .core import InvalidStateError from .core import InvalidStateError
from .hci import HCI_Object, name_or_number, key_with_value from .hci import HCI_Object, name_or_number, key_with_value
@@ -181,63 +182,63 @@ class DataElement:
raise ValueError('integer types must have a value size specified') raise ValueError('integer types must have a value size specified')
@staticmethod @staticmethod
def nil(): def nil() -> DataElement:
return DataElement(DataElement.NIL, None) return DataElement(DataElement.NIL, None)
@staticmethod @staticmethod
def unsigned_integer(value, value_size): def unsigned_integer(value: int, value_size: int) -> DataElement:
return DataElement(DataElement.UNSIGNED_INTEGER, value, value_size) return DataElement(DataElement.UNSIGNED_INTEGER, value, value_size)
@staticmethod @staticmethod
def unsigned_integer_8(value): def unsigned_integer_8(value: int) -> DataElement:
return DataElement(DataElement.UNSIGNED_INTEGER, value, value_size=1) return DataElement(DataElement.UNSIGNED_INTEGER, value, value_size=1)
@staticmethod @staticmethod
def unsigned_integer_16(value): def unsigned_integer_16(value: int) -> DataElement:
return DataElement(DataElement.UNSIGNED_INTEGER, value, value_size=2) return DataElement(DataElement.UNSIGNED_INTEGER, value, value_size=2)
@staticmethod @staticmethod
def unsigned_integer_32(value): def unsigned_integer_32(value: int) -> DataElement:
return DataElement(DataElement.UNSIGNED_INTEGER, value, value_size=4) return DataElement(DataElement.UNSIGNED_INTEGER, value, value_size=4)
@staticmethod @staticmethod
def signed_integer(value, value_size): def signed_integer(value: int, value_size: int) -> DataElement:
return DataElement(DataElement.SIGNED_INTEGER, value, value_size) return DataElement(DataElement.SIGNED_INTEGER, value, value_size)
@staticmethod @staticmethod
def signed_integer_8(value): def signed_integer_8(value: int) -> DataElement:
return DataElement(DataElement.SIGNED_INTEGER, value, value_size=1) return DataElement(DataElement.SIGNED_INTEGER, value, value_size=1)
@staticmethod @staticmethod
def signed_integer_16(value): def signed_integer_16(value: int) -> DataElement:
return DataElement(DataElement.SIGNED_INTEGER, value, value_size=2) return DataElement(DataElement.SIGNED_INTEGER, value, value_size=2)
@staticmethod @staticmethod
def signed_integer_32(value): def signed_integer_32(value: int) -> DataElement:
return DataElement(DataElement.SIGNED_INTEGER, value, value_size=4) return DataElement(DataElement.SIGNED_INTEGER, value, value_size=4)
@staticmethod @staticmethod
def uuid(value): def uuid(value: core.UUID) -> DataElement:
return DataElement(DataElement.UUID, value) return DataElement(DataElement.UUID, value)
@staticmethod @staticmethod
def text_string(value): def text_string(value: str) -> DataElement:
return DataElement(DataElement.TEXT_STRING, value) return DataElement(DataElement.TEXT_STRING, value)
@staticmethod @staticmethod
def boolean(value): def boolean(value: bool) -> DataElement:
return DataElement(DataElement.BOOLEAN, value) return DataElement(DataElement.BOOLEAN, value)
@staticmethod @staticmethod
def sequence(value): def sequence(value: List[DataElement]) -> DataElement:
return DataElement(DataElement.SEQUENCE, value) return DataElement(DataElement.SEQUENCE, value)
@staticmethod @staticmethod
def alternative(value): def alternative(value: List[DataElement]) -> DataElement:
return DataElement(DataElement.ALTERNATIVE, value) return DataElement(DataElement.ALTERNATIVE, value)
@staticmethod @staticmethod
def url(value): def url(value: str) -> DataElement:
return DataElement(DataElement.URL, value) return DataElement(DataElement.URL, value)
@staticmethod @staticmethod
@@ -456,7 +457,7 @@ class DataElement:
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
class ServiceAttribute: class ServiceAttribute:
def __init__(self, attribute_id, value): def __init__(self, attribute_id: int, value: DataElement) -> None:
self.id = attribute_id self.id = attribute_id
self.value = value self.value = value
@@ -504,7 +505,7 @@ class ServiceAttribute:
def to_string(self, with_colors=False): def to_string(self, with_colors=False):
if with_colors: if with_colors:
return ( return (
f'Attribute(id={colors.color(self.id_name(self.id),"magenta")},' f'Attribute(id={color(self.id_name(self.id),"magenta")},'
f'value={self.value})' f'value={self.value})'
) )
@@ -520,7 +521,7 @@ class SDP_PDU:
See Bluetooth spec @ Vol 3, Part B - 4.2 PROTOCOL DATA UNIT FORMAT See Bluetooth spec @ Vol 3, Part B - 4.2 PROTOCOL DATA UNIT FORMAT
''' '''
sdp_pdu_classes = {} sdp_pdu_classes: Dict[int, Type[SDP_PDU]] = {}
name = None name = None
pdu_id = 0 pdu_id = 0
+69 -99
View File
@@ -22,13 +22,25 @@
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
# Imports # Imports
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
from __future__ import annotations
import logging import logging
import asyncio import asyncio
import secrets import secrets
from pyee import EventEmitter from typing import Dict, Optional, Type
from colors import color
from .hci import Address, HCI_LE_Enable_Encryption_Command, HCI_Object, key_with_value from pyee import EventEmitter
from .colors import color
from .hci import (
HCI_DISPLAY_ONLY_IO_CAPABILITY,
HCI_DISPLAY_YES_NO_IO_CAPABILITY,
HCI_KEYBOARD_ONLY_IO_CAPABILITY,
HCI_NO_INPUT_NO_OUTPUT_IO_CAPABILITY,
Address,
HCI_LE_Enable_Encryption_Command,
HCI_Object,
key_with_value,
)
from .core import ( from .core import (
BT_BR_EDR_TRANSPORT, BT_BR_EDR_TRANSPORT,
BT_CENTRAL_ROLE, BT_CENTRAL_ROLE,
@@ -184,7 +196,7 @@ class SMP_Command:
See Bluetooth spec @ Vol 3, Part H - 3 SECURITY MANAGER PROTOCOL See Bluetooth spec @ Vol 3, Part H - 3 SECURITY MANAGER PROTOCOL
''' '''
smp_classes = {} smp_classes: Dict[int, Type[SMP_Command]] = {}
code = 0 code = 0
name = '' name = ''
@@ -473,7 +485,7 @@ class AddressResolver:
address_bytes = bytes(address) address_bytes = bytes(address)
hash_part = address_bytes[0:3] hash_part = address_bytes[0:3]
prand = address_bytes[3:6] prand = address_bytes[3:6]
for (irk, resolved_address) in self.resolving_keys: for irk, resolved_address in self.resolving_keys:
local_hash = crypto.ah(irk, prand) local_hash = crypto.ah(irk, prand)
if local_hash == hash_part: if local_hash == hash_part:
# Match! # Match!
@@ -488,68 +500,6 @@ class AddressResolver:
return None return None
# -----------------------------------------------------------------------------
class PairingDelegate:
NO_OUTPUT_NO_INPUT = SMP_NO_INPUT_NO_OUTPUT_IO_CAPABILITY
KEYBOARD_INPUT_ONLY = SMP_KEYBOARD_ONLY_IO_CAPABILITY
DISPLAY_OUTPUT_ONLY = SMP_DISPLAY_ONLY_IO_CAPABILITY
DISPLAY_OUTPUT_AND_YES_NO_INPUT = SMP_DISPLAY_YES_NO_IO_CAPABILITY
DISPLAY_OUTPUT_AND_KEYBOARD_INPUT = SMP_KEYBOARD_DISPLAY_IO_CAPABILITY
DEFAULT_KEY_DISTRIBUTION = (
SMP_ENC_KEY_DISTRIBUTION_FLAG | SMP_ID_KEY_DISTRIBUTION_FLAG
)
def __init__(
self,
io_capability=NO_OUTPUT_NO_INPUT,
local_initiator_key_distribution=DEFAULT_KEY_DISTRIBUTION,
local_responder_key_distribution=DEFAULT_KEY_DISTRIBUTION,
):
self.io_capability = io_capability
self.local_initiator_key_distribution = local_initiator_key_distribution
self.local_responder_key_distribution = local_responder_key_distribution
async def accept(self):
return True
async def confirm(self):
return True
async def compare_numbers(self, _number, _digits=6):
return True
async def get_number(self):
return 0
async def display_number(self, _number, _digits=6):
pass
async def key_distribution_response(
self, peer_initiator_key_distribution, peer_responder_key_distribution
):
return (
(peer_initiator_key_distribution & self.local_initiator_key_distribution),
(peer_responder_key_distribution & self.local_responder_key_distribution),
)
# -----------------------------------------------------------------------------
class PairingConfig:
def __init__(self, sc=True, mitm=True, bonding=True, delegate=None):
self.sc = sc
self.mitm = mitm
self.bonding = bonding
self.delegate = delegate or PairingDelegate()
def __str__(self):
io_capability_str = SMP_Command.io_capability_name(self.delegate.io_capability)
return (
f'PairingConfig(sc={self.sc}, '
f'mitm={self.mitm}, bonding={self.bonding}, '
f'delegate[{io_capability_str}])'
)
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
class Session: class Session:
# Pairing methods # Pairing methods
@@ -624,7 +574,7 @@ class Session:
}, },
} }
def __init__(self, manager, connection, pairing_config): def __init__(self, manager, connection, pairing_config, is_initiator):
self.manager = manager self.manager = manager
self.connection = connection self.connection = connection
self.preq = None self.preq = None
@@ -652,7 +602,8 @@ class Session:
self.peer_expected_distributions = [] self.peer_expected_distributions = []
self.dh_key = None self.dh_key = None
self.confirm_value = None self.confirm_value = None
self.passkey = 0 self.passkey = None
self.passkey_ready = asyncio.Event()
self.passkey_step = 0 self.passkey_step = 0
self.passkey_display = False self.passkey_display = False
self.pairing_method = 0 self.pairing_method = 0
@@ -662,7 +613,7 @@ class Session:
self.ctkd_task = None self.ctkd_task = None
# Decide if we're the initiator or the responder # Decide if we're the initiator or the responder
self.is_initiator = connection.role == BT_CENTRAL_ROLE self.is_initiator = is_initiator
self.is_responder = not self.is_initiator self.is_responder = not self.is_initiator
# Listen for connection events # Listen for connection events
@@ -830,6 +781,7 @@ class Session:
# Generate random Passkey/PIN code # Generate random Passkey/PIN code
self.passkey = secrets.randbelow(1000000) self.passkey = secrets.randbelow(1000000)
logger.debug(f'Pairing PIN CODE: {self.passkey:06}') logger.debug(f'Pairing PIN CODE: {self.passkey:06}')
self.passkey_ready.set()
# The value of TK is computed from the PIN code # The value of TK is computed from the PIN code
if not self.sc: if not self.sc:
@@ -850,6 +802,8 @@ class Session:
self.tk = passkey.to_bytes(16, byteorder='little') self.tk = passkey.to_bytes(16, byteorder='little')
logger.debug(f'TK from passkey = {self.tk.hex()}') logger.debug(f'TK from passkey = {self.tk.hex()}')
self.passkey_ready.set()
if next_steps is not None: if next_steps is not None:
next_steps() next_steps()
@@ -901,17 +855,29 @@ class Session:
logger.debug(f'generated random: {self.r.hex()}') logger.debug(f'generated random: {self.r.hex()}')
if self.sc: if self.sc:
if self.pairing_method in (self.JUST_WORKS, self.NUMERIC_COMPARISON):
z = 0
elif self.pairing_method == self.PASSKEY:
z = 0x80 + ((self.passkey >> self.passkey_step) & 1)
else:
return
if self.is_initiator: async def next_steps():
confirm_value = crypto.f4(self.pka, self.pkb, self.r, bytes([z])) if self.pairing_method in (self.JUST_WORKS, self.NUMERIC_COMPARISON):
else: z = 0
confirm_value = crypto.f4(self.pkb, self.pka, self.r, bytes([z])) elif self.pairing_method == self.PASSKEY:
# We need a passkey
await self.passkey_ready.wait()
z = 0x80 + ((self.passkey >> self.passkey_step) & 1)
else:
return
if self.is_initiator:
confirm_value = crypto.f4(self.pka, self.pkb, self.r, bytes([z]))
else:
confirm_value = crypto.f4(self.pkb, self.pka, self.r, bytes([z]))
self.send_command(
SMP_Pairing_Confirm_Command(confirm_value=confirm_value)
)
# Perform the next steps asynchronously in case we need to wait for input
self.connection.abort_on('disconnection', next_steps())
else: else:
confirm_value = crypto.c1( confirm_value = crypto.c1(
self.tk, self.tk,
@@ -924,7 +890,7 @@ class Session:
self.ra, self.ra,
) )
self.send_command(SMP_Pairing_Confirm_Command(confirm_value=confirm_value)) self.send_command(SMP_Pairing_Confirm_Command(confirm_value=confirm_value))
def send_pairing_random_command(self): def send_pairing_random_command(self):
self.send_command(SMP_Pairing_Random_Command(random_value=self.r)) self.send_command(SMP_Pairing_Random_Command(random_value=self.r))
@@ -1355,8 +1321,8 @@ class Session:
# Start phase 2 # Start phase 2
if self.sc: if self.sc:
if self.pairing_method == self.PASSKEY and self.passkey_display: if self.pairing_method == self.PASSKEY:
self.display_passkey() self.display_or_input_passkey()
self.send_public_key_command() self.send_public_key_command()
else: else:
@@ -1417,18 +1383,22 @@ class Session:
else: else:
srand = self.r srand = self.r
mrand = command.random_value mrand = command.random_value
stk = crypto.s1(self.tk, srand, mrand) self.stk = crypto.s1(self.tk, srand, mrand)
logger.debug(f'STK = {stk.hex()}') logger.debug(f'STK = {self.stk.hex()}')
# Generate LTK # Generate LTK
self.ltk = crypto.r() self.ltk = crypto.r()
if self.is_initiator: if self.is_initiator:
self.start_encryption(stk) self.start_encryption(self.stk)
else: else:
self.send_pairing_random_command() self.send_pairing_random_command()
def on_smp_pairing_random_command_secure_connections(self, command): def on_smp_pairing_random_command_secure_connections(self, command):
if self.pairing_method == self.PASSKEY and self.passkey is None:
logger.warning('no passkey entered, ignoring command')
return
# pylint: disable=too-many-return-statements # pylint: disable=too-many-return-statements
if self.is_initiator: if self.is_initiator:
if self.pairing_method in (self.JUST_WORKS, self.NUMERIC_COMPARISON): if self.pairing_method in (self.JUST_WORKS, self.NUMERIC_COMPARISON):
@@ -1556,17 +1526,13 @@ class Session:
logger.debug(f'DH key: {self.dh_key.hex()}') logger.debug(f'DH key: {self.dh_key.hex()}')
if self.is_initiator: if self.is_initiator:
if self.pairing_method == self.PASSKEY: self.send_pairing_confirm_command()
if self.passkey_display:
self.send_pairing_confirm_command()
else:
self.input_passkey(self.send_pairing_confirm_command)
else: else:
# Send our public key back to the initiator
if self.pairing_method == self.PASSKEY: if self.pairing_method == self.PASSKEY:
self.display_or_input_passkey(self.send_public_key_command) self.display_or_input_passkey()
else:
self.send_public_key_command() # 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 (self.JUST_WORKS, self.NUMERIC_COMPARISON):
# We can now send the confirmation value # We can now send the confirmation value
@@ -1625,12 +1591,12 @@ class Manager(EventEmitter):
Implements the Initiator and Responder roles of the Security Manager Protocol Implements the Initiator and Responder roles of the Security Manager Protocol
''' '''
def __init__(self, device): def __init__(self, device, pairing_config_factory):
super().__init__() super().__init__()
self.device = device self.device = device
self.sessions = {} self.sessions = {}
self._ecc_key = None self._ecc_key = None
self.pairing_config_factory = lambda connection: PairingConfig() self.pairing_config_factory = pairing_config_factory
def send_command(self, connection, command): def send_command(self, connection, command):
logger.debug( logger.debug(
@@ -1643,6 +1609,8 @@ class Manager(EventEmitter):
def on_smp_pdu(self, connection, pdu): def on_smp_pdu(self, connection, pdu):
# Look for a session with this connection, and create one if none exists # Look for a session with this connection, and create one if none exists
if not (session := self.sessions.get(connection.handle)): if not (session := self.sessions.get(connection.handle)):
if connection.role == BT_CENTRAL_ROLE:
logger.warning('Remote starts pairing as Peripheral!')
pairing_config = self.pairing_config_factory(connection) pairing_config = self.pairing_config_factory(connection)
if pairing_config is None: if pairing_config is None:
# Pairing disabled # Pairing disabled
@@ -1651,7 +1619,7 @@ class Manager(EventEmitter):
SMP_Pairing_Failed_Command(reason=SMP_PAIRING_NOT_SUPPORTED_ERROR), SMP_Pairing_Failed_Command(reason=SMP_PAIRING_NOT_SUPPORTED_ERROR),
) )
return return
session = Session(self, connection, pairing_config) session = Session(self, connection, pairing_config, is_initiator=False)
self.sessions[connection.handle] = session self.sessions[connection.handle] = session
# Parse the L2CAP payload into an SMP Command object # Parse the L2CAP payload into an SMP Command object
@@ -1672,10 +1640,12 @@ class Manager(EventEmitter):
async def pair(self, connection): async def pair(self, connection):
# TODO: check if there's already a session for this connection # TODO: check if there's already a session for this connection
if connection.role != BT_CENTRAL_ROLE:
logger.warning('Start pairing as Peripheral!')
pairing_config = self.pairing_config_factory(connection) pairing_config = self.pairing_config_factory(connection)
if pairing_config is None: if pairing_config is None:
raise ValueError('pairing config must not be None when initiating') raise ValueError('pairing config must not be None when initiating')
session = Session(self, connection, pairing_config) session = Session(self, connection, pairing_config, is_initiator=True)
self.sessions[connection.handle] = session self.sessions[connection.handle] = session
return await session.pair() return await session.pair()
+170
View File
@@ -0,0 +1,170 @@
# 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 contextlib import contextmanager
from enum import IntEnum
import logging
import struct
import datetime
from typing import BinaryIO, Generator
import os
from bumble.hci import HCI_COMMAND_PACKET, HCI_EVENT_PACKET
# -----------------------------------------------------------------------------
# Logging
# -----------------------------------------------------------------------------
logger = logging.getLogger(__name__)
# -----------------------------------------------------------------------------
# Classes
# -----------------------------------------------------------------------------
class Snooper:
"""
Base class for snooper implementations.
A snooper is an object that will be provided with HCI packets as they are
exchanged between a host and a controller.
"""
class Direction(IntEnum):
HOST_TO_CONTROLLER = 0
CONTROLLER_TO_HOST = 1
class DataLinkType(IntEnum):
H1 = 1001
H4 = 1002
HCI_BSCP = 1003
H5 = 1004
def snoop(self, hci_packet: bytes, direction: Direction) -> None:
"""Snoop on an HCI packet."""
# -----------------------------------------------------------------------------
class BtSnooper(Snooper):
"""
Snooper that saves HCI packets using the BTSnoop format, based on RFC 1761.
"""
IDENTIFICATION_PATTERN = b'btsnoop\0'
TIMESTAMP_ANCHOR = datetime.datetime(2000, 1, 1)
TIMESTAMP_DELTA = 0x00E03AB44A676000
ONE_MS = datetime.timedelta(microseconds=1)
def __init__(self, output: BinaryIO):
self.output = output
# Write the header
self.output.write(
self.IDENTIFICATION_PATTERN + struct.pack('>LL', 1, self.DataLinkType.H4)
)
def snoop(self, hci_packet: bytes, direction: Snooper.Direction) -> None:
flags = int(direction)
packet_type = hci_packet[0]
if packet_type in (HCI_EVENT_PACKET, HCI_COMMAND_PACKET):
flags |= 0x10
# Compute the current timestamp
timestamp = (
int((datetime.datetime.utcnow() - self.TIMESTAMP_ANCHOR) / self.ONE_MS)
+ self.TIMESTAMP_DELTA
)
# Emit the record
self.output.write(
struct.pack(
'>IIIIQ',
len(hci_packet), # Original Length
len(hci_packet), # Included Length
flags, # Packet Flags
0, # Cumulative Drops
timestamp, # Timestamp
)
+ hci_packet
)
# -----------------------------------------------------------------------------
_SNOOPER_INSTANCE_COUNT = 0
@contextmanager
def create_snooper(spec: str) -> Generator[Snooper, None, None]:
"""
Create a snooper given a specification string.
The general syntax for the specification string is:
<snooper-type>:<type-specific-arguments>
Supported snooper types are:
btsnoop
The syntax for the type-specific arguments for this type is:
<io-type>:<io-type-specific-arguments>
Supported I/O types are:
file
The type-specific arguments for this I/O type is a string that is converted
to a file path using the python `str.format()` string formatting. The log
records will be written to that file if it can be opened/created.
The keyword args that may be referenced by the string pattern are:
now: the value of `datetime.now()`
utcnow: the value of `datetime.utcnow()`
pid: the current process ID.
instance: the instance ID in the current process.
Examples:
btsnoop:file:my_btsnoop.log
btsnoop:file:/tmp/bumble_{now:%Y-%m-%d-%H:%M:%S}_{pid}.log
"""
if ':' not in spec:
raise ValueError('snooper type prefix missing')
snooper_type, snooper_args = spec.split(':', maxsplit=1)
if snooper_type == 'btsnoop':
if ':' not in snooper_args:
raise ValueError('I/O type for btsnoop snooper type missing')
io_type, io_name = snooper_args.split(':', maxsplit=1)
if io_type == 'file':
# Process the file name string pattern.
global _SNOOPER_INSTANCE_COUNT
file_path = io_name.format(
now=datetime.datetime.now(),
utcnow=datetime.datetime.utcnow(),
pid=os.getpid(),
instance=_SNOOPER_INSTANCE_COUNT,
)
# Open the file
logger.debug(f'Snoop file: {file_path}')
with open(file_path, 'wb') as snoop_file:
_SNOOPER_INSTANCE_COUNT += 1
yield BtSnooper(snoop_file)
_SNOOPER_INSTANCE_COUNT -= 1
return
raise ValueError(f'I/O type {io_type} not supported')
raise ValueError(f'snooper type {snooper_type} not found')
+63 -9
View File
@@ -1,4 +1,4 @@
# Copyright 2021-2022 Google LLC # Copyright 2021-2023 Google LLC
# #
# Licensed under the Apache License, Version 2.0 (the "License"); # Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License. # you may not use this file except in compliance with the License.
@@ -15,11 +15,13 @@
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
# Imports # Imports
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
from contextlib import asynccontextmanager
import logging import logging
import os
from .common import Transport, AsyncPipeSink from .common import Transport, AsyncPipeSink, SnoopingTransport
from ..link import RemoteLink
from ..controller import Controller from ..controller import Controller
from ..snoop import create_snooper
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
# Logging # Logging
@@ -28,13 +30,52 @@ logger = logging.getLogger(__name__)
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
async def open_transport(name): def _wrap_transport(transport: Transport) -> Transport:
''' """
Automatically wrap a Transport instance when a wrapping class can be inferred
from the environment.
If no wrapping class is applicable, the transport argument is returned as-is.
"""
# If BUMBLE_SNOOPER is set, try to automatically create a snooper.
if snooper_spec := os.getenv('BUMBLE_SNOOPER'):
try:
return SnoopingTransport.create_with(
transport, create_snooper(snooper_spec)
)
except Exception as exc:
logger.warning(f'Exception while creating snooper: {exc}')
return transport
# -----------------------------------------------------------------------------
async def open_transport(name: str) -> Transport:
"""
Open a transport by name. Open a transport by name.
The name must be <type>:<parameters> The name must be <type>:<parameters>
Where <parameters> depend on the type (and may be empty for some types). Where <parameters> depend on the type (and may be empty for some types).
The supported types are: serial,udp,tcp,pty,usb The supported types are:
''' * serial
* udp
* tcp-client
* tcp-server
* ws-client
* ws-server
* pty
* file
* vhci
* hci-socket
* usb
* pyusb
* android-emulator
"""
return _wrap_transport(await _open_transport(name))
# -----------------------------------------------------------------------------
async def _open_transport(name: str) -> Transport:
# pylint: disable=import-outside-toplevel # pylint: disable=import-outside-toplevel
# pylint: disable=too-many-return-statements # pylint: disable=too-many-return-statements
@@ -108,8 +149,21 @@ async def open_transport(name):
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
async def open_transport_or_link(name): async def open_transport_or_link(name: str) -> Transport:
"""
Open a transport or a link relay.
Args:
name:
Name of the transport or link relay to open.
When the name starts with "link-relay:", open a link relay (see RemoteLink
for details on what the arguments are).
For other namespaces, see `open_transport`.
"""
if name.startswith('link-relay:'): if name.startswith('link-relay:'):
from ..link import RemoteLink # lazy import
link = RemoteLink(name[11:]) link = RemoteLink(name[11:])
await link.wait_until_connected() await link.wait_until_connected()
controller = Controller('remote', link=link) controller = Controller('remote', link=link)
@@ -118,6 +172,6 @@ async def open_transport_or_link(name):
async def close(self): async def close(self):
link.close() link.close()
return LinkTransport(controller, AsyncPipeSink(controller)) return _wrap_transport(LinkTransport(controller, AsyncPipeSink(controller)))
return await open_transport(name) return await open_transport(name)
+4 -2
View File
@@ -1,4 +1,4 @@
# Copyright 2021-2022 Google LLC # Copyright 2021-2023 Google LLC
# #
# Licensed under the Apache License, Version 2.0 (the "License"); # Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License. # you may not use this file except in compliance with the License.
@@ -20,9 +20,11 @@ import grpc
from .common import PumpedTransport, PumpedPacketSource, PumpedPacketSink from .common import PumpedTransport, PumpedPacketSource, PumpedPacketSink
from .emulated_bluetooth_pb2_grpc import EmulatedBluetoothServiceStub from .emulated_bluetooth_pb2_grpc import EmulatedBluetoothServiceStub
from .emulated_bluetooth_packets_pb2 import HCIPacket
from .emulated_bluetooth_vhci_pb2_grpc import VhciForwardingServiceStub from .emulated_bluetooth_vhci_pb2_grpc import VhciForwardingServiceStub
# pylint: disable-next=no-name-in-module
from .emulated_bluetooth_packets_pb2 import HCIPacket
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
# Logging # Logging
+77 -2
View File
@@ -15,12 +15,16 @@
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
# Imports # Imports
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
from __future__ import annotations
import contextlib
import struct import struct
import asyncio import asyncio
import logging import logging
from colors import color from typing import ContextManager
from .. import hci from .. import hci
from ..colors import color
from ..snoop import Snooper
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
@@ -246,6 +250,20 @@ class StreamPacketSink:
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
class Transport: class Transport:
"""
Base class for all transports.
A Transport represents a source and a sink together.
An instance must be closed by calling close() when no longer used. Instances
implement the ContextManager protocol so that they may be used in a `async with`
statement.
An instance is iterable. The iterator yields, in order, its source and sink, so
that it may be used with a convenient call syntax like:
async with create_transport() as (source, sink):
...
"""
def __init__(self, source, sink): def __init__(self, source, sink):
self.source = source self.source = source
self.sink = sink self.sink = sink
@@ -259,7 +277,7 @@ class Transport:
def __iter__(self): def __iter__(self):
return iter((self.source, self.sink)) return iter((self.source, self.sink))
async def close(self): async def close(self) -> None:
self.source.close() self.source.close()
self.sink.close() self.sink.close()
@@ -335,3 +353,60 @@ class PumpedTransport(Transport):
async def close(self): async def close(self):
await super().close() await super().close()
await self.close_function() await self.close_function()
# -----------------------------------------------------------------------------
class SnoopingTransport(Transport):
"""Transport wrapper that snoops on packets to/from a wrapped transport."""
@staticmethod
def create_with(
transport: Transport, snooper: ContextManager[Snooper]
) -> SnoopingTransport:
"""
Create an instance given a snooper that works as as context manager.
The returned instance will exit the snooper context when it is closed.
"""
with contextlib.ExitStack() as exit_stack:
return SnoopingTransport(
transport, exit_stack.enter_context(snooper), exit_stack.pop_all().close
)
raise RuntimeError('unexpected code path') # Satisfy the type checker
class Source:
def __init__(self, source, snooper):
self.source = source
self.snooper = snooper
self.sink = None
def set_packet_sink(self, sink):
self.sink = sink
self.source.set_packet_sink(self)
def on_packet(self, packet):
self.snooper.snoop(packet, Snooper.Direction.CONTROLLER_TO_HOST)
if self.sink:
self.sink.on_packet(packet)
class Sink:
def __init__(self, sink, snooper):
self.sink = sink
self.snooper = snooper
def on_packet(self, packet):
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):
super().__init__(
self.Source(transport.source, snooper), self.Sink(transport.sink, snooper)
)
self.transport = transport
self.close_snooper = close_snooper
async def close(self):
await self.transport.close()
if self.close_snooper:
self.close_snooper()
@@ -1,4 +1,4 @@
# Copyright 2021-2022 Google LLC # Copyright 2021-2023 Google LLC
# #
# Licensed under the Apache License, Version 2.0 (the "License"); # Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License. # you may not use this file except in compliance with the License.
@@ -16,10 +16,9 @@
# Generated by the protocol buffer compiler. DO NOT EDIT! # Generated by the protocol buffer compiler. DO NOT EDIT!
# source: emulated_bluetooth_packets.proto # source: emulated_bluetooth_packets.proto
"""Generated protocol buffer code.""" """Generated protocol buffer code."""
from google.protobuf.internal import builder as _builder
from google.protobuf import descriptor as _descriptor from google.protobuf import descriptor as _descriptor
from google.protobuf import descriptor_pool as _descriptor_pool from google.protobuf import descriptor_pool as _descriptor_pool
from google.protobuf import message as _message
from google.protobuf import reflection as _reflection
from google.protobuf import symbol_database as _symbol_database from google.protobuf import symbol_database as _symbol_database
# @@protoc_insertion_point(imports) # @@protoc_insertion_point(imports)
@@ -31,20 +30,10 @@ DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile(
b'\n emulated_bluetooth_packets.proto\x12\x1b\x61ndroid.emulation.bluetooth\"\xfb\x01\n\tHCIPacket\x12?\n\x04type\x18\x01 \x01(\x0e\x32\x31.android.emulation.bluetooth.HCIPacket.PacketType\x12\x0e\n\x06packet\x18\x02 \x01(\x0c\"\x9c\x01\n\nPacketType\x12\x1b\n\x17PACKET_TYPE_UNSPECIFIED\x10\x00\x12\x1b\n\x17PACKET_TYPE_HCI_COMMAND\x10\x01\x12\x13\n\x0fPACKET_TYPE_ACL\x10\x02\x12\x13\n\x0fPACKET_TYPE_SCO\x10\x03\x12\x15\n\x11PACKET_TYPE_EVENT\x10\x04\x12\x13\n\x0fPACKET_TYPE_ISO\x10\x05\x42J\n\x1f\x63om.android.emulation.bluetoothP\x01\xf8\x01\x01\xa2\x02\x03\x41\x45\x42\xaa\x02\x1b\x41ndroid.Emulation.Bluetoothb\x06proto3' b'\n emulated_bluetooth_packets.proto\x12\x1b\x61ndroid.emulation.bluetooth\"\xfb\x01\n\tHCIPacket\x12?\n\x04type\x18\x01 \x01(\x0e\x32\x31.android.emulation.bluetooth.HCIPacket.PacketType\x12\x0e\n\x06packet\x18\x02 \x01(\x0c\"\x9c\x01\n\nPacketType\x12\x1b\n\x17PACKET_TYPE_UNSPECIFIED\x10\x00\x12\x1b\n\x17PACKET_TYPE_HCI_COMMAND\x10\x01\x12\x13\n\x0fPACKET_TYPE_ACL\x10\x02\x12\x13\n\x0fPACKET_TYPE_SCO\x10\x03\x12\x15\n\x11PACKET_TYPE_EVENT\x10\x04\x12\x13\n\x0fPACKET_TYPE_ISO\x10\x05\x42J\n\x1f\x63om.android.emulation.bluetoothP\x01\xf8\x01\x01\xa2\x02\x03\x41\x45\x42\xaa\x02\x1b\x41ndroid.Emulation.Bluetoothb\x06proto3'
) )
_builder.BuildMessageAndEnumDescriptors(DESCRIPTOR, globals())
_HCIPACKET = DESCRIPTOR.message_types_by_name['HCIPacket'] _builder.BuildTopDescriptorsAndMessages(
_HCIPACKET_PACKETTYPE = _HCIPACKET.enum_types_by_name['PacketType'] DESCRIPTOR, 'emulated_bluetooth_packets_pb2', globals()
HCIPacket = _reflection.GeneratedProtocolMessageType(
'HCIPacket',
(_message.Message,),
{
'DESCRIPTOR': _HCIPACKET,
'__module__': 'emulated_bluetooth_packets_pb2'
# @@protoc_insertion_point(class_scope:android.emulation.bluetooth.HCIPacket)
},
) )
_sym_db.RegisterMessage(HCIPacket)
if _descriptor._USE_C_DESCRIPTORS == False: if _descriptor._USE_C_DESCRIPTORS == False:
DESCRIPTOR._options = None DESCRIPTOR._options = None
@@ -0,0 +1,41 @@
# 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.
from google.protobuf.internal import enum_type_wrapper as _enum_type_wrapper
from google.protobuf import descriptor as _descriptor
from google.protobuf import message as _message
from typing import ClassVar as _ClassVar, Optional as _Optional, Union as _Union
DESCRIPTOR: _descriptor.FileDescriptor
class HCIPacket(_message.Message):
__slots__ = ["packet", "type"]
class PacketType(int, metaclass=_enum_type_wrapper.EnumTypeWrapper):
__slots__ = []
PACKET_FIELD_NUMBER: _ClassVar[int]
PACKET_TYPE_ACL: HCIPacket.PacketType
PACKET_TYPE_EVENT: HCIPacket.PacketType
PACKET_TYPE_HCI_COMMAND: HCIPacket.PacketType
PACKET_TYPE_ISO: HCIPacket.PacketType
PACKET_TYPE_SCO: HCIPacket.PacketType
PACKET_TYPE_UNSPECIFIED: HCIPacket.PacketType
TYPE_FIELD_NUMBER: _ClassVar[int]
packet: bytes
type: HCIPacket.PacketType
def __init__(
self,
type: _Optional[_Union[HCIPacket.PacketType, str]] = ...,
packet: _Optional[bytes] = ...,
) -> None: ...
@@ -0,0 +1,17 @@
# 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.
# Generated by the gRPC Python protocol compiler plugin. DO NOT EDIT!
"""Client and server classes corresponding to protobuf-defined services."""
import grpc
+4 -17
View File
@@ -1,4 +1,4 @@
# Copyright 2021-2022 Google LLC # Copyright 2021-2023 Google LLC
# #
# Licensed under the Apache License, Version 2.0 (the "License"); # Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License. # you may not use this file except in compliance with the License.
@@ -16,10 +16,9 @@
# Generated by the protocol buffer compiler. DO NOT EDIT! # Generated by the protocol buffer compiler. DO NOT EDIT!
# source: emulated_bluetooth.proto # source: emulated_bluetooth.proto
"""Generated protocol buffer code.""" """Generated protocol buffer code."""
from google.protobuf.internal import builder as _builder
from google.protobuf import descriptor as _descriptor from google.protobuf import descriptor as _descriptor
from google.protobuf import descriptor_pool as _descriptor_pool from google.protobuf import descriptor_pool as _descriptor_pool
from google.protobuf import message as _message
from google.protobuf import reflection as _reflection
from google.protobuf import symbol_database as _symbol_database from google.protobuf import symbol_database as _symbol_database
# @@protoc_insertion_point(imports) # @@protoc_insertion_point(imports)
@@ -34,20 +33,8 @@ DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile(
b'\n\x18\x65mulated_bluetooth.proto\x12\x1b\x61ndroid.emulation.bluetooth\x1a emulated_bluetooth_packets.proto\"\x19\n\x07RawData\x12\x0e\n\x06packet\x18\x01 \x01(\x0c\x32\xcb\x02\n\x18\x45mulatedBluetoothService\x12\x64\n\x12registerClassicPhy\x12$.android.emulation.bluetooth.RawData\x1a$.android.emulation.bluetooth.RawData(\x01\x30\x01\x12`\n\x0eregisterBlePhy\x12$.android.emulation.bluetooth.RawData\x1a$.android.emulation.bluetooth.RawData(\x01\x30\x01\x12g\n\x11registerHCIDevice\x12&.android.emulation.bluetooth.HCIPacket\x1a&.android.emulation.bluetooth.HCIPacket(\x01\x30\x01\x42\"\n\x1e\x63om.android.emulator.bluetoothP\x01\x62\x06proto3' b'\n\x18\x65mulated_bluetooth.proto\x12\x1b\x61ndroid.emulation.bluetooth\x1a emulated_bluetooth_packets.proto\"\x19\n\x07RawData\x12\x0e\n\x06packet\x18\x01 \x01(\x0c\x32\xcb\x02\n\x18\x45mulatedBluetoothService\x12\x64\n\x12registerClassicPhy\x12$.android.emulation.bluetooth.RawData\x1a$.android.emulation.bluetooth.RawData(\x01\x30\x01\x12`\n\x0eregisterBlePhy\x12$.android.emulation.bluetooth.RawData\x1a$.android.emulation.bluetooth.RawData(\x01\x30\x01\x12g\n\x11registerHCIDevice\x12&.android.emulation.bluetooth.HCIPacket\x1a&.android.emulation.bluetooth.HCIPacket(\x01\x30\x01\x42\"\n\x1e\x63om.android.emulator.bluetoothP\x01\x62\x06proto3'
) )
_builder.BuildMessageAndEnumDescriptors(DESCRIPTOR, globals())
_RAWDATA = DESCRIPTOR.message_types_by_name['RawData'] _builder.BuildTopDescriptorsAndMessages(DESCRIPTOR, 'emulated_bluetooth_pb2', globals())
RawData = _reflection.GeneratedProtocolMessageType(
'RawData',
(_message.Message,),
{
'DESCRIPTOR': _RAWDATA,
'__module__': 'emulated_bluetooth_pb2'
# @@protoc_insertion_point(class_scope:android.emulation.bluetooth.RawData)
},
)
_sym_db.RegisterMessage(RawData)
_EMULATEDBLUETOOTHSERVICE = DESCRIPTOR.services_by_name['EmulatedBluetoothService']
if _descriptor._USE_C_DESCRIPTORS == False: if _descriptor._USE_C_DESCRIPTORS == False:
DESCRIPTOR._options = None DESCRIPTOR._options = None
@@ -0,0 +1,26 @@
# 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.
import emulated_bluetooth_packets_pb2 as _emulated_bluetooth_packets_pb2
from google.protobuf import descriptor as _descriptor
from google.protobuf import message as _message
from typing import ClassVar as _ClassVar, Optional as _Optional
DESCRIPTOR: _descriptor.FileDescriptor
class RawData(_message.Message):
__slots__ = ["packet"]
PACKET_FIELD_NUMBER: _ClassVar[int]
packet: bytes
def __init__(self, packet: _Optional[bytes] = ...) -> None: ...
@@ -1,4 +1,4 @@
# Copyright 2021-2022 Google LLC # Copyright 2021-2023 Google LLC
# #
# Licensed under the Apache License, Version 2.0 (the "License"); # Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License. # you may not use this file except in compliance with the License.
@@ -1,4 +1,4 @@
# Copyright 2021-2022 Google LLC # Copyright 2021-2023 Google LLC
# #
# Licensed under the Apache License, Version 2.0 (the "License"); # Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License. # you may not use this file except in compliance with the License.
@@ -16,10 +16,9 @@
# Generated by the protocol buffer compiler. DO NOT EDIT! # Generated by the protocol buffer compiler. DO NOT EDIT!
# source: emulated_bluetooth_vhci.proto # source: emulated_bluetooth_vhci.proto
"""Generated protocol buffer code.""" """Generated protocol buffer code."""
from google.protobuf.internal import builder as _builder
from google.protobuf import descriptor as _descriptor from google.protobuf import descriptor as _descriptor
from google.protobuf import descriptor_pool as _descriptor_pool from google.protobuf import descriptor_pool as _descriptor_pool
from google.protobuf import message as _message
from google.protobuf import reflection as _reflection
from google.protobuf import symbol_database as _symbol_database from google.protobuf import symbol_database as _symbol_database
# @@protoc_insertion_point(imports) # @@protoc_insertion_point(imports)
@@ -27,15 +26,17 @@ from google.protobuf import symbol_database as _symbol_database
_sym_db = _symbol_database.Default() _sym_db = _symbol_database.Default()
import emulated_bluetooth_packets_pb2 as emulated__bluetooth__packets__pb2 from . import emulated_bluetooth_packets_pb2 as emulated__bluetooth__packets__pb2
DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile( DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile(
b'\n\x1d\x65mulated_bluetooth_vhci.proto\x12\x1b\x61ndroid.emulation.bluetooth\x1a emulated_bluetooth_packets.proto2y\n\x15VhciForwardingService\x12`\n\nattachVhci\x12&.android.emulation.bluetooth.HCIPacket\x1a&.android.emulation.bluetooth.HCIPacket(\x01\x30\x01\x42J\n\x1f\x63om.android.emulation.bluetoothP\x01\xf8\x01\x01\xa2\x02\x03\x41\x45\x42\xaa\x02\x1b\x41ndroid.Emulation.Bluetoothb\x06proto3' b'\n\x1d\x65mulated_bluetooth_vhci.proto\x12\x1b\x61ndroid.emulation.bluetooth\x1a emulated_bluetooth_packets.proto2y\n\x15VhciForwardingService\x12`\n\nattachVhci\x12&.android.emulation.bluetooth.HCIPacket\x1a&.android.emulation.bluetooth.HCIPacket(\x01\x30\x01\x42J\n\x1f\x63om.android.emulation.bluetoothP\x01\xf8\x01\x01\xa2\x02\x03\x41\x45\x42\xaa\x02\x1b\x41ndroid.Emulation.Bluetoothb\x06proto3'
) )
_builder.BuildMessageAndEnumDescriptors(DESCRIPTOR, globals())
_VHCIFORWARDINGSERVICE = DESCRIPTOR.services_by_name['VhciForwardingService'] _builder.BuildTopDescriptorsAndMessages(
DESCRIPTOR, 'emulated_bluetooth_vhci_pb2', globals()
)
if _descriptor._USE_C_DESCRIPTORS == False: if _descriptor._USE_C_DESCRIPTORS == False:
DESCRIPTOR._options = None DESCRIPTOR._options = None
@@ -0,0 +1,19 @@
# 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.
import emulated_bluetooth_packets_pb2 as _emulated_bluetooth_packets_pb2
from google.protobuf import descriptor as _descriptor
from typing import ClassVar as _ClassVar
DESCRIPTOR: _descriptor.FileDescriptor
@@ -1,4 +1,4 @@
# Copyright 2021-2022 Google LLC # Copyright 2021-2023 Google LLC
# #
# Licensed under the Apache License, Version 2.0 (the "License"); # Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License. # you may not use this file except in compliance with the License.
+2 -2
View File
@@ -97,7 +97,7 @@ async def open_hci_socket_transport(spec):
super().__init__() super().__init__()
self.socket = hci_socket self.socket = hci_socket
asyncio.get_running_loop().add_reader( asyncio.get_running_loop().add_reader(
socket.fileno(), self.recv_until_would_block self.socket.fileno(), self.recv_until_would_block
) )
def recv_until_would_block(self): def recv_until_would_block(self):
@@ -140,7 +140,7 @@ async def open_hci_socket_transport(spec):
if not self.writer_added: if not self.writer_added:
asyncio.get_running_loop().add_writer( asyncio.get_running_loop().add_writer(
# pylint: disable=no-member # pylint: disable=no-member
socket.fileno(), self.socket.fileno(),
self.send_until_would_block, self.send_until_would_block,
) )
self.writer_added = True self.writer_added = True
View File
+11 -6
View File
@@ -20,13 +20,12 @@ import logging
import threading import threading
import time import time
import libusb_package
import usb.core import usb.core
import usb.util import usb.util
from colors import color
from .common import Transport, ParserSource from .common import Transport, ParserSource
from .. import hci from .. import hci
from ..colors import color
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
@@ -205,16 +204,22 @@ async def open_pyusb_transport(spec):
await self.sink.stop() await self.sink.stop()
usb.util.release_interface(self.device, 0) usb.util.release_interface(self.device, 0)
usb_find = usb.core.find
try:
import libusb_package
except ImportError:
logger.debug('libusb_package is not available')
else:
usb_find = libusb_package.find
# Find the device according to the spec moniker # Find the device according to the spec moniker
if ':' in spec: if ':' in spec:
vendor_id, product_id = spec.split(':') vendor_id, product_id = spec.split(':')
device = libusb_package.find( device = usb_find(idVendor=int(vendor_id, 16), idProduct=int(product_id, 16))
idVendor=int(vendor_id, 16), idProduct=int(product_id, 16)
)
else: else:
device_index = int(spec) device_index = int(spec)
devices = list( devices = list(
libusb_package.find( usb_find(
find_all=1, find_all=1,
bDeviceClass=USB_DEVICE_CLASS_WIRELESS_CONTROLLER, bDeviceClass=USB_DEVICE_CLASS_WIRELESS_CONTROLLER,
bDeviceSubClass=USB_DEVICE_SUBCLASS_RF_CONTROLLER, bDeviceSubClass=USB_DEVICE_SUBCLASS_RF_CONTROLLER,
+15 -7
View File
@@ -22,12 +22,11 @@ import collections
import ctypes import ctypes
import platform import platform
import libusb_package
import usb1 import usb1
from colors import color
from .common import Transport, ParserSource from .common import Transport, ParserSource
from .. import hci from .. import hci
from ..colors import color
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
@@ -45,11 +44,20 @@ def load_libusb():
If the library does not exists, do nothing and usb1 will search default system paths If the library does not exists, do nothing and usb1 will search default system paths
when usb1.USBContext is created. when usb1.USBContext is created.
''' '''
if libusb_path := libusb_package.get_library_path(): try:
logger.debug(f'loading libusb library at {libusb_path}') import libusb_package
dll_loader = ctypes.WinDLL if platform.system() == 'Windows' else ctypes.CDLL except ImportError:
libusb_dll = dll_loader(str(libusb_path), use_errno=True, use_last_error=True) logger.debug('libusb_package is not available')
usb1.loadLibrary(libusb_dll) else:
if libusb_path := libusb_package.get_library_path():
logger.debug(f'loading libusb library at {libusb_path}')
dll_loader = (
ctypes.WinDLL if platform.system() == 'Windows' else ctypes.CDLL
)
libusb_dll = dll_loader(
str(libusb_path), use_errno=True, use_last_error=True
)
usb1.loadLibrary(libusb_dll)
async def open_usb_transport(spec): async def open_usb_transport(spec):
+24 -3
View File
@@ -20,11 +20,11 @@ import logging
import traceback import traceback
import collections import collections
import sys import sys
from typing import Awaitable from typing import Awaitable, Set, TypeVar
from functools import wraps from functools import wraps
from colors import color
from pyee import EventEmitter from pyee import EventEmitter
from .colors import color
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
# Logging # Logging
@@ -65,8 +65,11 @@ def composite_listener(cls):
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
_T = TypeVar('_T')
class AbortableEventEmitter(EventEmitter): class AbortableEventEmitter(EventEmitter):
def abort_on(self, event: str, awaitable: Awaitable): def abort_on(self, event: str, awaitable: Awaitable[_T]) -> Awaitable[_T]:
""" """
Set a coroutine or future to abort when an event occur. Set a coroutine or future to abort when an event occur.
""" """
@@ -75,6 +78,8 @@ class AbortableEventEmitter(EventEmitter):
return future return future
def on_event(*_): def on_event(*_):
if future.done():
return
msg = f'abort: {event} event occurred.' msg = f'abort: {event} event occurred.'
if isinstance(future, asyncio.Task): if isinstance(future, asyncio.Task):
# python < 3.9 does not support passing a message on `Task.cancel` # python < 3.9 does not support passing a message on `Task.cancel`
@@ -152,6 +157,9 @@ class AsyncRunner:
# Shared default queue # Shared default queue
default_queue = WorkQueue() default_queue = WorkQueue()
# Shared set of running tasks
running_tasks: Set[Awaitable] = set()
@staticmethod @staticmethod
def run_in_task(queue=None): def run_in_task(queue=None):
""" """
@@ -182,6 +190,19 @@ class AsyncRunner:
return decorator return decorator
@staticmethod
def spawn(coroutine):
"""
Spawn a task to run a coroutine in a "fire and forget" mode.
Using this method instead of just calling `asyncio.create_task(coroutine)`
is necessary when you don't keep a reference to the task, because `asyncio`
only keeps weak references to alive tasks.
"""
task = asyncio.create_task(coroutine)
AsyncRunner.running_tasks.add(task)
task.add_done_callback(AsyncRunner.running_tasks.remove)
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
class FlowControlAsyncPipe: class FlowControlAsyncPipe:
+3 -2
View File
@@ -43,7 +43,7 @@ nav:
- Apps & Tools: - Apps & Tools:
- Overview: apps_and_tools/index.md - Overview: apps_and_tools/index.md
- Console: apps_and_tools/console.md - Console: apps_and_tools/console.md
- Link Relay: apps_and_tools/link_relay.md - Bench: apps_and_tools/bench.md
- HCI Bridge: apps_and_tools/hci_bridge.md - HCI Bridge: apps_and_tools/hci_bridge.md
- Golden Gate Bridge: apps_and_tools/gg_bridge.md - Golden Gate Bridge: apps_and_tools/gg_bridge.md
- Show: apps_and_tools/show.md - Show: apps_and_tools/show.md
@@ -51,6 +51,7 @@ nav:
- Pair: apps_and_tools/pair.md - Pair: apps_and_tools/pair.md
- Unbond: apps_and_tools/unbond.md - Unbond: apps_and_tools/unbond.md
- USB Probe: apps_and_tools/usb_probe.md - USB Probe: apps_and_tools/usb_probe.md
- Link Relay: apps_and_tools/link_relay.md
- Hardware: - Hardware:
- Overview: hardware/index.md - Overview: hardware/index.md
- Platforms: - Platforms:
@@ -62,7 +63,7 @@ nav:
- Examples: - Examples:
- Overview: examples/index.md - Overview: examples/index.md
copyright: Copyright 2021-2022 Google LLC copyright: Copyright 2021-2023 Google LLC
theme: theme:
name: 'material' name: 'material'
+158
View File
@@ -0,0 +1,158 @@
BENCH TOOL
==========
The "bench" tool implements a number of different ways of measuring the
throughput and/or latency between two devices.
# General Usage
```
Usage: bench.py [OPTIONS] COMMAND [ARGS]...
Options:
--device-config FILENAME Device configuration file
--role [sender|receiver|ping|pong]
--mode [gatt-client|gatt-server|l2cap-client|l2cap-server|rfcomm-client|rfcomm-server]
--att-mtu MTU GATT MTU (gatt-client mode) [23<=x<=517]
-s, --packet-size SIZE Packet size (server role) [8<=x<=4096]
-c, --packet-count COUNT Packet count (server role)
-sd, --start-delay SECONDS Start delay (server role)
--help Show this message and exit.
Commands:
central Run as a central (initiates the connection)
peripheral Run as a peripheral (waits for a connection)
```
## Options for the ``central`` Command
```
Usage: bumble-bench central [OPTIONS] TRANSPORT
Run as a central (initiates the connection)
Options:
--peripheral ADDRESS_OR_NAME Address or name to connect to
--connection-interval, --ci CONNECTION_INTERVAL
Connection interval (in ms)
--phy [1m|2m|coded] PHY to use
--help Show this message and exit.
```
To test once device against another, one of the two devices must be running
the ``peripheral`` command and the other the ``central`` command. The device
running the ``peripheral`` command will accept connections from the device
running the ``central`` command.
When using Bluetooth LE (all modes except for ``rfcomm-server`` and ``rfcomm-client``utils),
the default addresses configured in the tool should be sufficient. But when using
Bluetooth Classic, the address of the Peripheral must be specified on the Central
using the ``--peripheral`` option. The address will be printed by the Peripheral when
it starts.
Independently of whether the device is the Central or Peripheral, each device selects a
``mode`` and and ``role`` to run as. The ``mode`` and ``role`` of the Central and Peripheral
must be compatible.
Device 1 mode | Device 2 mode
------------------|------------------
``gatt-client`` | ``gatt-server``
``l2cap-client`` | ``l2cap-server``
``rfcomm-client`` | ``rfcomm-server``
Device 1 role | Device 2 role
--------------|--------------
``sender`` | ``receiver``
``ping`` | ``pong``
# Examples
In the following examples, we have two USB Bluetooth controllers, one on `usb:0` and
the other on `usb:1`, and two consoles/terminals. We will run a command in each.
!!! example "GATT Throughput"
Using the default mode and role for the Central and Peripheral.
In the first console/terminal:
```
$ bumble-bench peripheral usb:0
```
In the second console/terminal:
```
$ bumble-bench central usb:1
```
In this default configuration, the Central runs a Sender, as a GATT client,
connecting to the Peripheral running a Receiver, as a GATT server.
!!! example "L2CAP Throughput"
In the first console/terminal:
```
$ bumble-bench --mode l2cap-server peripheral usb:0
```
In the second console/terminal:
```
$ bumble-bench --mode l2cap-client central usb:1
```
!!! example "RFComm Throughput"
In the first console/terminal:
```
$ bumble-bench --mode rfcomm-server peripheral usb:0
```
NOTE: the BT address of the Peripheral will be printed out, use it with the
``--peripheral`` option for the Central.
In this example, we use a larger packet size and packet count than the default.
In the second console/terminal:
```
$ bumble-bench --mode rfcomm-client --packet-size 2000 --packet-count 100 central --peripheral 00:16:A4:5A:40:F2 usb:1
```
!!! example "Ping/Pong Latency"
In the first console/terminal:
```
$ bumble-bench --role pong peripheral usb:0
```
In the second console/terminal:
```
$ bumble-bench --role ping central usb:1
```
!!! example "Reversed modes with GATT and custom connection interval"
In the first console/terminal:
```
$ bumble-bench --mode gatt-client peripheral usb:0
```
In the second console/terminal:
```
$ bumble-bench --mode gatt-server central --ci 10 usb:1
```
!!! example "Reversed modes with L2CAP and custom PHY"
In the first console/terminal:
```
$ bumble-bench --mode l2cap-client peripheral usb:0
```
In the second console/terminal:
```
$ bumble-bench --mode l2cap-server central --phy 2m usb:1
```
!!! example "Reversed roles with L2CAP"
In the first console/terminal:
```
$ bumble-bench --mode l2cap-client --role sender peripheral usb:0
```
In the second console/terminal:
```
$ bumble-bench --mode l2cap-server --role receiver central usb:1
```
+1
View File
@@ -5,6 +5,7 @@ Included in the project are a few apps and tools, built on top of the core libra
These include: These include:
* [Console](console.md) - an interactive text-based console * [Console](console.md) - an interactive text-based console
* [Bench](bench.md) - Speed and Latency benchmarking between two devices (LE and Classic)
* [Pair](pair.md) - Pair/bond two devices (LE and Classic) * [Pair](pair.md) - Pair/bond two devices (LE and Classic)
* [Unbond](unbond.md) - Remove a previously established bond * [Unbond](unbond.md) - Remove a previously established bond
* [HCI Bridge](hci_bridge.md) - a HCI transport bridge to connect two HCI transports and filter/snoop the HCI packets * [HCI Bridge](hci_bridge.md) - a HCI transport bridge to connect two HCI transports and filter/snoop the HCI packets
+1 -2
View File
@@ -8,8 +8,7 @@ The project initially only supported BLE (Bluetooth Low Energy), but support for
eventually added. Support for BLE is therefore currently somewhat more advanced than for Classic. eventually added. Support for BLE is therefore currently somewhat more advanced than for Classic.
!!! warning !!! warning
This project is still very much experimental and in an alpha state where a lot of things are still missing or broken, and what's there changes frequently. This project is still in an early state of development where some things are still missing or broken, and what's implemented may change and evolve frequently.
Also, there are still a few hardcoded values/parameters in some of the examples and apps which need to be changed (those will eventually be command line arguments, as appropriate)
Overview Overview
-------- --------
+1 -1
View File
@@ -19,7 +19,7 @@ import asyncio
import sys import sys
import os import os
import logging import logging
from colors import color from bumble.colors import color
from bumble.device import Device from bumble.device import Device
from bumble.transport import open_transport from bumble.transport import open_transport
from bumble.profiles.battery_service import BatteryServiceProxy from bumble.profiles.battery_service import BatteryServiceProxy
+1 -1
View File
@@ -19,7 +19,7 @@ import asyncio
import sys import sys
import os import os
import logging import logging
from colors import color from bumble.colors import color
from bumble.device import Device, Peer from bumble.device import Device, Peer
from bumble.profiles.device_information_service import DeviceInformationServiceProxy from bumble.profiles.device_information_service import DeviceInformationServiceProxy
from bumble.transport import open_transport from bumble.transport import open_transport
+1 -1
View File
@@ -19,7 +19,7 @@ import asyncio
import sys import sys
import os import os
import logging import logging
from colors import color from bumble.colors import color
from bumble.device import Device from bumble.device import Device
from bumble.transport import open_transport from bumble.transport import open_transport
from bumble.profiles.heart_rate_service import HeartRateServiceProxy from bumble.profiles.heart_rate_service import HeartRateServiceProxy
+3 -1
View File
@@ -1,4 +1,6 @@
{ {
"name": "Bumble Hands-Free", "name": "Bumble Hands-Free",
"class_of_device": 2360324 "class_of_device": 2360324,
"keystore": "JsonKeyStore",
"le_enabled": false
} }
+12 -10
View File
@@ -22,7 +22,7 @@ import logging
import struct import struct
import json import json
import websockets import websockets
from colors import color from bumble.colors import color
from bumble.core import AdvertisingData from bumble.core import AdvertisingData
from bumble.device import Device, Connection, Peer from bumble.device import Device, Connection, Peer
@@ -209,7 +209,7 @@ async def keyboard_host(device, peer_address):
return return
for i, characteristic in enumerate(report_characteristics): for i, characteristic in enumerate(report_characteristics):
print(color('REPORT:', 'yellow'), characteristic) print(color('REPORT:', 'yellow'), characteristic)
if characteristic.properties & Characteristic.NOTIFY: if characteristic.properties & Characteristic.Properties.NOTIFY:
await peer.discover_descriptors(characteristic) await peer.discover_descriptors(characteristic)
report_reference_descriptor = characteristic.get_descriptor( report_reference_descriptor = characteristic.get_descriptor(
GATT_REPORT_REFERENCE_DESCRIPTOR GATT_REPORT_REFERENCE_DESCRIPTOR
@@ -241,7 +241,9 @@ async def keyboard_device(device, command):
# Create an 'input report' characteristic to send keyboard reports to the host # Create an 'input report' characteristic to send keyboard reports to the host
input_report_characteristic = Characteristic( input_report_characteristic = Characteristic(
GATT_REPORT_CHARACTERISTIC, GATT_REPORT_CHARACTERISTIC,
Characteristic.READ | Characteristic.WRITE | Characteristic.NOTIFY, Characteristic.Properties.READ
| Characteristic.Properties.WRITE
| Characteristic.Properties.NOTIFY,
Characteristic.READABLE | Characteristic.WRITEABLE, Characteristic.READABLE | Characteristic.WRITEABLE,
bytes([0, 0, 0, 0, 0, 0, 0, 0]), bytes([0, 0, 0, 0, 0, 0, 0, 0]),
[ [
@@ -256,8 +258,8 @@ async def keyboard_device(device, command):
# Create an 'output report' characteristic to receive keyboard reports from the host # Create an 'output report' characteristic to receive keyboard reports from the host
output_report_characteristic = Characteristic( output_report_characteristic = Characteristic(
GATT_REPORT_CHARACTERISTIC, GATT_REPORT_CHARACTERISTIC,
Characteristic.READ Characteristic.Properties.READ
| Characteristic.WRITE | Characteristic.Properties.WRITE
| Characteristic.WRITE_WITHOUT_RESPONSE, | Characteristic.WRITE_WITHOUT_RESPONSE,
Characteristic.READABLE | Characteristic.WRITEABLE, Characteristic.READABLE | Characteristic.WRITEABLE,
bytes([0]), bytes([0]),
@@ -278,7 +280,7 @@ async def keyboard_device(device, command):
[ [
Characteristic( Characteristic(
GATT_MANUFACTURER_NAME_STRING_CHARACTERISTIC, GATT_MANUFACTURER_NAME_STRING_CHARACTERISTIC,
Characteristic.READ, Characteristic.Properties.READ,
Characteristic.READABLE, Characteristic.READABLE,
'Bumble', 'Bumble',
) )
@@ -289,13 +291,13 @@ async def keyboard_device(device, command):
[ [
Characteristic( Characteristic(
GATT_PROTOCOL_MODE_CHARACTERISTIC, GATT_PROTOCOL_MODE_CHARACTERISTIC,
Characteristic.READ, Characteristic.Properties.READ,
Characteristic.READABLE, Characteristic.READABLE,
bytes([HID_REPORT_PROTOCOL]), bytes([HID_REPORT_PROTOCOL]),
), ),
Characteristic( Characteristic(
GATT_HID_INFORMATION_CHARACTERISTIC, GATT_HID_INFORMATION_CHARACTERISTIC,
Characteristic.READ, Characteristic.Properties.READ,
Characteristic.READABLE, Characteristic.READABLE,
# bcdHID=1.1, bCountryCode=0x00, # bcdHID=1.1, bCountryCode=0x00,
# Flags=RemoteWake|NormallyConnectable # Flags=RemoteWake|NormallyConnectable
@@ -309,7 +311,7 @@ async def keyboard_device(device, command):
), ),
Characteristic( Characteristic(
GATT_REPORT_MAP_CHARACTERISTIC, GATT_REPORT_MAP_CHARACTERISTIC,
Characteristic.READ, Characteristic.Properties.READ,
Characteristic.READABLE, Characteristic.READABLE,
HID_KEYBOARD_REPORT_MAP, HID_KEYBOARD_REPORT_MAP,
), ),
@@ -322,7 +324,7 @@ async def keyboard_device(device, command):
[ [
Characteristic( Characteristic(
GATT_BATTERY_LEVEL_CHARACTERISTIC, GATT_BATTERY_LEVEL_CHARACTERISTIC,
Characteristic.READ, Characteristic.Properties.READ,
Characteristic.READABLE, Characteristic.READABLE,
bytes([100]), bytes([100]),
) )
+1 -1
View File
@@ -20,7 +20,7 @@ import sys
import os import os
import logging import logging
from colors import color from bumble.colors import color
from bumble.device import Device from bumble.device import Device
from bumble.transport import open_transport_or_link from bumble.transport import open_transport_or_link
from bumble.core import ( from bumble.core import (
+1 -1
View File
@@ -20,7 +20,7 @@ import sys
import os import os
import logging import logging
from colors import color from bumble.colors import color
from bumble.device import Device from bumble.device import Device
from bumble.transport import open_transport_or_link from bumble.transport import open_transport_or_link
from bumble.core import BT_BR_EDR_TRANSPORT from bumble.core import BT_BR_EDR_TRANSPORT
+4 -4
View File
@@ -101,7 +101,7 @@ async def main():
# Add the ASHA service to the GATT server # Add the ASHA service to the GATT server
read_only_properties_characteristic = Characteristic( read_only_properties_characteristic = Characteristic(
ASHA_READ_ONLY_PROPERTIES_CHARACTERISTIC, ASHA_READ_ONLY_PROPERTIES_CHARACTERISTIC,
Characteristic.READ, Characteristic.Properties.READ,
Characteristic.READABLE, Characteristic.READABLE,
bytes( bytes(
[ [
@@ -127,13 +127,13 @@ async def main():
) )
audio_control_point_characteristic = Characteristic( audio_control_point_characteristic = Characteristic(
ASHA_AUDIO_CONTROL_POINT_CHARACTERISTIC, ASHA_AUDIO_CONTROL_POINT_CHARACTERISTIC,
Characteristic.WRITE | Characteristic.WRITE_WITHOUT_RESPONSE, Characteristic.Properties.WRITE | Characteristic.WRITE_WITHOUT_RESPONSE,
Characteristic.WRITEABLE, Characteristic.WRITEABLE,
CharacteristicValue(write=on_audio_control_point_write), CharacteristicValue(write=on_audio_control_point_write),
) )
audio_status_characteristic = Characteristic( audio_status_characteristic = Characteristic(
ASHA_AUDIO_STATUS_CHARACTERISTIC, ASHA_AUDIO_STATUS_CHARACTERISTIC,
Characteristic.READ | Characteristic.NOTIFY, Characteristic.Properties.READ | Characteristic.Properties.NOTIFY,
Characteristic.READABLE, Characteristic.READABLE,
bytes([0]), bytes([0]),
) )
@@ -145,7 +145,7 @@ async def main():
) )
le_psm_out_characteristic = Characteristic( le_psm_out_characteristic = Characteristic(
ASHA_LE_PSM_OUT_CHARACTERISTIC, ASHA_LE_PSM_OUT_CHARACTERISTIC,
Characteristic.READ, Characteristic.Properties.READ,
Characteristic.READABLE, Characteristic.READABLE,
struct.pack('<H', psm), struct.pack('<H', psm),
) )
+1 -1
View File
@@ -19,7 +19,7 @@ import asyncio
import sys import sys
import os import os
import logging import logging
from colors import color from bumble.colors import color
from bumble.device import Device from bumble.device import Device
from bumble.transport import open_transport_or_link from bumble.transport import open_transport_or_link
+1 -1
View File
@@ -19,7 +19,7 @@ import asyncio
import sys import sys
import os import os
import logging import logging
from colors import color from bumble.colors import color
from bumble.device import Device from bumble.device import Device
from bumble.transport import open_transport_or_link from bumble.transport import open_transport_or_link
+1 -1
View File
@@ -80,7 +80,7 @@ async def main():
) )
manufacturer_name_characteristic = Characteristic( manufacturer_name_characteristic = Characteristic(
GATT_MANUFACTURER_NAME_STRING_CHARACTERISTIC, GATT_MANUFACTURER_NAME_STRING_CHARACTERISTIC,
Characteristic.READ, Characteristic.Properties.READ,
Characteristic.READABLE, Characteristic.READABLE,
"Fitbit", "Fitbit",
[descriptor], [descriptor],
+1 -1
View File
@@ -19,7 +19,7 @@ import logging
import asyncio import asyncio
import sys import sys
import os import os
from colors import color from bumble.colors import color
from bumble.device import Device from bumble.device import Device
from bumble.controller import Controller from bumble.controller import Controller
+51
View File
@@ -0,0 +1,51 @@
# Copyright 2021-2023 Google LLC
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# https://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
# -----------------------------------------------------------------------------
# Imports
# -----------------------------------------------------------------------------
import asyncio
import sys
import os
import logging
from bumble.colors import color
from bumble.device import Device
from bumble.transport import open_transport_or_link
from bumble.snoop import BtSnooper
# -----------------------------------------------------------------------------
async def main():
if len(sys.argv) != 3:
print('Usage: run_device_with_snooper.py <transport-spec> <snoop-file>')
print('example: run_device_with_snooper.py usb:0 btsnoop.log')
return
print('<<< connecting to HCI...')
async with await open_transport_or_link(sys.argv[1]) as (hci_source, hci_sink):
print('<<< connected')
device = Device.with_hci('Bumble', 'F0:F1:F2:F3:F4:F5', hci_source, hci_sink)
with open(sys.argv[2], "wb") as snoop_file:
device.host.snooper = BtSnooper(snoop_file)
await device.power_on()
await device.start_scanning()
await hci_source.wait_for_termination()
# -----------------------------------------------------------------------------
logging.basicConfig(level=os.environ.get('BUMBLE_LOGLEVEL', 'DEBUG').upper())
asyncio.run(main())
+1 -1
View File
@@ -19,7 +19,7 @@ import asyncio
import sys import sys
import os import os
import logging import logging
from colors import color from bumble.colors import color
from bumble.core import ProtocolError from bumble.core import ProtocolError
from bumble.device import Device, Peer from bumble.device import Device, Peer
+2 -2
View File
@@ -18,7 +18,7 @@
import asyncio import asyncio
import os import os
import logging import logging
from colors import color from bumble.colors import color
from bumble.core import ProtocolError from bumble.core import ProtocolError
from bumble.controller import Controller from bumble.controller import Controller
@@ -70,7 +70,7 @@ async def main():
) )
manufacturer_name_characteristic = Characteristic( manufacturer_name_characteristic = Characteristic(
GATT_MANUFACTURER_NAME_STRING_CHARACTERISTIC, GATT_MANUFACTURER_NAME_STRING_CHARACTERISTIC,
Characteristic.READ, Characteristic.Properties.READ,
Characteristic.READABLE, Characteristic.READABLE,
"Fitbit", "Fitbit",
[descriptor], [descriptor],
+4 -4
View File
@@ -96,7 +96,7 @@ async def main():
) )
manufacturer_name_characteristic = Characteristic( manufacturer_name_characteristic = Characteristic(
GATT_MANUFACTURER_NAME_STRING_CHARACTERISTIC, GATT_MANUFACTURER_NAME_STRING_CHARACTERISTIC,
Characteristic.READ, Characteristic.Properties.READ,
Characteristic.READABLE, Characteristic.READABLE,
'Fitbit', 'Fitbit',
[descriptor], [descriptor],
@@ -109,13 +109,13 @@ async def main():
[ [
Characteristic( Characteristic(
'D901B45B-4916-412E-ACCA-376ECB603B2C', 'D901B45B-4916-412E-ACCA-376ECB603B2C',
Characteristic.READ | Characteristic.WRITE, Characteristic.Properties.READ | Characteristic.Properties.WRITE,
Characteristic.READABLE | Characteristic.WRITEABLE, Characteristic.READABLE | Characteristic.WRITEABLE,
CharacteristicValue(read=my_custom_read, write=my_custom_write), CharacteristicValue(read=my_custom_read, write=my_custom_write),
), ),
Characteristic( Characteristic(
'552957FB-CF1F-4A31-9535-E78847E1A714', '552957FB-CF1F-4A31-9535-E78847E1A714',
Characteristic.READ | Characteristic.WRITE, Characteristic.Properties.READ | Characteristic.Properties.WRITE,
Characteristic.READABLE | Characteristic.WRITEABLE, Characteristic.READABLE | Characteristic.WRITEABLE,
CharacteristicValue( CharacteristicValue(
read=my_custom_read_with_error, write=my_custom_write_with_error read=my_custom_read_with_error, write=my_custom_write_with_error
@@ -123,7 +123,7 @@ async def main():
), ),
Characteristic( Characteristic(
'486F64C6-4B5F-4B3B-8AFF-EDE134A8446A', '486F64C6-4B5F-4B3B-8AFF-EDE134A8446A',
Characteristic.READ | Characteristic.NOTIFY, Characteristic.Properties.READ | Characteristic.Properties.NOTIFY,
Characteristic.READABLE, Characteristic.READABLE,
'hello', 'hello',
), ),
+1 -1
View File
@@ -20,7 +20,7 @@ import sys
import os import os
import logging import logging
from colors import color from bumble.colors import color
import bumble.core import bumble.core
from bumble.device import Device from bumble.device import Device
+5 -3
View File
@@ -74,19 +74,21 @@ async def main():
# Add a few entries to the device's GATT server # Add a few entries to the device's GATT server
characteristic1 = Characteristic( characteristic1 = Characteristic(
'486F64C6-4B5F-4B3B-8AFF-EDE134A8446A', '486F64C6-4B5F-4B3B-8AFF-EDE134A8446A',
Characteristic.READ | Characteristic.NOTIFY, Characteristic.Properties.READ | Characteristic.Properties.NOTIFY,
Characteristic.READABLE, Characteristic.READABLE,
bytes([0x40]), bytes([0x40]),
) )
characteristic2 = Characteristic( characteristic2 = Characteristic(
'8EBDEBAE-0017-418E-8D3B-3A3809492165', '8EBDEBAE-0017-418E-8D3B-3A3809492165',
Characteristic.READ | Characteristic.INDICATE, Characteristic.Properties.READ | Characteristic.Properties.INDICATE,
Characteristic.READABLE, Characteristic.READABLE,
bytes([0x41]), bytes([0x41]),
) )
characteristic3 = Characteristic( characteristic3 = Characteristic(
'8EBDEBAE-0017-418E-8D3B-3A3809492165', '8EBDEBAE-0017-418E-8D3B-3A3809492165',
Characteristic.READ | Characteristic.NOTIFY | Characteristic.INDICATE, Characteristic.Properties.READ
| Characteristic.Properties.NOTIFY
| Characteristic.Properties.INDICATE,
Characteristic.READABLE, Characteristic.READABLE,
bytes([0x42]), bytes([0x42]),
) )
+1 -1
View File
@@ -20,7 +20,7 @@ import sys
import os import os
import logging import logging
from colors import color from bumble.colors import color
import bumble.core import bumble.core
from bumble.device import Device from bumble.device import Device
+1 -1
View File
@@ -19,7 +19,7 @@ import asyncio
import sys import sys
import os import os
import logging import logging
from colors import color from bumble.colors import color
from bumble.device import Device from bumble.device import Device
from bumble.transport import open_transport_or_link from bumble.transport import open_transport_or_link
+41
View File
@@ -50,3 +50,44 @@ signature-mutators="AsyncRunner.run_in_task"
[tool.black] [tool.black]
skip-string-normalization = true skip-string-normalization = true
[[tool.mypy.overrides]]
module = "bumble.transport.emulated_bluetooth_pb2_grpc"
ignore_missing_imports = true
[[tool.mypy.overrides]]
module = "bumble.transport.emulated_bluetooth_packets_pb2"
ignore_errors = true
[[tool.mypy.overrides]]
module = "aioconsole.*"
ignore_missing_imports = true
[[tool.mypy.overrides]]
module = "colors.*"
ignore_missing_imports = true
[[tool.mypy.overrides]]
module = "construct.*"
ignore_missing_imports = true
[[tool.mypy.overrides]]
module = "emulated_bluetooth_packets_pb2.*"
ignore_missing_imports = true
[[tool.mypy.overrides]]
module = "grpc.*"
ignore_missing_imports = true
[[tool.mypy.overrides]]
module = "serial_asyncio.*"
ignore_missing_imports = true
[[tool.mypy.overrides]]
module = "usb.*"
ignore_missing_imports = true
[[tool.mypy.overrides]]
module = "usb1.*"
ignore_missing_imports = true
@@ -0,0 +1,27 @@
# Invoke this script with an argument pointing to where the Android emulator .proto files are.
# The .proto files should be slightly modified from their original version (as distributed with
# the Android emulator):
# --> Remove unused types/methods from emulated_bluetooth.proto
PROTOC_OUT=bumble/transport
LICENSE_FILE_INPUT=bumble/transport/android_emulator.py
proto_files=(emulated_bluetooth.proto emulated_bluetooth_vhci.proto emulated_bluetooth_packets.proto)
for proto_file in "${proto_files[@]}"
do
python -m grpc_tools.protoc -I$1 --proto_path=bumble/transport --python_out=$PROTOC_OUT --pyi_out=$PROTOC_OUT --grpc_python_out=$PROTOC_OUT $1/$proto_file
done
python_files=(emulated_bluetooth_pb2.py emulated_bluetooth_pb2_grpc.py emulated_bluetooth_packets_pb2.py emulated_bluetooth_packets_pb2_grpc.py emulated_bluetooth_vhci_pb2_grpc.py emulated_bluetooth_vhci_pb2.py)
for python_file in "${python_files[@]}"
do
sed -i '' 's/^import .*_pb2 as/from . &/' $PROTOC_OUT/$python_file
done
stub_files=(emulated_bluetooth_pb2.pyi emulated_bluetooth_packets_pb2.pyi emulated_bluetooth_vhci_pb2.pyi)
for source_file in "${python_files[@]}" "${stub_files[@]}"
do
head -14 $LICENSE_FILE_INPUT > $PROTOC_OUT/${source_file}.lic
cat $PROTOC_OUT/$source_file >> $PROTOC_OUT/${source_file}.lic
mv $PROTOC_OUT/${source_file}.lic $PROTOC_OUT/$source_file
done
+14 -6
View File
@@ -28,16 +28,14 @@ packages = bumble, bumble.transport, bumble.profiles, bumble.apps, bumble.apps.l
package_dir = package_dir =
bumble = bumble bumble = bumble
bumble.apps = apps bumble.apps = apps
include-package-data = True
install_requires = install_requires =
aioconsole >= 0.4.1
ansicolors >= 1.1
appdirs >= 1.4 appdirs >= 1.4
bitstruct >= 8.12
click >= 7.1.2; platform_system!='Emscripten' click >= 7.1.2; platform_system!='Emscripten'
cryptography == 35; platform_system!='Emscripten' cryptography == 35; platform_system!='Emscripten'
grpcio >= 1.46; platform_system!='Emscripten' grpcio >= 1.46; platform_system!='Emscripten'
libusb1 >= 2.0.1; platform_system!='Emscripten' libusb1 >= 2.0.1; platform_system!='Emscripten'
libusb-package == 1.0.26.0; platform_system!='Emscripten' libusb-package == 1.0.26.1; platform_system!='Emscripten'
prompt_toolkit >= 3.0.16; platform_system!='Emscripten' prompt_toolkit >= 3.0.16; platform_system!='Emscripten'
protobuf >= 3.12.4 protobuf >= 3.12.4
pyee >= 8.2.2 pyee >= 8.2.2
@@ -45,6 +43,8 @@ install_requires =
pyserial >= 3.5; platform_system!='Emscripten' pyserial >= 3.5; platform_system!='Emscripten'
pyusb >= 1.2; platform_system!='Emscripten' pyusb >= 1.2; platform_system!='Emscripten'
websockets >= 8.1; platform_system!='Emscripten' websockets >= 8.1; platform_system!='Emscripten'
prettytable >= 3.6.0
humanize >= 4.6.0
[options.entry_points] [options.entry_points]
console_scripts = console_scripts =
@@ -59,6 +59,10 @@ console_scripts =
bumble-unbond = bumble.apps.unbond:main bumble-unbond = bumble.apps.unbond:main
bumble-usb-probe = bumble.apps.usb_probe:main bumble-usb-probe = bumble.apps.usb_probe:main
bumble-link-relay = bumble.apps.link_relay.link_relay:main bumble-link-relay = bumble.apps.link_relay.link_relay:main
bumble-bench = bumble.apps.bench:main
[options.package_data]
* = py.typed, *.pyi
[options.extras_require] [options.extras_require]
build = build =
@@ -69,10 +73,14 @@ test =
pytest-html >= 3.2.0 pytest-html >= 3.2.0
coverage >= 6.4 coverage >= 6.4
development = development =
black >= 22.10 black == 22.10
invoke >= 1.7.3 invoke >= 1.7.3
mypy == 1.1.1
nox >= 2022 nox >= 2022
pylint >= 2.15.8 pylint == 2.15.8
types-appdirs >= 1.4.3
types-invoke >= 1.7.3
types-protobuf >= 4.21.0
documentation = documentation =
mkdocs >= 1.4.0 mkdocs >= 1.4.0
mkdocs-material >= 8.5.6 mkdocs-material >= 8.5.6
+25 -6
View File
@@ -22,7 +22,7 @@ Invoke tasks
import os import os
from invoke import task, call, Collection from invoke import task, call, Collection
from invoke.exceptions import UnexpectedExit from invoke.exceptions import Exit, UnexpectedExit
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
@@ -126,9 +126,9 @@ def lint(ctx, disable='C,R', errors_only=False):
try: try:
ctx.run(f"pylint {' '.join(options)} bumble apps examples tasks.py") ctx.run(f"pylint {' '.join(options)} bumble apps examples tasks.py")
print("The linter is happy. ✅ 😊 🐝'") print("The linter is happy. ✅ 😊 🐝'")
except UnexpectedExit: except UnexpectedExit as exc:
print("Please check your code against the linter messages. ❌") print("Please check your code against the linter messages. ❌")
print(">>> Linter done.") raise Exit(code=1) from exc
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
@@ -143,13 +143,31 @@ def format_code(ctx, check=False, diff=False):
print(">>> Running the formatter...") print(">>> Running the formatter...")
try: try:
ctx.run(f"black -S {' '.join(options)} .") ctx.run(f"black -S {' '.join(options)} .")
except UnexpectedExit: except UnexpectedExit as exc:
print("Please run 'invoke project.format' or 'black .' to format the code. ❌") print("Please run 'invoke project.format' or 'black .' to format the code. ❌")
print(">>> formatter done.") raise Exit(code=1) from exc
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
@task(pre=[call(format_code, check=True), call(lint, errors_only=True), test]) @task
def check_types(ctx):
checklist = ["apps", "bumble", "examples", "tests", "tasks.py"]
try:
ctx.run(f"mypy {' '.join(checklist)}")
except UnexpectedExit as exc:
print("Please check your code against the mypy messages.")
raise Exit(code=1) from exc
# -----------------------------------------------------------------------------
@task(
pre=[
call(format_code, check=True),
call(lint, errors_only=True),
call(check_types),
test,
]
)
def pre_commit(_ctx): def pre_commit(_ctx):
print("All good!") print("All good!")
@@ -157,4 +175,5 @@ def pre_commit(_ctx):
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
project_tasks.add_task(lint) project_tasks.add_task(lint)
project_tasks.add_task(format_code, name="format") project_tasks.add_task(format_code, name="format")
project_tasks.add_task(check_types, name="check-types")
project_tasks.add_task(pre_commit) project_tasks.add_task(pre_commit)
+32 -8
View File
@@ -21,6 +21,7 @@ import os
import pytest import pytest
from bumble.controller import Controller from bumble.controller import Controller
from bumble.core import BT_BR_EDR_TRANSPORT
from bumble.link import LocalLink from bumble.link import LocalLink
from bumble.device import Device from bumble.device import Device
from bumble.host import Host from bumble.host import Host
@@ -58,18 +59,19 @@ class TwoDevices:
def __init__(self): def __init__(self):
self.connections = [None, None] self.connections = [None, None]
addresses = ['F0:F1:F2:F3:F4:F5', 'F5:F4:F3:F2:F1:F0']
self.link = LocalLink() self.link = LocalLink()
self.controllers = [ self.controllers = [
Controller('C1', link=self.link), Controller('C1', link=self.link, public_address=addresses[0]),
Controller('C2', link=self.link), Controller('C2', link=self.link, public_address=addresses[1]),
] ]
self.devices = [ self.devices = [
Device( Device(
address='F0:F1:F2:F3:F4:F5', address=addresses[0],
host=Host(self.controllers[0], AsyncPipeSink(self.controllers[0])), host=Host(self.controllers[0], AsyncPipeSink(self.controllers[0])),
), ),
Device( Device(
address='F5:F4:F3:F2:F1:F0', address=addresses[1],
host=Host(self.controllers[1], AsyncPipeSink(self.controllers[1])), host=Host(self.controllers[1], AsyncPipeSink(self.controllers[1])),
), ),
] ]
@@ -79,6 +81,9 @@ class TwoDevices:
def on_connection(self, which, connection): def on_connection(self, which, connection):
self.connections[which] = connection self.connections[which] = connection
def on_paired(self, which, keys):
self.paired[which] = keys
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
@pytest.mark.asyncio @pytest.mark.asyncio
@@ -94,12 +99,21 @@ async def test_self_connection():
'connection', lambda connection: two_devices.on_connection(1, connection) 'connection', lambda connection: two_devices.on_connection(1, connection)
) )
# Enable Classic connections
two_devices.devices[0].classic_enabled = True
two_devices.devices[1].classic_enabled = True
# Start # Start
await two_devices.devices[0].power_on() await two_devices.devices[0].power_on()
await two_devices.devices[1].power_on() await two_devices.devices[1].power_on()
# Connect the two devices # Connect the two devices
await two_devices.devices[0].connect(two_devices.devices[1].random_address) await asyncio.gather(
two_devices.devices[0].connect(
two_devices.devices[1].public_address, transport=BT_BR_EDR_TRANSPORT
),
two_devices.devices[1].accept(two_devices.devices[0].public_address),
)
# Check the post conditions # Check the post conditions
assert two_devices.connections[0] is not None assert two_devices.connections[0] is not None
@@ -152,6 +166,9 @@ def sink_codec_capabilities():
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_source_sink_1(): async def test_source_sink_1():
two_devices = TwoDevices() two_devices = TwoDevices()
# Enable Classic connections
two_devices.devices[0].classic_enabled = True
two_devices.devices[1].classic_enabled = True
await two_devices.devices[0].power_on() await two_devices.devices[0].power_on()
await two_devices.devices[1].power_on() await two_devices.devices[1].power_on()
@@ -171,9 +188,16 @@ async def test_source_sink_1():
listener = Listener(Listener.create_registrar(two_devices.devices[1])) listener = Listener(Listener.create_registrar(two_devices.devices[1]))
listener.on('connection', on_avdtp_connection) listener.on('connection', on_avdtp_connection)
connection = await two_devices.devices[0].connect( async def make_connection():
two_devices.devices[1].random_address connections = await asyncio.gather(
) two_devices.devices[0].connect(
two_devices.devices[1].public_address, BT_BR_EDR_TRANSPORT
),
two_devices.devices[1].accept(two_devices.devices[0].public_address),
)
return connections[0]
connection = await make_connection()
client = await Protocol.connect(connection) client = await Protocol.connect(connection)
endpoints = await client.discover_remote_endpoints() endpoints = await client.discover_remote_endpoints()
assert len(endpoints) == 1 assert len(endpoints) == 1
+4 -6
View File
@@ -25,10 +25,8 @@ def test_ad_data():
assert data == ad_bytes assert data == ad_bytes
assert ad.get(AdvertisingData.COMPLETE_LOCAL_NAME, raw=True) is None assert ad.get(AdvertisingData.COMPLETE_LOCAL_NAME, raw=True) is None
assert ad.get(AdvertisingData.TX_POWER_LEVEL, raw=True) == bytes([123]) assert ad.get(AdvertisingData.TX_POWER_LEVEL, raw=True) == bytes([123])
assert ad.get(AdvertisingData.COMPLETE_LOCAL_NAME, return_all=True, raw=True) == [] assert ad.get_all(AdvertisingData.COMPLETE_LOCAL_NAME, raw=True) == []
assert ad.get(AdvertisingData.TX_POWER_LEVEL, return_all=True, raw=True) == [ assert ad.get_all(AdvertisingData.TX_POWER_LEVEL, raw=True) == [bytes([123])]
bytes([123])
]
data2 = bytes([2, AdvertisingData.TX_POWER_LEVEL, 234]) data2 = bytes([2, AdvertisingData.TX_POWER_LEVEL, 234])
ad.append(data2) ad.append(data2)
@@ -36,8 +34,8 @@ def test_ad_data():
assert ad_bytes == data + data2 assert ad_bytes == data + data2
assert ad.get(AdvertisingData.COMPLETE_LOCAL_NAME, raw=True) is None assert ad.get(AdvertisingData.COMPLETE_LOCAL_NAME, raw=True) is None
assert ad.get(AdvertisingData.TX_POWER_LEVEL, raw=True) == bytes([123]) assert ad.get(AdvertisingData.TX_POWER_LEVEL, raw=True) == bytes([123])
assert ad.get(AdvertisingData.COMPLETE_LOCAL_NAME, return_all=True, raw=True) == [] assert ad.get_all(AdvertisingData.COMPLETE_LOCAL_NAME, raw=True) == []
assert ad.get(AdvertisingData.TX_POWER_LEVEL, return_all=True, raw=True) == [ assert ad.get_all(AdvertisingData.TX_POWER_LEVEL, raw=True) == [
bytes([123]), bytes([123]),
bytes([234]), bytes([234]),
] ]
+47
View File
@@ -0,0 +1,47 @@
# 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.
# -----------------------------------------------------------------------------
# Imports
# -----------------------------------------------------------------------------
import hashlib
import os
from bumble.decoder import G722Decoder
# -----------------------------------------------------------------------------
def test_decode_file():
decoder = G722Decoder()
output_bytes = bytearray()
with open(
os.path.join(os.path.dirname(__file__), 'g722_sample.g722'), 'rb'
) as file:
file_content = file.read()
frame_length = 80
data_length = int(len(file_content) / frame_length)
for i in range(0, data_length):
decoded_data = decoder.decode_frame(
file_content[i * frame_length : i * frame_length + frame_length]
)
output_bytes.extend(decoded_data)
result = hashlib.md5(output_bytes).hexdigest()
assert result == 'b58e0cdd012d12f5633fc796c3b0fbd4'
# -----------------------------------------------------------------------------
if __name__ == '__main__':
test_decode_file()
+1 -4
View File
@@ -197,7 +197,7 @@ async def test_device_connect_parallel():
d1.host.set_packet_sink(Sink(d1_flow())) d1.host.set_packet_sink(Sink(d1_flow()))
d2.host.set_packet_sink(Sink(d2_flow())) d2.host.set_packet_sink(Sink(d2_flow()))
[c01, c02, a10, a20, a01] = await asyncio.gather( [c01, c02, a10, a20] = await asyncio.gather(
*[ *[
asyncio.create_task( asyncio.create_task(
d0.connect(d1.public_address, transport=BT_BR_EDR_TRANSPORT) d0.connect(d1.public_address, transport=BT_BR_EDR_TRANSPORT)
@@ -207,7 +207,6 @@ async def test_device_connect_parallel():
), ),
asyncio.create_task(d1.accept(peer_address=d0.public_address)), asyncio.create_task(d1.accept(peer_address=d0.public_address)),
asyncio.create_task(d2.accept()), asyncio.create_task(d2.accept()),
asyncio.create_task(d0.accept(peer_address=d1.public_address)),
] ]
) )
@@ -215,11 +214,9 @@ async def test_device_connect_parallel():
assert type(c02) == Connection assert type(c02) == Connection
assert type(a10) == Connection assert type(a10) == Connection
assert type(a20) == Connection assert type(a20) == Connection
assert type(a01) == Connection
assert c01.handle == a10.handle and c01.handle == 0x100 assert c01.handle == a10.handle and c01.handle == 0x100
assert c02.handle == a20.handle and c02.handle == 0x101 assert c02.handle == a20.handle and c02.handle == 0x101
assert a01 == c01
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
File diff suppressed because one or more lines are too long
+188 -30
View File
@@ -23,6 +23,7 @@ import pytest
from bumble.controller import Controller from bumble.controller import Controller
from bumble.gatt_client import CharacteristicProxy from bumble.gatt_client import CharacteristicProxy
from bumble.gatt_server import Server
from bumble.link import LocalLink from bumble.link import LocalLink
from bumble.device import Device, Peer from bumble.device import Device, Peer
from bumble.host import Host from bumble.host import Host
@@ -37,10 +38,12 @@ from bumble.gatt import (
Service, Service,
Characteristic, Characteristic,
CharacteristicValue, CharacteristicValue,
Descriptor,
) )
from bumble.transport import AsyncPipeSink from bumble.transport import AsyncPipeSink
from bumble.core import UUID from bumble.core import UUID
from bumble.att import ( from bumble.att import (
Attribute,
ATT_EXCHANGE_MTU_REQUEST, ATT_EXCHANGE_MTU_REQUEST,
ATT_ATTRIBUTE_NOT_FOUND_ERROR, ATT_ATTRIBUTE_NOT_FOUND_ERROR,
ATT_PDU, ATT_PDU,
@@ -112,7 +115,7 @@ async def test_characteristic_encoding():
c = Foo( c = Foo(
GATT_BATTERY_LEVEL_CHARACTERISTIC, GATT_BATTERY_LEVEL_CHARACTERISTIC,
Characteristic.READ, Characteristic.Properties.READ,
Characteristic.READABLE, Characteristic.READABLE,
123, 123,
) )
@@ -141,7 +144,9 @@ async def test_characteristic_encoding():
characteristic = Characteristic( characteristic = Characteristic(
'FDB159DB-036C-49E3-B3DB-6325AC750806', 'FDB159DB-036C-49E3-B3DB-6325AC750806',
Characteristic.READ | Characteristic.WRITE | Characteristic.NOTIFY, Characteristic.Properties.READ
| Characteristic.Properties.WRITE
| Characteristic.Properties.NOTIFY,
Characteristic.READABLE | Characteristic.WRITEABLE, Characteristic.READABLE | Characteristic.WRITEABLE,
bytes([123]), bytes([123]),
) )
@@ -237,7 +242,9 @@ async def test_attribute_getters():
characteristic_uuid = UUID('FDB159DB-036C-49E3-B3DB-6325AC750806') characteristic_uuid = UUID('FDB159DB-036C-49E3-B3DB-6325AC750806')
characteristic = Characteristic( characteristic = Characteristic(
characteristic_uuid, characteristic_uuid,
Characteristic.READ | Characteristic.WRITE | Characteristic.NOTIFY, Characteristic.Properties.READ
| Characteristic.Properties.WRITE
| Characteristic.Properties.NOTIFY,
Characteristic.READABLE | Characteristic.WRITEABLE, Characteristic.READABLE | Characteristic.WRITEABLE,
bytes([123]), bytes([123]),
) )
@@ -282,7 +289,7 @@ def test_CharacteristicAdapter():
v = bytes([1, 2, 3]) v = bytes([1, 2, 3])
c = Characteristic( c = Characteristic(
GATT_BATTERY_LEVEL_CHARACTERISTIC, GATT_BATTERY_LEVEL_CHARACTERISTIC,
Characteristic.READ, Characteristic.Properties.READ,
Characteristic.READABLE, Characteristic.READABLE,
v, v,
) )
@@ -418,7 +425,7 @@ async def test_read_write():
characteristic1 = Characteristic( characteristic1 = Characteristic(
'FDB159DB-036C-49E3-B3DB-6325AC750806', 'FDB159DB-036C-49E3-B3DB-6325AC750806',
Characteristic.READ | Characteristic.WRITE, Characteristic.Properties.READ | Characteristic.Properties.WRITE,
Characteristic.READABLE | Characteristic.WRITEABLE, Characteristic.READABLE | Characteristic.WRITEABLE,
) )
@@ -435,7 +442,7 @@ async def test_read_write():
characteristic2 = Characteristic( characteristic2 = Characteristic(
'66DE9057-C848-4ACA-B993-D675644EBB85', '66DE9057-C848-4ACA-B993-D675644EBB85',
Characteristic.READ | Characteristic.WRITE, Characteristic.Properties.READ | Characteristic.Properties.WRITE,
Characteristic.READABLE | Characteristic.WRITEABLE, Characteristic.READABLE | Characteristic.WRITEABLE,
CharacteristicValue( CharacteristicValue(
read=on_characteristic2_read, write=on_characteristic2_write read=on_characteristic2_read, write=on_characteristic2_write
@@ -498,7 +505,7 @@ async def test_read_write2():
v = bytes([0x11, 0x22, 0x33, 0x44]) v = bytes([0x11, 0x22, 0x33, 0x44])
characteristic1 = Characteristic( characteristic1 = Characteristic(
'FDB159DB-036C-49E3-B3DB-6325AC750806', 'FDB159DB-036C-49E3-B3DB-6325AC750806',
Characteristic.READ | Characteristic.WRITE, Characteristic.Properties.READ | Characteristic.Properties.WRITE,
Characteristic.READABLE | Characteristic.WRITEABLE, Characteristic.READABLE | Characteristic.WRITEABLE,
value=v, value=v,
) )
@@ -542,7 +549,7 @@ async def test_subscribe_notify():
characteristic1 = Characteristic( characteristic1 = Characteristic(
'FDB159DB-036C-49E3-B3DB-6325AC750806', 'FDB159DB-036C-49E3-B3DB-6325AC750806',
Characteristic.READ | Characteristic.NOTIFY, Characteristic.Properties.READ | Characteristic.Properties.NOTIFY,
Characteristic.READABLE, Characteristic.READABLE,
bytes([1, 2, 3]), bytes([1, 2, 3]),
) )
@@ -558,7 +565,7 @@ async def test_subscribe_notify():
characteristic2 = Characteristic( characteristic2 = Characteristic(
'66DE9057-C848-4ACA-B993-D675644EBB85', '66DE9057-C848-4ACA-B993-D675644EBB85',
Characteristic.READ | Characteristic.INDICATE, Characteristic.Properties.READ | Characteristic.Properties.INDICATE,
Characteristic.READABLE, Characteristic.READABLE,
bytes([4, 5, 6]), bytes([4, 5, 6]),
) )
@@ -574,7 +581,9 @@ async def test_subscribe_notify():
characteristic3 = Characteristic( characteristic3 = Characteristic(
'AB5E639C-40C1-4238-B9CB-AF41F8B806E4', 'AB5E639C-40C1-4238-B9CB-AF41F8B806E4',
Characteristic.READ | Characteristic.NOTIFY | Characteristic.INDICATE, Characteristic.Properties.READ
| Characteristic.Properties.NOTIFY
| Characteristic.Properties.INDICATE,
Characteristic.READABLE, Characteristic.READABLE,
bytes([7, 8, 9]), bytes([7, 8, 9]),
) )
@@ -794,32 +803,46 @@ async def test_mtu_exchange():
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
def test_char_property_to_string(): def test_char_property_to_string():
# single # single
assert Characteristic.property_name(0x01) == "BROADCAST" assert str(Characteristic.Properties(0x01)) == "Properties.BROADCAST"
assert Characteristic.property_name(Characteristic.BROADCAST) == "BROADCAST" assert str(Characteristic.Properties.BROADCAST) == "Properties.BROADCAST"
# double # double
assert Characteristic.properties_as_string(0x03) == "BROADCAST,READ" assert str(Characteristic.Properties(0x03)) == "Properties.READ|BROADCAST"
assert ( assert (
Characteristic.properties_as_string( str(Characteristic.Properties.BROADCAST | Characteristic.Properties.READ)
Characteristic.BROADCAST | Characteristic.READ == "Properties.READ|BROADCAST"
)
== "BROADCAST,READ"
) )
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
def test_char_property_string_to_type(): def test_characteristic_property_from_string():
# single # single
assert Characteristic.string_to_properties("BROADCAST") == Characteristic.BROADCAST assert (
Characteristic.Properties.from_string("BROADCAST")
== Characteristic.Properties.BROADCAST
)
# double # double
assert ( assert (
Characteristic.string_to_properties("BROADCAST,READ") Characteristic.Properties.from_string("BROADCAST,READ")
== Characteristic.BROADCAST | Characteristic.READ == Characteristic.Properties.BROADCAST | Characteristic.Properties.READ
) )
assert ( assert (
Characteristic.string_to_properties("READ,BROADCAST") Characteristic.Properties.from_string("READ,BROADCAST")
== Characteristic.BROADCAST | Characteristic.READ == Characteristic.Properties.BROADCAST | Characteristic.Properties.READ
)
# -----------------------------------------------------------------------------
def test_characteristic_property_from_string_assert():
with pytest.raises(TypeError) as e_info:
Characteristic.Properties.from_string("BROADCAST,HELLO")
assert (
str(e_info.value)
== """Characteristic.Properties::from_string() error:
Expected a string containing any of the keys, separated by commas: BROADCAST,READ,WRITE_WITHOUT_RESPONSE,WRITE,NOTIFY,INDICATE,AUTHENTICATED_SIGNED_WRITES,EXTENDED_PROPERTIES
Got: BROADCAST,HELLO"""
) )
@@ -830,7 +853,9 @@ async def test_server_string():
characteristic = Characteristic( characteristic = Characteristic(
'FDB159DB-036C-49E3-B3DB-6325AC750806', 'FDB159DB-036C-49E3-B3DB-6325AC750806',
Characteristic.READ | Characteristic.WRITE | Characteristic.NOTIFY, Characteristic.Properties.READ
| Characteristic.Properties.WRITE
| Characteristic.Properties.NOTIFY,
Characteristic.READABLE | Characteristic.WRITEABLE, Characteristic.READABLE | Characteristic.WRITEABLE,
bytes([123]), bytes([123]),
) )
@@ -841,13 +866,13 @@ async def test_server_string():
assert ( assert (
str(server.gatt_server) str(server.gatt_server)
== """Service(handle=0x0001, end=0x0005, uuid=UUID-16:1800 (Generic Access)) == """Service(handle=0x0001, end=0x0005, uuid=UUID-16:1800 (Generic Access))
CharacteristicDeclaration(handle=0x0002, value_handle=0x0003, uuid=UUID-16:2A00 (Device Name), properties=READ) CharacteristicDeclaration(handle=0x0002, value_handle=0x0003, uuid=UUID-16:2A00 (Device Name), Properties.READ)
Characteristic(handle=0x0003, end=0x0003, uuid=UUID-16:2A00 (Device Name), properties=READ) Characteristic(handle=0x0003, end=0x0003, uuid=UUID-16:2A00 (Device Name), Properties.READ)
CharacteristicDeclaration(handle=0x0004, value_handle=0x0005, uuid=UUID-16:2A01 (Appearance), properties=READ) CharacteristicDeclaration(handle=0x0004, value_handle=0x0005, uuid=UUID-16:2A01 (Appearance), Properties.READ)
Characteristic(handle=0x0005, end=0x0005, uuid=UUID-16:2A01 (Appearance), properties=READ) Characteristic(handle=0x0005, end=0x0005, uuid=UUID-16:2A01 (Appearance), Properties.READ)
Service(handle=0x0006, end=0x0009, uuid=3A657F47-D34F-46B3-B1EC-698E29B6B829) Service(handle=0x0006, end=0x0009, uuid=3A657F47-D34F-46B3-B1EC-698E29B6B829)
CharacteristicDeclaration(handle=0x0007, value_handle=0x0008, uuid=FDB159DB-036C-49E3-B3DB-6325AC750806, properties=READ,WRITE,NOTIFY) CharacteristicDeclaration(handle=0x0007, value_handle=0x0008, uuid=FDB159DB-036C-49E3-B3DB-6325AC750806, Properties.NOTIFY|WRITE|READ)
Characteristic(handle=0x0008, end=0x0009, uuid=FDB159DB-036C-49E3-B3DB-6325AC750806, properties=READ,WRITE,NOTIFY) Characteristic(handle=0x0008, end=0x0009, uuid=FDB159DB-036C-49E3-B3DB-6325AC750806, Properties.NOTIFY|WRITE|READ)
Descriptor(handle=0x0009, type=UUID-16:2902 (Client Characteristic Configuration), value=0000)""" Descriptor(handle=0x0009, type=UUID-16:2902 (Client Characteristic Configuration), value=0000)"""
) )
@@ -861,6 +886,139 @@ async def async_main():
await test_mtu_exchange() await test_mtu_exchange()
# -----------------------------------------------------------------------------
def test_attribute_string_to_permissions():
assert Attribute.string_to_permissions('READABLE') == 1
assert Attribute.string_to_permissions('WRITEABLE') == 2
assert Attribute.string_to_permissions('READABLE,WRITEABLE') == 3
# -----------------------------------------------------------------------------
def test_characteristic_permissions():
characteristic = Characteristic(
'FDB159DB-036C-49E3-B3DB-6325AC750806',
Characteristic.Properties.READ
| Characteristic.Properties.WRITE
| Characteristic.Properties.NOTIFY,
'READABLE,WRITEABLE',
)
assert characteristic.permissions == 3
# -----------------------------------------------------------------------------
def test_characteristic_has_properties():
characteristic = Characteristic(
'FDB159DB-036C-49E3-B3DB-6325AC750806',
Characteristic.Properties.READ
| Characteristic.Properties.WRITE
| Characteristic.Properties.NOTIFY,
'READABLE,WRITEABLE',
)
assert characteristic.has_properties(Characteristic.Properties.READ)
assert characteristic.has_properties(
Characteristic.Properties.READ | Characteristic.Properties.WRITE
)
assert not characteristic.has_properties(
Characteristic.Properties.READ
| Characteristic.Properties.WRITE
| Characteristic.Properties.INDICATE
)
assert not characteristic.has_properties(Characteristic.Properties.INDICATE)
# -----------------------------------------------------------------------------
def test_descriptor_permissions():
descriptor = Descriptor('2902', 'READABLE,WRITEABLE')
assert descriptor.permissions == 3
# -----------------------------------------------------------------------------
def test_get_attribute_group():
device = Device()
# add some services / characteristics to the gatt server
characteristic1 = Characteristic(
'1111',
Characteristic.READ | Characteristic.WRITE | Characteristic.NOTIFY,
Characteristic.READABLE | Characteristic.WRITEABLE,
bytes([123]),
)
characteristic2 = Characteristic(
'2222',
Characteristic.READ | Characteristic.WRITE | Characteristic.NOTIFY,
Characteristic.READABLE | Characteristic.WRITEABLE,
bytes([123]),
)
services = [Service('1212', [characteristic1]), Service('3233', [characteristic2])]
device.gatt_server.add_services(services)
# get the handles from gatt server
characteristic_attributes1 = device.gatt_server.get_characteristic_attributes(
UUID('1212'), UUID('1111')
)
assert characteristic_attributes1 is not None
characteristic_attributes2 = device.gatt_server.get_characteristic_attributes(
UUID('3233'), UUID('2222')
)
assert characteristic_attributes2 is not None
descriptor1 = device.gatt_server.get_descriptor_attribute(
UUID('1212'), UUID('1111'), UUID('2902')
)
assert descriptor1 is not None
descriptor2 = device.gatt_server.get_descriptor_attribute(
UUID('3233'), UUID('2222'), UUID('2902')
)
assert descriptor2 is not None
# confirm the handles map back to the service
assert (
UUID('1212')
== device.gatt_server.get_attribute_group(
characteristic_attributes1[0].handle, Service
).uuid
)
assert (
UUID('1212')
== device.gatt_server.get_attribute_group(
characteristic_attributes1[1].handle, Service
).uuid
)
assert (
UUID('1212')
== device.gatt_server.get_attribute_group(descriptor1.handle, Service).uuid
)
assert (
UUID('3233')
== device.gatt_server.get_attribute_group(
characteristic_attributes2[0].handle, Service
).uuid
)
assert (
UUID('3233')
== device.gatt_server.get_attribute_group(
characteristic_attributes2[1].handle, Service
).uuid
)
assert (
UUID('3233')
== device.gatt_server.get_attribute_group(descriptor2.handle, Service).uuid
)
# confirm the handles map back to the characteristic
assert (
UUID('1111')
== device.gatt_server.get_attribute_group(
descriptor1.handle, Characteristic
).uuid
)
assert (
UUID('2222')
== device.gatt_server.get_attribute_group(
descriptor2.handle, Characteristic
).uuid
)
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
if __name__ == '__main__': if __name__ == '__main__':
logging.basicConfig(level=os.environ.get('BUMBLE_LOGLEVEL', 'INFO').upper()) logging.basicConfig(level=os.environ.get('BUMBLE_LOGLEVEL', 'INFO').upper())
+19
View File
@@ -52,6 +52,7 @@ from bumble.hci import (
HCI_LE_Set_Scan_Parameters_Command, HCI_LE_Set_Scan_Parameters_Command,
HCI_Number_Of_Completed_Packets_Event, HCI_Number_Of_Completed_Packets_Event,
HCI_Packet, HCI_Packet,
HCI_PIN_Code_Request_Reply_Command,
HCI_Read_Local_Supported_Commands_Command, HCI_Read_Local_Supported_Commands_Command,
HCI_Read_Local_Supported_Features_Command, HCI_Read_Local_Supported_Features_Command,
HCI_Read_Local_Version_Information_Command, HCI_Read_Local_Version_Information_Command,
@@ -213,6 +214,23 @@ def test_HCI_Command():
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
def test_HCI_PIN_Code_Request_Reply_Command():
pin_code = b'1234'
pin_code_length = len(pin_code)
# here to make the test pass, we need to
# pad pin_code, as HCI_Object.format_fields
# does not do it for us
padded_pin_code = pin_code + bytes(16 - pin_code_length)
command = HCI_PIN_Code_Request_Reply_Command(
bd_addr=Address(
'00:11:22:33:44:55', address_type=Address.PUBLIC_DEVICE_ADDRESS
),
pin_code_length=pin_code_length,
pin_code=padded_pin_code,
)
basic_check(command)
def test_HCI_Reset_Command(): def test_HCI_Reset_Command():
command = HCI_Reset_Command() command = HCI_Reset_Command()
basic_check(command) basic_check(command)
@@ -440,6 +458,7 @@ def run_test_events():
def run_test_commands(): def run_test_commands():
test_HCI_Command() test_HCI_Command()
test_HCI_Reset_Command() test_HCI_Reset_Command()
test_HCI_PIN_Code_Request_Reply_Command()
test_HCI_Read_Local_Version_Information_Command() test_HCI_Read_Local_Version_Information_Command()
test_HCI_Read_Local_Supported_Commands_Command() test_HCI_Read_Local_Supported_Commands_Command()
test_HCI_Read_Local_Supported_Features_Command() test_HCI_Read_Local_Supported_Features_Command()
+96 -26
View File
@@ -22,17 +22,16 @@ import os
import pytest import pytest
from bumble.controller import Controller from bumble.controller import Controller
from bumble.core import BT_BR_EDR_TRANSPORT, BT_PERIPHERAL_ROLE, BT_CENTRAL_ROLE
from bumble.link import LocalLink from bumble.link import LocalLink
from bumble.device import Device, Peer from bumble.device import Device, Peer
from bumble.host import Host from bumble.host import Host
from bumble.gatt import Service, Characteristic from bumble.gatt import Service, Characteristic
from bumble.transport import AsyncPipeSink from bumble.transport import AsyncPipeSink
from bumble.pairing import PairingConfig, PairingDelegate
from bumble.smp import ( from bumble.smp import (
PairingConfig,
PairingDelegate,
SMP_PAIRING_NOT_SUPPORTED_ERROR, SMP_PAIRING_NOT_SUPPORTED_ERROR,
SMP_CONFIRM_VALUE_FAILED_ERROR, SMP_CONFIRM_VALUE_FAILED_ERROR,
SMP_ID_KEY_DISTRIBUTION_FLAG,
) )
from bumble.core import ProtocolError from bumble.core import ProtocolError
@@ -48,18 +47,19 @@ class TwoDevices:
def __init__(self): def __init__(self):
self.connections = [None, None] self.connections = [None, None]
addresses = ['F0:F1:F2:F3:F4:F5', 'F5:F4:F3:F2:F1:F0']
self.link = LocalLink() self.link = LocalLink()
self.controllers = [ self.controllers = [
Controller('C1', link=self.link), Controller('C1', link=self.link, public_address=addresses[0]),
Controller('C2', link=self.link), Controller('C2', link=self.link, public_address=addresses[1]),
] ]
self.devices = [ self.devices = [
Device( Device(
address='F0:F1:F2:F3:F4:F5', address=addresses[0],
host=Host(self.controllers[0], AsyncPipeSink(self.controllers[0])), host=Host(self.controllers[0], AsyncPipeSink(self.controllers[0])),
), ),
Device( Device(
address='F5:F4:F3:F2:F1:F0', address=addresses[1],
host=Host(self.controllers[1], AsyncPipeSink(self.controllers[1])), host=Host(self.controllers[1], AsyncPipeSink(self.controllers[1])),
), ),
] ]
@@ -99,6 +99,60 @@ async def test_self_connection():
assert two_devices.connections[1] is not None assert two_devices.connections[1] is not None
# -----------------------------------------------------------------------------
@pytest.mark.asyncio
@pytest.mark.parametrize(
'responder_role,',
(BT_CENTRAL_ROLE, BT_PERIPHERAL_ROLE),
)
async def test_self_classic_connection(responder_role):
# Create two devices, each with a controller, attached to the same link
two_devices = TwoDevices()
# Attach listeners
two_devices.devices[0].on(
'connection', lambda connection: two_devices.on_connection(0, connection)
)
two_devices.devices[1].on(
'connection', lambda connection: two_devices.on_connection(1, connection)
)
# Enable Classic connections
two_devices.devices[0].classic_enabled = True
two_devices.devices[1].classic_enabled = True
# Start
await two_devices.devices[0].power_on()
await two_devices.devices[1].power_on()
# Connect the two devices
await asyncio.gather(
two_devices.devices[0].connect(
two_devices.devices[1].public_address, transport=BT_BR_EDR_TRANSPORT
),
two_devices.devices[1].accept(
two_devices.devices[0].public_address, responder_role
),
)
# Check the post conditions
assert two_devices.connections[0] is not None
assert two_devices.connections[1] is not None
# Check the role
assert two_devices.connections[0].role != responder_role
assert two_devices.connections[1].role == responder_role
# Role switch
await two_devices.connections[0].switch_role(responder_role)
# Check the role
assert two_devices.connections[0].role == responder_role
assert two_devices.connections[1].role != responder_role
await two_devices.connections[0].disconnect()
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_self_gatt(): async def test_self_gatt():
@@ -108,25 +162,28 @@ async def test_self_gatt():
# Add some GATT characteristics to device 1 # Add some GATT characteristics to device 1
c1 = Characteristic( c1 = Characteristic(
'3A143AD7-D4A7-436B-97D6-5B62C315E833', '3A143AD7-D4A7-436B-97D6-5B62C315E833',
Characteristic.READ, Characteristic.Properties.READ,
Characteristic.READABLE, Characteristic.READABLE,
bytes([1, 2, 3]), bytes([1, 2, 3]),
) )
c2 = Characteristic( c2 = Characteristic(
'9557CCE2-DB37-46EB-94C4-50AE5B9CB0F8', '9557CCE2-DB37-46EB-94C4-50AE5B9CB0F8',
Characteristic.READ | Characteristic.WRITE, Characteristic.Properties.READ | Characteristic.Properties.WRITE,
Characteristic.READABLE | Characteristic.WRITEABLE, Characteristic.READABLE | Characteristic.WRITEABLE,
bytes([4, 5, 6]), bytes([4, 5, 6]),
) )
c3 = Characteristic( c3 = Characteristic(
'84FC1A2E-C52D-4A2D-B8C3-8855BAB86638', '84FC1A2E-C52D-4A2D-B8C3-8855BAB86638',
Characteristic.READ | Characteristic.WRITE_WITHOUT_RESPONSE, Characteristic.Properties.READ
| Characteristic.Properties.WRITE_WITHOUT_RESPONSE,
Characteristic.READABLE | Characteristic.WRITEABLE, Characteristic.READABLE | Characteristic.WRITEABLE,
bytes([7, 8, 9]), bytes([7, 8, 9]),
) )
c4 = Characteristic( c4 = Characteristic(
'84FC1A2E-C52D-4A2D-B8C3-8855BAB86638', '84FC1A2E-C52D-4A2D-B8C3-8855BAB86638',
Characteristic.READ | Characteristic.NOTIFY | Characteristic.INDICATE, Characteristic.Properties.READ
| Characteristic.Properties.NOTIFY
| Characteristic.Properties.INDICATE,
Characteristic.READABLE, Characteristic.READABLE,
bytes([1, 1, 1]), bytes([1, 1, 1]),
) )
@@ -179,7 +236,7 @@ async def test_self_gatt_long_read():
characteristics = [ characteristics = [
Characteristic( Characteristic(
f'3A143AD7-D4A7-436B-97D6-5B62C315{i:04X}', f'3A143AD7-D4A7-436B-97D6-5B62C315{i:04X}',
Characteristic.READ, Characteristic.Properties.READ,
Characteristic.READABLE, Characteristic.READABLE,
bytes([x & 255 for x in range(i)]), bytes([x & 255 for x in range(i)]),
) )
@@ -204,7 +261,7 @@ async def test_self_gatt_long_read():
found_service = result[0] found_service = result[0]
found_characteristics = await found_service.discover_characteristics() found_characteristics = await found_service.discover_characteristics()
assert len(found_characteristics) == 513 assert len(found_characteristics) == 513
for (i, characteristic) in enumerate(found_characteristics): for i, characteristic in enumerate(found_characteristics):
value = await characteristic.read_value() value = await characteristic.read_value()
assert value == characteristics[i].value assert value == characteristics[i].value
@@ -259,11 +316,11 @@ async def _test_self_smp_with_configs(pairing_config1, pairing_config2):
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
IO_CAP = [ IO_CAP = [
PairingDelegate.NO_OUTPUT_NO_INPUT, PairingDelegate.IoCapability.NO_OUTPUT_NO_INPUT,
PairingDelegate.KEYBOARD_INPUT_ONLY, PairingDelegate.IoCapability.KEYBOARD_INPUT_ONLY,
PairingDelegate.DISPLAY_OUTPUT_ONLY, PairingDelegate.IoCapability.DISPLAY_OUTPUT_ONLY,
PairingDelegate.DISPLAY_OUTPUT_AND_YES_NO_INPUT, PairingDelegate.IoCapability.DISPLAY_OUTPUT_AND_YES_NO_INPUT,
PairingDelegate.DISPLAY_OUTPUT_AND_KEYBOARD_INPUT, PairingDelegate.IoCapability.DISPLAY_OUTPUT_AND_KEYBOARD_INPUT,
] ]
SC = [False, True] SC = [False, True]
MITM = [False, True] MITM = [False, True]
@@ -273,9 +330,18 @@ KEY_DIST = range(16)
@pytest.mark.asyncio @pytest.mark.asyncio
@pytest.mark.parametrize( @pytest.mark.parametrize(
'io_cap, sc, mitm, key_dist', itertools.product(IO_CAP, SC, MITM, KEY_DIST) 'io_caps, sc, mitm, key_dist',
itertools.chain(
itertools.product([IO_CAP], SC, MITM, [15]),
itertools.product(
[[PairingDelegate.IoCapability.DISPLAY_OUTPUT_AND_KEYBOARD_INPUT]],
SC,
MITM,
KEY_DIST,
),
),
) )
async def test_self_smp(io_cap, sc, mitm, key_dist): async def test_self_smp(io_caps, sc, mitm, key_dist):
class Delegate(PairingDelegate): class Delegate(PairingDelegate):
def __init__( def __init__(
self, self,
@@ -296,6 +362,7 @@ async def test_self_smp(io_cap, sc, mitm, key_dist):
self.peer_delegate = None self.peer_delegate = None
self.number = asyncio.get_running_loop().create_future() self.number = asyncio.get_running_loop().create_future()
# pylint: disable-next=unused-argument
async def compare_numbers(self, number, digits): async def compare_numbers(self, number, digits):
if self.peer_delegate is None: if self.peer_delegate is None:
logger.warning(f'[{self.name}] no peer delegate') logger.warning(f'[{self.name}] no peer delegate')
@@ -313,7 +380,7 @@ async def test_self_smp(io_cap, sc, mitm, key_dist):
else: else:
if ( if (
self.peer_delegate.io_capability self.peer_delegate.io_capability
== PairingDelegate.KEYBOARD_INPUT_ONLY == PairingDelegate.IoCapability.KEYBOARD_INPUT_ONLY
): ):
peer_number = 6789 peer_number = 6789
else: else:
@@ -331,8 +398,9 @@ async def test_self_smp(io_cap, sc, mitm, key_dist):
pairing_config_sets = [('Initiator', [None]), ('Responder', [None])] pairing_config_sets = [('Initiator', [None]), ('Responder', [None])]
for pairing_config_set in pairing_config_sets: for pairing_config_set in pairing_config_sets:
delegate = Delegate(pairing_config_set[0], io_cap, key_dist, key_dist) for io_cap in io_caps:
pairing_config_set[1].append(PairingConfig(sc, mitm, True, delegate)) delegate = Delegate(pairing_config_set[0], io_cap, key_dist, key_dist)
pairing_config_set[1].append(PairingConfig(sc, mitm, True, delegate))
for pairing_config1 in pairing_config_sets[0][1]: for pairing_config1 in pairing_config_sets[0][1]:
for pairing_config2 in pairing_config_sets[1][1]: for pairing_config2 in pairing_config_sets[1][1]:
@@ -355,7 +423,7 @@ async def test_self_smp(io_cap, sc, mitm, key_dist):
async def test_self_smp_reject(): async def test_self_smp_reject():
class RejectingDelegate(PairingDelegate): class RejectingDelegate(PairingDelegate):
def __init__(self): def __init__(self):
super().__init__(PairingDelegate.NO_OUTPUT_NO_INPUT) super().__init__(PairingDelegate.IoCapability.NO_OUTPUT_NO_INPUT)
async def accept(self): async def accept(self):
return False return False
@@ -376,12 +444,14 @@ async def test_self_smp_reject():
async def test_self_smp_wrong_pin(): async def test_self_smp_wrong_pin():
class WrongPinDelegate(PairingDelegate): class WrongPinDelegate(PairingDelegate):
def __init__(self): def __init__(self):
super().__init__(PairingDelegate.DISPLAY_OUTPUT_AND_KEYBOARD_INPUT) super().__init__(
PairingDelegate.IoCapability.DISPLAY_OUTPUT_AND_KEYBOARD_INPUT
)
async def compare_numbers(self, number, digits): async def compare_numbers(self, number, digits):
return False return False
wrong_pin_pairing_config = PairingConfig(delegate=WrongPinDelegate()) wrong_pin_pairing_config = PairingConfig(mitm=True, delegate=WrongPinDelegate())
paired = False paired = False
try: try:
await _test_self_smp_with_configs( await _test_self_smp_with_configs(
+3 -2
View File
@@ -72,5 +72,6 @@ def test_parser_extensions():
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
test_parser() if __name__ == '__main__':
test_parser_extensions() test_parser()
test_parser_extensions()
+1 -1
View File
@@ -16,7 +16,7 @@
# Imports # Imports
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
from bumble.device import Device from bumble.device import Device
from bumble.transport import PacketParser from bumble.transport.common import PacketParser
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------