Compare commits

..

31 Commits

Author SHA1 Message Date
Gilles Boccon-Gibod 864889ccab rename .run to .spawn 2023-03-22 17:26:32 -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 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 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
60 changed files with 2097 additions and 253 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
} }
+1207
View File
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -27,7 +27,6 @@ import re
from collections import OrderedDict from collections import OrderedDict
import click import click
import colors
from prompt_toolkit import Application from prompt_toolkit import Application
from prompt_toolkit.history import FileHistory from prompt_toolkit.history import FileHistory
@@ -53,6 +52,7 @@ 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
+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
+1 -1
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
+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
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
+78 -43
View File
@@ -19,9 +19,9 @@ 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.smp import PairingDelegate, PairingConfig
@@ -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()
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
@@ -262,6 +273,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')
@@ -332,7 +345,19 @@ async def pair(
# Advertise so that peers can find us and connect # Advertise so that peers can find us and connect
await device.start_advertising(auto_restart=True) await device.start_advertising(auto_restart=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)
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
@@ -366,7 +391,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)
@@ -384,7 +413,13 @@ 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,
+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
+45 -30
View File
@@ -18,7 +18,6 @@
import struct import struct
import logging import logging
from collections import namedtuple from collections import namedtuple
import construct
from .company_ids import COMPANY_IDENTIFIERS from .company_ids import COMPANY_IDENTIFIERS
from .sdp import ( from .sdp import (
@@ -258,17 +257,6 @@ class SbcMediaCodecInformation(
A2DP spec - 4.3.2 Codec Specific Information Elements A2DP spec - 4.3.2 Codec Specific Information Elements
''' '''
BIT_FIELDS = construct.Bitwise(
construct.Sequence(
construct.BitsInteger(4),
construct.BitsInteger(4),
construct.BitsInteger(4),
construct.BitsInteger(2),
construct.BitsInteger(2),
construct.BitsInteger(8),
construct.BitsInteger(8),
)
)
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,
@@ -284,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(
*SbcMediaCodecInformation.BIT_FIELDS.parse(data) sampling_frequency,
channel_mode,
block_length,
subbands,
allocation_method,
minimum_bitpool_value,
maximum_bitpool_value,
) )
@classmethod @classmethod
@@ -335,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 self.BIT_FIELDS.build(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']
@@ -367,16 +377,6 @@ class AacMediaCodecInformation(
A2DP spec - 4.5.2 Codec Specific Information Elements A2DP spec - 4.5.2 Codec Specific Information Elements
''' '''
BIT_FIELDS = construct.Bitwise(
construct.Sequence(
construct.BitsInteger(8),
construct.BitsInteger(12),
construct.BitsInteger(2),
construct.BitsInteger(2),
construct.BitsInteger(1),
construct.BitsInteger(23),
)
)
OBJECT_TYPE_BITS = { OBJECT_TYPE_BITS = {
MPEG_2_AAC_LC_OBJECT_TYPE: 1 << 7, MPEG_2_AAC_LC_OBJECT_TYPE: 1 << 7,
MPEG_4_AAC_LC_OBJECT_TYPE: 1 << 6, MPEG_4_AAC_LC_OBJECT_TYPE: 1 << 6,
@@ -400,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(
*AacMediaCodecInformation.BIT_FIELDS.parse(data) object_type, sampling_frequency, channels, rfa, vbr, bitrate
) )
@classmethod @classmethod
@@ -430,8 +436,17 @@ class AacMediaCodecInformation(
bitrate=bitrate, bitrate=bitrate,
) )
def __bytes__(self): def __bytes__(self) -> bytes:
return self.BIT_FIELDS.build(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 = [
+42 -4
View File
@@ -24,13 +24,15 @@
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
from __future__ import annotations from __future__ import annotations
import struct import struct
from colors import color
from pyee import EventEmitter from pyee import EventEmitter
from typing import Dict, Type from typing import Dict, Type, TYPE_CHECKING
from bumble.core import UUID, name_or_number from bumble.core import UUID, name_or_number
from bumble.hci import HCI_Object, key_with_value from bumble.hci import HCI_Object, key_with_value
from bumble.colors import color
if TYPE_CHECKING:
from bumble.device import Connection
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
# Constants # Constants
@@ -749,7 +751,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
@@ -762,7 +782,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):
+1 -1
View File
@@ -20,7 +20,6 @@ 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 typing import Dict, Type
@@ -40,6 +39,7 @@ from .a2dp import (
VendorSpecificMediaCodecInformation, VendorSpecificMediaCodecInformation,
) )
from . import sdp from . import sdp
from .colors import color
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
# Logging # Logging
+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')
+1 -1
View File
@@ -20,7 +20,7 @@ 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
from bumble.hci import ( from bumble.hci import (
+16 -6
View File
@@ -144,9 +144,12 @@ 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: List[UUID] = [] # 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):
@@ -209,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):
''' '''
+32 -23
View File
@@ -25,8 +25,7 @@ from contextlib import asynccontextmanager, AsyncExitStack
from dataclasses import dataclass from dataclasses import dataclass
from typing import Any, ClassVar, Dict, List, Optional, Tuple, Union from typing import Any, ClassVar, Dict, List, Optional, Tuple, Union
from colors import color from .colors import color
from .att import ATT_CID, ATT_DEFAULT_MTU, ATT_PDU from .att import ATT_CID, ATT_DEFAULT_MTU, ATT_PDU
from .gatt import Characteristic, Descriptor, Service from .gatt import Characteristic, Descriptor, Service
from .hci import ( from .hci import (
@@ -51,6 +50,7 @@ from .hci import (
HCI_LE_EXTENDED_CREATE_CONNECTION_COMMAND, HCI_LE_EXTENDED_CREATE_CONNECTION_COMMAND,
HCI_LE_RAND_COMMAND, HCI_LE_RAND_COMMAND,
HCI_LE_READ_PHY_COMMAND, HCI_LE_READ_PHY_COMMAND,
HCI_LE_SET_PHY_COMMAND,
HCI_MITM_NOT_REQUIRED_GENERAL_BONDING_AUTHENTICATION_REQUIREMENTS, HCI_MITM_NOT_REQUIRED_GENERAL_BONDING_AUTHENTICATION_REQUIREMENTS,
HCI_MITM_NOT_REQUIRED_NO_BONDING_AUTHENTICATION_REQUIREMENTS, HCI_MITM_NOT_REQUIRED_NO_BONDING_AUTHENTICATION_REQUIREMENTS,
HCI_MITM_REQUIRED_GENERAL_BONDING_AUTHENTICATION_REQUIREMENTS, HCI_MITM_REQUIRED_GENERAL_BONDING_AUTHENTICATION_REQUIREMENTS,
@@ -535,6 +535,9 @@ class Connection(CompositeEventEmitter):
def on_connection_parameters_update_failure(self, error): def on_connection_parameters_update_failure(self, error):
pass pass
def on_connection_data_length_change(self):
pass
def on_connection_phy_update(self): def on_connection_phy_update(self):
pass pass
@@ -1240,6 +1243,11 @@ class Device(CompositeEventEmitter):
# Done # Done
self.powered_on = True self.powered_on = True
async def power_off(self) -> None:
if self.powered_on:
await self.host.flush()
self.powered_on = False
def supports_le_feature(self, feature): def supports_le_feature(self, feature):
return self.host.supports_le_feature(feature) return self.host.supports_le_feature(feature)
@@ -1664,7 +1672,7 @@ class Device(CompositeEventEmitter):
) )
) )
if not phys: if not phys:
raise ValueError('least one supported PHY needed') raise ValueError('at least one supported PHY needed')
phy_count = len(phys) phy_count = len(phys)
initiating_phys = phy_list_to_bits(phys) initiating_phys = phy_list_to_bits(phys)
@@ -1805,7 +1813,7 @@ class Device(CompositeEventEmitter):
try: try:
return await self.abort_on('flush', pending_connection) return await self.abort_on('flush', pending_connection)
except ConnectionError as error: except core.ConnectionError as error:
raise core.TimeoutError() from error raise core.TimeoutError() from error
finally: finally:
self.remove_listener('connection', on_connection) self.remove_listener('connection', on_connection)
@@ -1827,7 +1835,7 @@ class Device(CompositeEventEmitter):
set. set.
Notes: Notes:
* A `connect` to the same peer will also complete this call. * A `connect` to the same peer will not complete this call.
* The `timeout` parameter is only handled while waiting for the connection * The `timeout` parameter is only handled while waiting for the connection
request, once received and accepted, the controller shall issue a connection request, once received and accepted, the controller shall issue a connection
complete event. complete event.
@@ -2009,7 +2017,7 @@ class Device(CompositeEventEmitter):
NOTE: the name of the parameters may look odd, but it just follows the names NOTE: the name of the parameters may look odd, but it just follows the names
used in the Bluetooth spec. used in the Bluetooth spec.
''' '''
await self.send_command( result = await self.send_command(
HCI_LE_Connection_Update_Command( HCI_LE_Connection_Update_Command(
connection_handle=connection.handle, connection_handle=connection.handle,
connection_interval_min=connection_interval_min, connection_interval_min=connection_interval_min,
@@ -2018,9 +2026,10 @@ class Device(CompositeEventEmitter):
supervision_timeout=supervision_timeout, supervision_timeout=supervision_timeout,
min_ce_length=min_ce_length, min_ce_length=min_ce_length,
max_ce_length=max_ce_length, max_ce_length=max_ce_length,
), )
check_result=True,
) )
if result.status != HCI_Command_Status_Event.PENDING:
raise HCI_StatusError(result)
async def get_connection_rssi(self, connection): async def get_connection_rssi(self, connection):
result = await self.send_command( result = await self.send_command(
@@ -2038,21 +2047,31 @@ class Device(CompositeEventEmitter):
async def set_connection_phy( async def set_connection_phy(
self, connection, tx_phys=None, rx_phys=None, phy_options=None self, connection, tx_phys=None, rx_phys=None, phy_options=None
): ):
if not self.host.supports_command(HCI_LE_SET_PHY_COMMAND):
logger.warning('ignoring request, command not supported')
return
all_phys_bits = (1 if tx_phys is None else 0) | ( all_phys_bits = (1 if tx_phys is None else 0) | (
(1 if rx_phys is None else 0) << 1 (1 if rx_phys is None else 0) << 1
) )
return await self.send_command( result = await self.send_command(
HCI_LE_Set_PHY_Command( HCI_LE_Set_PHY_Command(
connection_handle=connection.handle, connection_handle=connection.handle,
all_phys=all_phys_bits, all_phys=all_phys_bits,
tx_phys=phy_list_to_bits(tx_phys), tx_phys=phy_list_to_bits(tx_phys),
rx_phys=phy_list_to_bits(rx_phys), rx_phys=phy_list_to_bits(rx_phys),
phy_options=0 if phy_options is None else int(phy_options), phy_options=0 if phy_options is None else int(phy_options),
), )
check_result=True,
) )
if result.status != HCI_COMMAND_STATUS_PENDING:
logger.warning(
'HCI_LE_Set_PHY_Command failed: '
f'{HCI_Constant.error_name(result.status)}'
)
raise HCI_StatusError(result)
async def set_default_phy(self, tx_phys=None, rx_phys=None): async def set_default_phy(self, tx_phys=None, rx_phys=None):
all_phys_bits = (1 if tx_phys is None else 0) | ( all_phys_bits = (1 if tx_phys is None else 0) | (
(1 if rx_phys is None else 0) << 1 (1 if rx_phys is None else 0) << 1
@@ -2409,16 +2428,6 @@ class Device(CompositeEventEmitter):
) )
self.connections[connection_handle] = connection self.connections[connection_handle] = connection
# We may have an accept ongoing waiting for a connection request for
# `peer_address`.
# Typically happen when using `connect` to the same `peer_address` we are
# waiting for with an `accept`.
# In this case, set the completed `connection` to the `accept` future
# result.
if peer_address in self.classic_pending_accepts:
future, *_ = self.classic_pending_accepts.pop(peer_address)
future.set_result(connection)
# Emit an event to notify listeners of the new connection # Emit an event to notify listeners of the new connection
self.emit('connection', connection) self.emit('connection', connection)
else: else:
@@ -2501,7 +2510,7 @@ class Device(CompositeEventEmitter):
self.advertising = False self.advertising = False
# Notify listeners # Notify listeners
error = ConnectionError( error = core.ConnectionError(
error_code, error_code,
transport, transport,
peer_address, peer_address,
@@ -2574,7 +2583,7 @@ class Device(CompositeEventEmitter):
@with_connection_from_handle @with_connection_from_handle
def on_disconnection_failure(self, connection, error_code): def on_disconnection_failure(self, connection, error_code):
logger.debug(f'*** Disconnection failed: {error_code}') logger.debug(f'*** Disconnection failed: {error_code}')
error = ConnectionError( error = core.ConnectionError(
error_code, error_code,
connection.transport, connection.transport,
connection.peer_address, connection.peer_address,
+1 -1
View File
@@ -29,8 +29,8 @@ import functools
import logging import logging
import struct import struct
from typing import Optional, Sequence from typing import Optional, Sequence
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
+1 -1
View File
@@ -27,9 +27,9 @@ import asyncio
import logging import logging
import struct import struct
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,
+61 -35
View File
@@ -29,8 +29,8 @@ from collections import defaultdict
import struct import struct
from typing import List, Tuple, Optional from typing import List, Tuple, Optional
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,
@@ -543,8 +542,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:
@@ -638,6 +635,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
@@ -647,10 +651,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
@@ -676,11 +691,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)
@@ -690,10 +701,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,
@@ -708,29 +726,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,
@@ -746,7 +771,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,
@@ -766,8 +790,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
+14 -2
View File
@@ -20,9 +20,9 @@ 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 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,
@@ -1421,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),
)
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
@@ -1846,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]
@@ -1864,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
@@ -1875,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):
+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
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
+9 -2
View File
@@ -20,9 +20,9 @@ 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 .hci import ( from .hci import (
HCI_ACL_DATA_PACKET, HCI_ACL_DATA_PACKET,
@@ -134,6 +134,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:
@@ -274,6 +275,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(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):
@@ -420,6 +424,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(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)
+3 -3
View File
@@ -25,8 +25,8 @@ import logging
import os import os
import json import json
from typing import Optional from typing import Optional
from colors import color
from .colors import color
from .hci import Address from .hci import Address
@@ -130,7 +130,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}')
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
@@ -217,7 +217,7 @@ class JsonKeyStore(KeyStore):
params = device_config.keystore.split(':', 1)[1:] params = device_config.keystore.split(':', 1)[1:]
namespace = str(device_config.address) namespace = str(device_config.address)
if params: if params:
filename = params[1] filename = params[0]
else: else:
filename = None filename = None
+14 -1
View File
@@ -21,10 +21,10 @@ 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 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,
@@ -796,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(
[ [
@@ -1105,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')
@@ -1492,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]
+3 -3
View File
@@ -19,9 +19,7 @@ import logging
import asyncio import asyncio
from functools import partial from functools import partial
from colors import color from bumble.colors import color
import websockets
from bumble.hci import ( from bumble.hci import (
Address, Address,
HCI_SUCCESS, HCI_SUCCESS,
@@ -220,6 +218,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
+21 -11
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
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
@@ -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}')
+2 -3
View File
@@ -18,11 +18,10 @@
from __future__ import annotations from __future__ import annotations
import logging import logging
import struct import struct
from colors import color
import colors
from typing import Dict, List, Type from typing import Dict, List, Type
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
@@ -506,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})'
) )
+48 -30
View File
@@ -29,8 +29,8 @@ import secrets
from typing import Dict, Optional, Type from typing import Dict, Optional, Type
from pyee import EventEmitter from pyee import EventEmitter
from colors import color
from .colors import color
from .hci import Address, HCI_LE_Enable_Encryption_Command, HCI_Object, key_with_value from .hci import 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,
@@ -498,7 +498,7 @@ class PairingDelegate:
DISPLAY_OUTPUT_ONLY = SMP_DISPLAY_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_YES_NO_INPUT = SMP_DISPLAY_YES_NO_IO_CAPABILITY
DISPLAY_OUTPUT_AND_KEYBOARD_INPUT = SMP_KEYBOARD_DISPLAY_IO_CAPABILITY DISPLAY_OUTPUT_AND_KEYBOARD_INPUT = SMP_KEYBOARD_DISPLAY_IO_CAPABILITY
DEFAULT_KEY_DISTRIBUTION = ( DEFAULT_KEY_DISTRIBUTION: int = (
SMP_ENC_KEY_DISTRIBUTION_FLAG | SMP_ID_KEY_DISTRIBUTION_FLAG SMP_ENC_KEY_DISTRIBUTION_FLAG | SMP_ID_KEY_DISTRIBUTION_FLAG
) )
@@ -518,13 +518,15 @@ class PairingDelegate:
async def confirm(self) -> bool: async def confirm(self) -> bool:
return True return True
async def compare_numbers(self, _number: int, _digits: int = 6) -> bool: # pylint: disable-next=unused-argument
async def compare_numbers(self, number: int, digits: int) -> bool:
return True return True
async def get_number(self) -> int: async def get_number(self) -> int:
return 0 return 0
async def display_number(self, _number: int, _digits: int = 6) -> None: # pylint: disable-next=unused-argument
async def display_number(self, number: int, digits: int) -> None:
pass pass
async def key_distribution_response( async def key_distribution_response(
@@ -661,7 +663,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
@@ -839,6 +842,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:
@@ -859,6 +863,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()
@@ -910,17 +916,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,
@@ -933,7 +951,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))
@@ -1364,8 +1382,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:
@@ -1426,18 +1444,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):
@@ -1565,17 +1587,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
+93
View File
@@ -0,0 +1,93 @@
# 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 enum import IntEnum
import struct
import datetime
from typing import BinaryIO
from bumble.hci import HCI_Packet, HCI_COMMAND_PACKET, HCI_EVENT_PACKET
# -----------------------------------------------------------------------------
# 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: HCI_Packet, 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: HCI_Packet, direction: Snooper.Direction) -> None:
flags = int(direction)
if hci_packet.hci_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
packet_data = bytes(hci_packet)
self.output.write(
struct.pack(
'>IIIIQ',
len(packet_data), # Original Length
len(packet_data), # Included Length
flags, # Packet Flags
0, # Cumulative Drops
timestamp, # Timestamp
)
+ packet_data
)
+2 -1
View File
@@ -18,7 +18,6 @@
import logging import logging
from .common import Transport, AsyncPipeSink from .common import Transport, AsyncPipeSink
from ..link import RemoteLink
from ..controller import Controller from ..controller import Controller
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
@@ -110,6 +109,8 @@ async def open_transport(name: str) -> Transport:
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
async def open_transport_or_link(name): async def open_transport_or_link(name):
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)
+1 -1
View File
@@ -18,9 +18,9 @@
import struct import struct
import asyncio import asyncio
import logging import logging
from colors import color
from .. import hci from .. import hci
from ..colors import color
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
+1 -1
View File
@@ -22,10 +22,10 @@ import time
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
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
+1 -1
View File
@@ -23,10 +23,10 @@ import ctypes
import platform import platform
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
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
+20 -2
View File
@@ -20,11 +20,11 @@ import logging
import traceback import traceback
import collections import collections
import sys import sys
from typing import Awaitable, TypeVar 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
@@ -78,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`
@@ -155,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):
""" """
@@ -185,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
+1 -1
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
+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
+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
@@ -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
+1 -1
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
+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
@@ -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
+1 -3
View File
@@ -30,11 +30,8 @@ package_dir =
bumble.apps = apps bumble.apps = apps
include-package-data = True include-package-data = True
install_requires = install_requires =
aioconsole >= 0.4.1
ansicolors >= 1.1
appdirs >= 1.4 appdirs >= 1.4
click >= 7.1.2; platform_system!='Emscripten' click >= 7.1.2; platform_system!='Emscripten'
construct >= 2.10
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'
@@ -60,6 +57,7 @@ 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] [options.package_data]
* = py.typed, *.pyi * = py.typed, *.pyi
+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
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
+12 -5
View File
@@ -32,7 +32,6 @@ from bumble.smp import (
PairingDelegate, 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
@@ -273,9 +272,15 @@ 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.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 +301,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')
@@ -331,8 +337,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]: