Compare commits

...

28 Commits

Author SHA1 Message Date
Gilles Boccon-Gibod
3d14df909c Merge pull request #5 from google/gbg/disconnection-event-routing
fix the routing of disconnection events
2022-06-15 10:50:14 -07:00
Gilles Boccon-Gibod
153788afe3 fix the routing of disconnection events 2022-06-14 14:38:40 -07:00
Gilles Boccon-Gibod
99ca31c063 Merge pull request #4 from google/gbg/classic-pairing-io
classic pairing io
2022-06-14 11:32:37 -07:00
Gilles Boccon-Gibod
9629e677f2 improve readability as per PR suggestion 2022-06-14 10:57:54 -07:00
Gilles Boccon-Gibod
250c1e3395 address PR comments 2022-06-13 16:44:57 -07:00
Gilles Boccon-Gibod
70dca1d7c9 cosmetic fix 2022-06-11 15:50:53 -07:00
Gilles Boccon-Gibod
a5015c1305 add pytest async options 2022-06-11 12:28:00 -07:00
Gilles Boccon-Gibod
6e22df4838 add doc structure 2022-06-11 12:19:54 -07:00
Gilles Boccon-Gibod
b4e2f21d2a add classic pairing io delegation 2022-06-11 01:33:51 -07:00
Gilles Boccon-Gibod
1af61e8af3 Update getting_started.md 2022-06-04 23:03:31 -07:00
Gilles Boccon-Gibod
e11119c565 Update README.md 2022-06-04 22:57:14 -07:00
Gilles Boccon-Gibod
b1a31564ef Merge pull request #3 from google/gbg/usb-serial-number
gbg/usb serial number
2022-06-04 20:47:18 -07:00
Gilles Boccon-Gibod
01492d510c close device inside for loop 2022-06-03 19:53:26 -07:00
Gilles Boccon-Gibod
302c495178 fix mkdocstrings python dependency 2022-06-02 15:56:01 -07:00
Gilles Boccon-Gibod
fc7923f83b add missing paths config for mkdocstrings 2022-06-02 15:47:27 -07:00
Gilles Boccon-Gibod
a9bd77e6ee add build workflow 2022-06-02 15:24:45 -07:00
Gilles Boccon-Gibod
ce0cf5fd27 refactor doc 2022-06-02 15:07:50 -07:00
Gilles Boccon-Gibod
86ded3fece support selecting usb device by serial number 2022-05-29 16:30:05 -07:00
Gilles Boccon-Gibod
d6b426eeec Merge pull request #2 from stefanst3000/fix-missing-dep
setup.cfg: add protobuf dependency and update package_dir
2022-05-19 12:55:07 -07:00
Stefan Stanacar
884315ae00 setup.cfg: add protobuf dependenc
Add protobuf to required dependencies, since is needed when using the
android-emulator transport.

Signed-off-by: Stefan Stanacar <stefanst@google.com>
2022-05-19 18:26:02 +00:00
Gilles Boccon-Gibod
7e8b201999 Update python-build-test.yml 2022-05-17 09:16:17 -07:00
Gilles Boccon-Gibod
e99d291cb7 Create python-build-test.yml 2022-05-17 09:14:49 -07:00
Gilles Boccon-Gibod
27c0551279 Create codeql-analysis.yml
Enable code analysis workflow
2022-05-16 20:24:05 -07:00
Gilles Boccon-Gibod
db2c833276 update mkdocs options 2022-05-16 20:16:02 -07:00
Gilles Boccon-Gibod
3dc2b9b2a8 Merge branch 'main' of https://github.com/google/bumble 2022-05-16 20:05:24 -07:00
Gilles Boccon-Gibod
210a509385 point to pre-built doc on GitHub Pages host 2022-05-16 20:05:10 -07:00
Gilles Boccon-Gibod
7b7c0ffa42 Merge pull request #1 from google/dependabot/pip/docs/mkdocs/mkdocs-1.2.3
Bump mkdocs from 1.2 to 1.2.3 in /docs/mkdocs
2022-05-16 19:47:25 -07:00
dependabot[bot]
ba0e123d96 Bump mkdocs from 1.2 to 1.2.3 in /docs/mkdocs
Bumps [mkdocs](https://github.com/mkdocs/mkdocs) from 1.2 to 1.2.3.
- [Release notes](https://github.com/mkdocs/mkdocs/releases)
- [Commits](https://github.com/mkdocs/mkdocs/compare/1.2...1.2.3)

---
updated-dependencies:
- dependency-name: mkdocs
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
2022-05-17 02:46:22 +00:00
25 changed files with 673 additions and 174 deletions

72
.github/workflows/codeql-analysis.yml vendored Normal file
View File

@@ -0,0 +1,72 @@
# For most projects, this workflow file will not need changing; you simply need
# to commit it to your repository.
#
# You may wish to alter this file to override the set of languages analyzed,
# or to provide custom queries or build logic.
#
# ******** NOTE ********
# We have attempted to detect the languages in your repository. Please check
# the `language` matrix defined below to confirm you have the correct set of
# supported CodeQL languages.
#
name: "CodeQL"
on:
push:
branches: [ main ]
pull_request:
# The branches below must be a subset of the branches above
branches: [ main ]
schedule:
- cron: '39 21 * * 4'
jobs:
analyze:
name: Analyze
runs-on: ubuntu-latest
permissions:
actions: read
contents: read
security-events: write
strategy:
fail-fast: false
matrix:
language: [ 'python' ]
# CodeQL supports [ 'cpp', 'csharp', 'go', 'java', 'javascript', 'python', 'ruby' ]
# Learn more about CodeQL language support at https://aka.ms/codeql-docs/language-support
steps:
- name: Checkout repository
uses: actions/checkout@v3
# Initializes the CodeQL tools for scanning.
- name: Initialize CodeQL
uses: github/codeql-action/init@v2
with:
languages: ${{ matrix.language }}
# If you wish to specify custom queries, you can do so here or in a config file.
# By default, queries listed here will override any specified in a config file.
# Prefix the list here with "+" to use these queries and those in the config file.
# Details on CodeQL's query packs refer to : https://docs.github.com/en/code-security/code-scanning/automatically-scanning-your-code-for-vulnerabilities-and-errors/configuring-code-scanning#using-queries-in-ql-packs
# queries: security-extended,security-and-quality
# Autobuild attempts to build any compiled languages (C/C++, C#, or Java).
# If this step fails, then you should remove it and run the build manually (see below)
- name: Autobuild
uses: github/codeql-action/autobuild@v2
# Command-line programs to run using the OS shell.
# 📚 See https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsrun
# If the Autobuild fails above, remove it and uncomment the following three lines.
# modify them (or add more) to build your code if your project, please refer to the EXAMPLE below for guidance.
# - run: |
# echo "Run, Build Application using script"
# ./location_of_script_within_repo/buildscript.sh
- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@v2

34
.github/workflows/python-build-test.yml vendored Normal file
View File

@@ -0,0 +1,34 @@
# Build and test the python package
name: Python build and test
on:
push:
branches: [ main ]
pull_request:
branches: [ main ]
permissions:
contents: read
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- name: Set up Python 3.10
uses: actions/setup-python@v3
with:
python-version: "3.10"
- name: Install dependencies
run: |
python -m pip install --upgrade pip
python -m pip install ".[test,development,documentation]"
- name: Test with pytest
run: |
pytest
- name: Build
run: |
inv build
inv mkdocs

10
.gitignore vendored Normal file
View File

@@ -0,0 +1,10 @@
.eggs/
build/
dist/
*.egg-info/
*~
bumble/__pycache__
docs/mkdocs/site
tests/__pycache__
test-results.xml
bumble/transport/__pycache__

View File

@@ -11,9 +11,12 @@ Bluetooth Stack for Apps, Emulation, Test and Experimentation
<img src="docs/mkdocs/src/images/logo_framed.png" alt="drawing" width="200" height="200"/>
Bumble is a full-featured Bluetooth stack written entirely in Python. It supports most of the common Bluetooth Low Energy (BLE) and Bluetooth Classic (BR/EDR) protocols and profiles, including GAP, L2CAP, ATT, GATT, SMP, SDP, RFCOMM, HFP, HID and A2DP. The stack can be used with physical radios via HCI over USB, UART, or the Linux VHCI, as well as virtual radios, including the virtual Bluetooth support of the Android emulator.
## Documentation
See the documentation under `docs/mkdocs/src`, or build the static HTML site from the markdown text with:
Browse the pre-built [Online Documentation](https://google.github.io/bumble/),
or see the documentation source under `docs/mkdocs/src`, or build the static HTML site from the markdown text with:
```
mkdocs build -f docs/mkdocs/mkdocs.yml
```

View File

@@ -44,7 +44,7 @@ from bumble.att import (
# -----------------------------------------------------------------------------
class Delegate(PairingDelegate):
def __init__(self, connection, capability_string, prompt):
def __init__(self, mode, connection, capability_string, prompt):
super().__init__({
'keyboard': PairingDelegate.KEYBOARD_INPUT_ONLY,
'display': PairingDelegate.DISPLAY_OUTPUT_ONLY,
@@ -53,6 +53,7 @@ class Delegate(PairingDelegate):
'none': PairingDelegate.NO_OUTPUT_NO_INPUT
}[capability_string.lower()])
self.mode = mode
self.peer = Peer(connection)
self.peer_name = None
self.prompt = prompt
@@ -64,7 +65,7 @@ class Delegate(PairingDelegate):
# Try to get the peer's name
if self.peer:
peer_name = await get_peer_name(self.peer)
peer_name = await get_peer_name(self.peer, self.mode)
self.peer_name = f'{peer_name or ""} [{self.peer.connection.peer_address}]'
else:
self.peer_name = '[?]'
@@ -91,7 +92,7 @@ class Delegate(PairingDelegate):
# Accept silently
return True
async def compare_numbers(self, number):
async def compare_numbers(self, number, digits):
await self.update_peer_name()
# Wait a bit to allow some of the log lines to print before we prompt
@@ -102,7 +103,7 @@ class Delegate(PairingDelegate):
print(color(f'### Pairing with {self.peer_name}', 'yellow'))
print(color('###-----------------------------------', 'yellow'))
while True:
response = await aioconsole.ainput(color(f'>>> Does the other device display {number:06}? ', 'yellow'))
response = await aioconsole.ainput(color(f'>>> Does the other device display {number:0{digits}}? ', 'yellow'))
response = response.lower().strip()
if response == 'yes':
return True
@@ -125,7 +126,7 @@ class Delegate(PairingDelegate):
except ValueError:
pass
async def display_number(self, number):
async def display_number(self, number, digits):
await self.update_peer_name()
# Wait a bit to allow some of the log lines to print before we prompt
@@ -134,19 +135,23 @@ class Delegate(PairingDelegate):
# Display a PIN code
print(color('###-----------------------------------', 'yellow'))
print(color(f'### Pairing with {self.peer_name}', 'yellow'))
print(color(f'### PIN: {number:06}', 'yellow'))
print(color(f'### PIN: {number:0{digits}}', 'yellow'))
print(color('###-----------------------------------', 'yellow'))
# -----------------------------------------------------------------------------
async def get_peer_name(peer):
services = await peer.discover_service(GATT_GENERIC_ACCESS_SERVICE)
if not services:
return None
async def get_peer_name(peer, mode):
if mode == 'classic':
return await peer.request_name()
else:
# Try to get the peer name from GATT
services = await peer.discover_service(GATT_GENERIC_ACCESS_SERVICE)
if not services:
return None
values = await peer.read_characteristics_by_uuid(GATT_DEVICE_NAME_CHARACTERISTIC, services[0])
if values:
return values[0].decode('utf-8')
values = await peer.read_characteristics_by_uuid(GATT_DEVICE_NAME_CHARACTERISTIC, services[0])
if values:
return values[0].decode('utf-8')
# -----------------------------------------------------------------------------
@@ -224,9 +229,22 @@ def on_pairing_failure(reason):
# -----------------------------------------------------------------------------
async def pair(sc, mitm, bond, io, prompt, request, print_keys, keystore_file, device_config, transport, address_or_name):
async def pair(
mode,
sc,
mitm,
bond,
io,
prompt,
request,
print_keys,
keystore_file,
device_config,
hci_transport,
address_or_name
):
print('<<< connecting to HCI...')
async with await open_transport_or_link(transport) as (hci_source, hci_sink):
async with await open_transport_or_link(hci_transport) as (hci_source, hci_sink):
print('<<< connected')
# Create a device to manage the host
@@ -245,19 +263,25 @@ async def pair(sc, mitm, bond, io, prompt, request, print_keys, keystore_file, d
# Expose a GATT characteristic that can be used to trigger pairing by
# responding with an authentication error when read
device.add_service(
Service(
'50DB505C-8AC4-4738-8448-3B1D9CC09CC5',
[
Characteristic(
'552957FB-CF1F-4A31-9535-E78847E1A714',
Characteristic.READ | Characteristic.WRITE,
Characteristic.READABLE | Characteristic.WRITEABLE,
CharacteristicValue(read=read_with_error, write=write_with_error)
)
]
if mode == 'le':
device.add_service(
Service(
'50DB505C-8AC4-4738-8448-3B1D9CC09CC5',
[
Characteristic(
'552957FB-CF1F-4A31-9535-E78847E1A714',
Characteristic.READ | Characteristic.WRITE,
Characteristic.READABLE | Characteristic.WRITEABLE,
CharacteristicValue(read=read_with_error, write=write_with_error)
)
]
)
)
)
# Select LE or Classic
if mode == 'classic':
device.classic_enabled = True
device.le_enabled = False
# Get things going
await device.power_on()
@@ -267,7 +291,7 @@ async def pair(sc, mitm, bond, io, prompt, request, print_keys, keystore_file, d
sc,
mitm,
bond,
Delegate(connection, io, prompt)
Delegate(mode, connection, io, prompt)
)
# Connect to a peer or wait for a connection
@@ -278,10 +302,14 @@ async def pair(sc, mitm, bond, io, prompt, request, print_keys, keystore_file, d
if not request:
try:
await connection.pair()
if mode == 'le':
await connection.pair()
else:
await connection.authenticate()
return
except ProtocolError as error:
print(color(f'Pairing failed: {error}', 'red'))
return
except ProtocolError:
pass
else:
# Advertise so that peers can find us and connect
await device.start_advertising(auto_restart=True)
@@ -291,6 +319,7 @@ async def pair(sc, mitm, bond, io, prompt, request, print_keys, keystore_file, d
# -----------------------------------------------------------------------------
@click.command()
@click.option('--mode', type=click.Choice(['le', 'classic']), default='le', show_default=True)
@click.option('--sc', type=bool, default=True, help='Use the Secure Connections protocol', show_default=True)
@click.option('--mitm', type=bool, default=True, help='Request MITM protection', show_default=True)
@click.option('--bond', type=bool, default=True, help='Enable bonding', show_default=True)
@@ -300,11 +329,11 @@ async def pair(sc, mitm, bond, io, prompt, request, print_keys, keystore_file, d
@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.argument('device-config')
@click.argument('transport')
@click.argument('hci_transport')
@click.argument('address-or-name', required=False)
def main(sc, mitm, bond, io, prompt, request, print_keys, keystore_file, device_config, transport, address_or_name):
def main(mode, sc, mitm, bond, io, prompt, request, print_keys, keystore_file, device_config, hci_transport, address_or_name):
logging.basicConfig(level = os.environ.get('BUMBLE_LOGLEVEL', 'INFO').upper())
asyncio.run(pair(sc, mitm, bond, io, prompt, request, print_keys, keystore_file, device_config, transport, address_or_name))
asyncio.run(pair(mode, sc, mitm, bond, io, prompt, request, print_keys, keystore_file, device_config, hci_transport, address_or_name))
# -----------------------------------------------------------------------------

View File

@@ -20,7 +20,7 @@ import click
from colors import color
from bumble import hci
from bumble.transport import PacketReader
from bumble.transport.common import PacketReader
from bumble.helpers import PacketTracer

View File

@@ -137,6 +137,10 @@ class Peer:
def get_characteristics_by_uuid(self, uuid, service = None):
return self.gatt_client.get_characteristics_by_uuid(uuid, service)
# [Classic only]
async def request_name(self):
return await self.connection.request_remote_name()
def __str__(self):
return f'{self.connection.peer_address} as {self.connection.role_name}'
@@ -176,6 +180,7 @@ class Connection(CompositeEventEmitter):
self.transport = transport
self.peer_address = peer_address
self.peer_resolvable_address = peer_resolvable_address
self.peer_name = None # Classic only
self.role = role
self.parameters = parameters
self.encryption = 0
@@ -231,6 +236,10 @@ class Connection(CompositeEventEmitter):
supervision_timeout
)
# [Classic only]
async def request_remote_name(self):
return await self.device.request_remote_name(self)
def __str__(self):
return f'Connection(handle=0x{self.handle:04X}, role={self.role_name}, address={self.peer_address})'
@@ -290,9 +299,19 @@ def with_connection_from_handle(function):
@functools.wraps(function)
def wrapper(self, connection_handle, *args, **kwargs):
if (connection := self.lookup_connection(connection_handle)) is None:
logger.warn(f'no connection found for handle 0x{connection_handle:04X}')
return
function(self, connection, *args, **kwargs)
raise ValueError('no connection for handle')
return function(self, connection, *args, **kwargs)
return wrapper
# Decorator that converts the first argument from a bluetooth address to a connection
def with_connection_from_address(function):
@functools.wraps(function)
def wrapper(self, address, *args, **kwargs):
for connection in self.connections.values():
if connection.peer_address == address:
return function(self, connection, *args, **kwargs)
raise ValueError('no connection for address')
return wrapper
@@ -453,6 +472,11 @@ class Device(CompositeEventEmitter):
if connection := self.connections.get(connection_handle):
return connection
def find_connection_by_bd_addr(self, bd_addr):
for connection in self.connections.values():
if connection.peer_address == bd_addr:
return connection
def register_l2cap_server(self, psm, server):
self.l2cap_channel_manager.register_server(psm, server)
@@ -792,8 +816,8 @@ class Device(CompositeEventEmitter):
async def disconnect(self, connection, reason):
# Create a future so that we can wait for the disconnection's result
pending_disconnection = asyncio.get_running_loop().create_future()
self.on('disconnection', pending_disconnection.set_result)
self.on('disconnection_failure', pending_disconnection.set_exception)
connection.on('disconnection', pending_disconnection.set_result)
connection.on('disconnection_failure', pending_disconnection.set_exception)
# Request a disconnection
result = await self.send_command(HCI_Disconnect_Command(connection_handle = connection.handle, reason = reason))
@@ -806,8 +830,8 @@ class Device(CompositeEventEmitter):
self.disconnecting = True
return await pending_disconnection
finally:
self.remove_listener('disconnection', pending_disconnection.set_result)
self.remove_listener('disconnection_failure', pending_disconnection.set_exception)
connection.remove_listener('disconnection', pending_disconnection.set_result)
connection.remove_listener('disconnection_failure', pending_disconnection.set_exception)
self.disconnecting = False
async def update_connection_parameters(
@@ -936,13 +960,13 @@ class Device(CompositeEventEmitter):
# Set up event handlers
pending_authentication = asyncio.get_running_loop().create_future()
def on_authentication_complete():
def on_authentication():
pending_authentication.set_result(None)
def on_authentication_failure(error):
pending_authentication.set_exception(error)
def on_authentication_failure(error_code):
pending_authentication.set_exception(HCI_Error(error_code))
connection.on('connection_authentication_complete', on_authentication_complete)
connection.on('connection_authentication', on_authentication)
connection.on('connection_authentication_failure', on_authentication_failure)
# Request the authentication
@@ -957,7 +981,7 @@ class Device(CompositeEventEmitter):
# Wait for the authentication to complete
await pending_authentication
finally:
connection.remove_listener('connection_authentication_complete', on_authentication_complete)
connection.remove_listener('connection_authentication', on_authentication)
connection.remove_listener('connection_authentication_failure', on_authentication_failure)
async def encrypt(self, connection):
@@ -1028,6 +1052,40 @@ class Device(CompositeEventEmitter):
connection.remove_listener('connection_encryption_change', on_encryption_change)
connection.remove_listener('connection_encryption_failure', on_encryption_failure)
# [Classic only]
async def request_remote_name(self, connection):
# Set up event handlers
pending_name = asyncio.get_running_loop().create_future()
def on_remote_name():
pending_name.set_result(connection.peer_name)
def on_remote_name_failure(error_code):
pending_name.set_exception(HCI_Error(error_code))
connection.on('remote_name', on_remote_name)
connection.on('remote_name_failure', on_remote_name_failure)
try:
result = await self.send_command(
HCI_Remote_Name_Request_Command(
bd_addr = connection.peer_address,
page_scan_repetition_mode = HCI_Remote_Name_Request_Command.R0, # TODO investigate other options
reserved = 0,
clock_offset = 0 # TODO investigate non-0 values
)
)
if result.status != HCI_COMMAND_STATUS_PENDING:
logger.warn(f'HCI_Set_Connection_Encryption_Command failed: {HCI_Constant.error_name(result.status)}')
raise HCI_Error(result.status)
# Wait for the result
return await pending_name
finally:
connection.remove_listener('remote_name', on_remote_name)
connection.remove_listener('remote_name_failure', on_remote_name_failure)
# [Classic only]
@host_event_handler
def on_link_key(self, bd_addr, link_key, key_type):
@@ -1123,14 +1181,15 @@ class Device(CompositeEventEmitter):
asyncio.create_task(self.start_advertising(auto_restart=self.auto_restart_advertising))
@host_event_handler
def on_disconnection_failure(self, error_code):
@with_connection_from_handle
def on_disconnection_failure(self, connection, error_code):
logger.debug(f'*** Disconnection failed: {error_code}')
error = ConnectionError(
error_code,
'hci',
HCI_Constant.error_name(error_code)
)
self.emit('disconnection_failure', error)
connection.emit('disconnection_failure', error)
@host_event_handler
@AsyncRunner.run_in_task()
@@ -1141,10 +1200,10 @@ class Device(CompositeEventEmitter):
@host_event_handler
@with_connection_from_handle
def on_connection_authentication_complete(self, connection):
logger.debug(f'*** Connection Authentication Complete: [0x{connection.handle:04X}] {connection.peer_address} as {connection.role_name}')
def on_connection_authentication(self, connection):
logger.debug(f'*** Connection Authentication: [0x{connection.handle:04X}] {connection.peer_address} as {connection.role_name}')
connection.authenticated = True
connection.emit('connection_authentication_complete')
connection.emit('connection_authentication')
@host_event_handler
@with_connection_from_handle
@@ -1152,6 +1211,132 @@ class Device(CompositeEventEmitter):
logger.debug(f'*** Connection Authentication Failure: [0x{connection.handle:04X}] {connection.peer_address} as {connection.role_name}, error={error}')
connection.emit('connection_authentication_failure', error)
# [Classic only]
@host_event_handler
@with_connection_from_address
def on_authentication_io_capability_request(self, connection):
# Ask what the pairing config should be for this connection
pairing_config = self.pairing_config_factory(connection)
# Map the SMP IO capability to a Classic IO capability
io_capability = {
smp.SMP_DISPLAY_ONLY_IO_CAPABILITY: HCI_DISPLAY_ONLY_IO_CAPABILITY,
smp.SMP_DISPLAY_YES_NO_IO_CAPABILITY: HCI_DISPLAY_YES_NO_IO_CAPABILITY,
smp.SMP_KEYBOARD_ONLY_IO_CAPABILITY: HCI_KEYBOARD_ONLY_IO_CAPABILITY,
smp.SMP_NO_INPUT_NO_OUTPUT_IO_CAPABILITY: HCI_NO_INPUT_NO_OUTPUT_IO_CAPABILITY,
smp.SMP_KEYBOARD_DISPLAY_IO_CAPABILITY: HCI_DISPLAY_YES_NO_IO_CAPABILITY
}.get(pairing_config.delegate.io_capability)
if io_capability is None:
logger.warning(f'cannot map IO capability ({pairing_config.delegate.io_capability}')
io_capability = HCI_NO_INPUT_NO_OUTPUT_IO_CAPABILITY
# Compute the authentication requirements
authentication_requirements = (
# No Bonding
(
HCI_MITM_NOT_REQUIRED_NO_BONDING_AUTHENTICATION_REQUIREMENTS,
HCI_MITM_REQUIRED_NO_BONDING_AUTHENTICATION_REQUIREMENTS
),
# General Bonding
(
HCI_MITM_NOT_REQUIRED_GENERAL_BONDING_AUTHENTICATION_REQUIREMENTS,
HCI_MITM_REQUIRED_GENERAL_BONDING_AUTHENTICATION_REQUIREMENTS
)
)[1 if pairing_config.bonding else 0][1 if pairing_config.mitm else 0]
# Respond
self.host.send_command_sync(
HCI_IO_Capability_Request_Reply_Command(
bd_addr = connection.peer_address,
io_capability = io_capability,
oob_data_present = 0x00, # Not present
authentication_requirements = authentication_requirements
)
)
# [Classic only]
@host_event_handler
@with_connection_from_address
def on_authentication_user_confirmation_request(self, connection, code):
# Ask what the pairing config should be for this connection
pairing_config = self.pairing_config_factory(connection)
can_confirm = pairing_config.delegate.io_capability not in {
smp.SMP_NO_INPUT_NO_OUTPUT_IO_CAPABILITY,
smp.SMP_DISPLAY_ONLY_IO_CAPABILITY
}
# Respond
if can_confirm and pairing_config.delegate:
async def compare_numbers():
numbers_match = await pairing_config.delegate.compare_numbers(code, digits=6)
if numbers_match:
self.host.send_command_sync(
HCI_User_Confirmation_Request_Reply_Command(bd_addr=connection.peer_address)
)
else:
self.host.send_command_sync(
HCI_User_Confirmation_Request_Negative_Reply_Command(bd_addr=connection.peer_address)
)
asyncio.create_task(compare_numbers())
else:
self.host.send_command_sync(
HCI_User_Confirmation_Request_Reply_Command(bd_addr=connection.peer_address)
)
# [Classic only]
@host_event_handler
@with_connection_from_address
def on_authentication_user_passkey_request(self, connection):
# Ask what the pairing config should be for this connection
pairing_config = self.pairing_config_factory(connection)
can_input = pairing_config.delegate.io_capability in {
smp.SMP_KEYBOARD_ONLY_IO_CAPABILITY,
smp.SMP_KEYBOARD_DISPLAY_IO_CAPABILITY
}
# Respond
if can_input and pairing_config.delegate:
async def get_number():
number = await pairing_config.delegate.get_number()
if number is not None:
self.host.send_command_sync(
HCI_User_Passkey_Request_Reply_Command(
bd_addr = connection.peer_address,
numeric_value = number)
)
else:
self.host.send_command_sync(
HCI_User_Passkey_Request_Negative_Reply_Command(bd_addr=connection.peer_address)
)
asyncio.create_task(get_number())
else:
self.host.send_command_sync(
HCI_User_Passkey_Request_Negative_Reply_Command(bd_addr=connection.peer_address)
)
# [Classic only]
@host_event_handler
@with_connection_from_address
def on_remote_name(self, connection, remote_name):
# Try to decode the name
try:
connection.peer_name = remote_name.decode('utf-8')
connection.emit('remote_name')
except UnicodeDecodeError as error:
logger.warning('peer name is not valid UTF-8')
connection.emit('remote_name_failure', error)
# [Classic only]
@host_event_handler
@with_connection_from_address
def on_remote_name_failure(self, connection, error):
connection.emit('remote_name_failure', error)
@host_event_handler
@with_connection_from_handle
def on_connection_encryption_change(self, connection, encryption):

View File

@@ -79,6 +79,7 @@ HCI_VERSION_BLUETOOTH_CORE_4_2 = 8
HCI_VERSION_BLUETOOTH_CORE_5_0 = 9
HCI_VERSION_BLUETOOTH_CORE_5_1 = 10
HCI_VERSION_BLUETOOTH_CORE_5_2 = 11
HCI_VERSION_BLUETOOTH_CORE_5_3 = 12
# HCI Packet types
HCI_COMMAND_PACKET = 0x01
@@ -258,6 +259,9 @@ HCI_READ_REMOTE_VERSION_INFORMATION_COMMAND = hci_command_
HCI_READ_CLOCK_OFFSET_COMMAND = hci_command_op_code(0x01, 0x001F)
HCI_IO_CAPABILITY_REQUEST_REPLY_COMMAND = hci_command_op_code(0x01, 0x002B)
HCI_USER_CONFIRMATION_REQUEST_REPLY_COMMAND = hci_command_op_code(0x01, 0x002C)
HCI_USER_CONFIRMATION_REQUEST_NEGATIVE_REPLY_COMMAND = hci_command_op_code(0x01, 0x002D)
HCI_USER_PASSKEY_REQUEST_REPLY_COMMAND = hci_command_op_code(0x01, 0x002E)
HCI_USER_PASSKEY_REQUEST_NEGATIVE_REPLY_COMMAND = hci_command_op_code(0x01, 0x002F)
HCI_ENHANCED_SETUP_SYNCHRONOUS_CONNECTION_COMMAND = hci_command_op_code(0x01, 0x003D)
HCI_SNIFF_MODE_COMMAND = hci_command_op_code(0x02, 0x0003)
HCI_EXIT_SNIFF_MODE_COMMAND = hci_command_op_code(0x02, 0x0004)
@@ -407,6 +411,9 @@ HCI_COMMAND_NAMES = {
HCI_READ_CLOCK_OFFSET_COMMAND: 'HCI_READ_CLOCK_OFFSET_COMMAND',
HCI_IO_CAPABILITY_REQUEST_REPLY_COMMAND: 'HCI_IO_CAPABILITY_REQUEST_REPLY_COMMAND',
HCI_USER_CONFIRMATION_REQUEST_REPLY_COMMAND: 'HCI_USER_CONFIRMATION_REQUEST_REPLY_COMMAND',
HCI_USER_CONFIRMATION_REQUEST_NEGATIVE_REPLY_COMMAND: 'HCI_USER_CONFIRMATION_REQUEST_NEGATIVE_REPLY_COMMAND',
HCI_USER_PASSKEY_REQUEST_REPLY_COMMAND: 'HCI_USER_PASSKEY_REQUEST_REPLY_COMMAND',
HCI_USER_PASSKEY_REQUEST_NEGATIVE_REPLY_COMMAND: 'HCI_USER_PASSKEY_REQUEST_NEGATIVE_REPLY_COMMAND',
HCI_ENHANCED_SETUP_SYNCHRONOUS_CONNECTION_COMMAND: 'HCI_ENHANCED_SETUP_SYNCHRONOUS_CONNECTION_COMMAND',
HCI_SNIFF_MODE_COMMAND: 'HCI_SNIFF_MODE_COMMAND',
HCI_EXIT_SNIFF_MODE_COMMAND: 'HCI_EXIT_SNIFF_MODE_COMMAND',
@@ -683,20 +690,20 @@ HCI_IO_CAPABILITY_NAMES = {
}
# Authentication Requirements
HCI_MITM_NOT_REQUIRED_NO_BONDING_NUMERIC_COMPARISON_AUTHENTICATION_REQUIREMENTS = 0x00
HCI_MITM_REQUIRED_NO_BONDING_USE_IO_CAPABILITIES_AUTHENTICATION_REQUIREMENTS = 0x01
HCI_MITM_NOT_REQUIRED_DEDICATED_BONDING_NUMERIC_COMPARISON_AUTHENTICATION_REQUIREMENTS = 0x02
HCI_MITM_REQUIRED_DEDICATED_BONDING_USE_IO_CAPABILITIES_AUTHENTICATION_REQUIREMENTS = 0x03
HCI_MITM_NOT_REQUIRED_GENERAL_BONDING_NUMERIC_COMPARISON_AUTHENTICATION_REQUIREMENTS = 0x04
HCI_MITM_REQUIRED_GENERAL_BONDING_USE_IO_CAPABILITIES_AUTHENTICATION_REQUIREMENTS = 0x05
HCI_MITM_NOT_REQUIRED_NO_BONDING_AUTHENTICATION_REQUIREMENTS = 0x00
HCI_MITM_REQUIRED_NO_BONDING_AUTHENTICATION_REQUIREMENTS = 0x01
HCI_MITM_NOT_REQUIRED_DEDICATED_BONDING_AUTHENTICATION_REQUIREMENTS = 0x02
HCI_MITM_REQUIRED_DEDICATED_BONDING_AUTHENTICATION_REQUIREMENTS = 0x03
HCI_MITM_NOT_REQUIRED_GENERAL_BONDING_AUTHENTICATION_REQUIREMENTS = 0x04
HCI_MITM_REQUIRED_GENERAL_BONDING_AUTHENTICATION_REQUIREMENTS = 0x05
HCI_AUTHENTICATION_REQUIREMENTS_NAMES = {
HCI_MITM_NOT_REQUIRED_NO_BONDING_NUMERIC_COMPARISON_AUTHENTICATION_REQUIREMENTS: 'HCI_MITM_NOT_REQUIRED_NO_BONDING_NUMERIC_COMPARISON_AUTHENTICATION_REQUIREMENTS',
HCI_MITM_REQUIRED_NO_BONDING_USE_IO_CAPABILITIES_AUTHENTICATION_REQUIREMENTS: 'HCI_MITM_REQUIRED_NO_BONDING_USE_IO_CAPABILITIES_AUTHENTICATION_REQUIREMENTS',
HCI_MITM_NOT_REQUIRED_DEDICATED_BONDING_NUMERIC_COMPARISON_AUTHENTICATION_REQUIREMENTS: 'HCI_MITM_NOT_REQUIRED_DEDICATED_BONDING_NUMERIC_COMPARISON_AUTHENTICATION_REQUIREMENTS',
HCI_MITM_REQUIRED_DEDICATED_BONDING_USE_IO_CAPABILITIES_AUTHENTICATION_REQUIREMENTS: 'HCI_MITM_REQUIRED_DEDICATED_BONDING_USE_IO_CAPABILITIES_AUTHENTICATION_REQUIREMENTS',
HCI_MITM_NOT_REQUIRED_GENERAL_BONDING_NUMERIC_COMPARISON_AUTHENTICATION_REQUIREMENTS: 'HCI_MITM_NOT_REQUIRED_GENERAL_BONDING_NUMERIC_COMPARISON_AUTHENTICATION_REQUIREMENTS',
HCI_MITM_REQUIRED_GENERAL_BONDING_USE_IO_CAPABILITIES_AUTHENTICATION_REQUIREMENTS: 'HCI_MITM_REQUIRED_GENERAL_BONDING_USE_IO_CAPABILITIES_AUTHENTICATION_REQUIREMENTS'
HCI_MITM_NOT_REQUIRED_NO_BONDING_AUTHENTICATION_REQUIREMENTS: 'HCI_MITM_NOT_REQUIRED_NO_BONDING_AUTHENTICATION_REQUIREMENTS',
HCI_MITM_REQUIRED_NO_BONDING_AUTHENTICATION_REQUIREMENTS: 'HCI_MITM_REQUIRED_NO_BONDING_AUTHENTICATION_REQUIREMENTS',
HCI_MITM_NOT_REQUIRED_DEDICATED_BONDING_AUTHENTICATION_REQUIREMENTS: 'HCI_MITM_NOT_REQUIRED_DEDICATED_BONDING_AUTHENTICATION_REQUIREMENTS',
HCI_MITM_REQUIRED_DEDICATED_BONDING_AUTHENTICATION_REQUIREMENTS: 'HCI_MITM_REQUIRED_DEDICATED_BONDING_AUTHENTICATION_REQUIREMENTS',
HCI_MITM_NOT_REQUIRED_GENERAL_BONDING_AUTHENTICATION_REQUIREMENTS: 'HCI_MITM_NOT_REQUIRED_GENERAL_BONDING_AUTHENTICATION_REQUIREMENTS',
HCI_MITM_REQUIRED_GENERAL_BONDING_AUTHENTICATION_REQUIREMENTS: 'HCI_MITM_REQUIRED_GENERAL_BONDING_AUTHENTICATION_REQUIREMENTS'
}
# Link Key Types
@@ -723,7 +730,7 @@ HCI_LINK_TYPE_NAMES = {
}
# Address types
HCI_PUBLIC_DEVICE_ADDRESS_TYPE = 0x0
HCI_PUBLIC_DEVICE_ADDRESS_TYPE = 0x00
HCI_RANDOM_DEVICE_ADDRESS_TYPE = 0x01
HCI_PUBLIC_IDENTITY_ADDRESS_TYPE = 0x02
HCI_RANDOM_IDENTITY_ADDRESS_TYPE = 0x03
@@ -1371,6 +1378,9 @@ class HCI_Remote_Name_Request_Command(HCI_Command):
'''
See Bluetooth spec @ 7.1.19 Remote Name Request Command
'''
R0 = 0x00
R1 = 0x01
R2 = 0x02
# -----------------------------------------------------------------------------
@@ -1449,6 +1459,55 @@ class HCI_User_Confirmation_Request_Reply_Command(HCI_Command):
'''
# -----------------------------------------------------------------------------
@HCI_Command.command(
fields=[
('bd_addr', Address.parse_address)
],
return_parameters_fields=[
('status', STATUS_SPEC),
('bd_addr', Address.parse_address)
]
)
class HCI_User_Confirmation_Request_Negative_Reply_Command(HCI_Command):
'''
See Bluetooth spec @ 7.1.31 User Confirmation Request Negative Reply Command
'''
# -----------------------------------------------------------------------------
@HCI_Command.command(
fields=[
('bd_addr', Address.parse_address),
('numeric_value', 4)
],
return_parameters_fields=[
('status', STATUS_SPEC),
('bd_addr', Address.parse_address)
]
)
class HCI_User_Passkey_Request_Reply_Command(HCI_Command):
'''
See Bluetooth spec @ 7.1.32 User Passkey Request Reply Command
'''
# -----------------------------------------------------------------------------
@HCI_Command.command(
fields=[
('bd_addr', Address.parse_address)
],
return_parameters_fields=[
('status', STATUS_SPEC),
('bd_addr', Address.parse_address)
]
)
class HCI_User_Passkey_Request_Negative_Reply_Command(HCI_Command):
'''
See Bluetooth spec @ 7.1.33 User Passkey Request Negative Reply Command
'''
# -----------------------------------------------------------------------------
@HCI_Command.command([
('connection_handle', 2),
@@ -3367,6 +3426,16 @@ class HCI_User_Confirmation_Request_Event(HCI_Event):
'''
# -----------------------------------------------------------------------------
@HCI_Event.event([
('bd_addr', Address.parse_address)
])
class HCI_User_Passkey_Request_Event(HCI_Event):
'''
See Bluetooth spec @ 7.7.43 User Passkey Request Event
'''
# -----------------------------------------------------------------------------
@HCI_Event.event([
('status', STATUS_SPEC),

View File

@@ -85,6 +85,7 @@ class Host(EventEmitter):
self.command_semaphore = asyncio.Semaphore(1)
self.long_term_key_provider = None
self.link_key_provider = None
self.pairing_io_capability_provider = None # Classic only
# Connect to the source and sink if specified
if controller_source:
@@ -363,7 +364,7 @@ class Host(EventEmitter):
logger.debug(f'### BR/EDR CONNECTION FAILED: {event.status}')
# Notify the client
self.emit('connection_failure', event.status)
self.emit('connection_failure', event.connection_handle, event.status)
def on_hci_disconnection_complete_event(self, event):
# Find the connection
@@ -495,7 +496,7 @@ class Host(EventEmitter):
def on_hci_authentication_complete_event(self, event):
# Notify the client
if event.status == HCI_SUCCESS:
self.emit('connection_authentication_complete', event.connection_handle)
self.emit('connection_authentication', event.connection_handle)
else:
self.emit('connection_authentication_failure', event.connection_handle, event.status)
@@ -560,26 +561,16 @@ class Host(EventEmitter):
asyncio.create_task(send_link_key())
def on_hci_io_capability_request_event(self, event):
# For now, just return NoInputNoOutput and no MITM
# TODO: delegate the decision
self.send_command_sync(
HCI_IO_Capability_Request_Reply_Command(
bd_addr = event.bd_addr,
io_capability = HCI_NO_INPUT_NO_OUTPUT_IO_CAPABILITY,
oob_data_present = 0x00,
authentication_requirements = 0x00 # 0x02 # FIXME: testing only
)
)
self.emit('authentication_io_capability_request', event.bd_addr)
def on_hci_io_capability_response_event(self, event):
pass
def on_hci_user_confirmation_request_event(self, event):
# For now, just confirm everything
# TODO: delegate the decision
self.send_command_sync(
HCI_User_Confirmation_Request_Reply_Command(bd_addr = event.bd_addr)
)
self.emit('authentication_user_confirmation_request', event.bd_addr, event.numeric_value)
def on_hci_user_passkey_request_event(self, event):
self.emit('authentication_user_passkey_request', event.bd_addr)
def on_hci_inquiry_complete_event(self, event):
self.emit('inquiry_complete')
@@ -602,3 +593,9 @@ class Host(EventEmitter):
event.extended_inquiry_response,
event.rssi
)
def on_hci_remote_name_request_complete_event(self, event):
if event.status != HCI_SUCCESS:
self.emit('remote_name_failure', event.bd_addr, event.status)
else:
self.emit('remote_name', event.bd_addr, event.remote_name)

View File

@@ -243,7 +243,7 @@ class L2CAP_Control_Frame:
# -----------------------------------------------------------------------------
@L2CAP_Control_Frame.subclass([
('reason', {'size': 2, 'mapper': lambda x: L2CAP_Command_Reject.map_reason(x)}),
('reason', {'size': 2, 'mapper': lambda x: L2CAP_Command_Reject.reason_name(x)}),
('data', '*')
])
class L2CAP_Command_Reject(L2CAP_Control_Frame):
@@ -262,7 +262,7 @@ class L2CAP_Command_Reject(L2CAP_Control_Frame):
}
@staticmethod
def map_reason(reason):
def reason_name(reason):
return name_or_number(L2CAP_Command_Reject.REASON_NAMES, reason)
@@ -330,7 +330,7 @@ class L2CAP_Configure_Request(L2CAP_Control_Frame):
@L2CAP_Control_Frame.subclass([
('source_cid', 2),
('flags', 2),
('result', {'size': 2, 'mapper': lambda x: L2CAP_Configure_Response.map_result(x)}),
('result', {'size': 2, 'mapper': lambda x: L2CAP_Configure_Response.result_name(x)}),
('options', '*')
])
class L2CAP_Configure_Response(L2CAP_Control_Frame):
@@ -355,7 +355,7 @@ class L2CAP_Configure_Response(L2CAP_Control_Frame):
}
@staticmethod
def map_result(result):
def result_name(result):
return name_or_number(L2CAP_Configure_Response.RESULT_NAMES, result)
@@ -403,31 +403,49 @@ class L2CAP_Echo_Response(L2CAP_Control_Frame):
# -----------------------------------------------------------------------------
@L2CAP_Control_Frame.subclass([
('info_type', 2)
('info_type', {'size': 2, 'mapper': lambda x: L2CAP_Information_Request.info_type_name(x)})
])
class L2CAP_Information_Request(L2CAP_Control_Frame):
'''
See Bluetooth spec @ Vol 3, Part A - 4.10 INFORMATION REQUEST
'''
SUCCESS = 0x00
NOT_SUPPORTED = 0x01
CONNECTIONLESS_MTU = 0x0001
EXTENDED_FEATURES_SUPPORTED = 0x0002
FIXED_CHANNELS_SUPPORTED = 0x0003
INFO_TYPE_NAMES = {
CONNECTIONLESS_MTU: 'CONNECTIONLESS_MTU',
EXTENDED_FEATURES_SUPPORTED: 'EXTENDED_FEATURES_SUPPORTED',
FIXED_CHANNELS_SUPPORTED: 'FIXED_CHANNELS_SUPPORTED'
}
@staticmethod
def info_type_name(info_type):
return name_or_number(L2CAP_Information_Request.INFO_TYPE_NAMES, info_type)
# -----------------------------------------------------------------------------
@L2CAP_Control_Frame.subclass([
('info_type', 2),
('result', 2),
('info_type', {'size': 2, 'mapper': L2CAP_Information_Request.info_type_name}),
('result', {'size': 2, 'mapper': lambda x: L2CAP_Information_Response.result_name(x)}),
('data', '*')
])
class L2CAP_Information_Response(L2CAP_Control_Frame):
'''
See Bluetooth spec @ Vol 3, Part A - 4.11 INFORMATION RESPONSE
'''
SUCCESS = 0x00
NOT_SUPPORTED = 0x01
RESULT_NAMES = {
SUCCESS: 'SUCCESS',
NOT_SUPPORTED: 'NOT_SUPPORTED'
}
@staticmethod
def result_name(result):
return name_or_number(L2CAP_Information_Response.RESULT_NAMES, result)
# -----------------------------------------------------------------------------
@@ -473,7 +491,7 @@ class L2CAP_LE_Credit_Based_Connection_Request(L2CAP_Control_Frame):
('mtu', 2),
('mps', 2),
('initial_credits', 2),
('result', {'size': 2, 'mapper': lambda x: L2CAP_LE_Credit_Based_Connection_Response.map_result(x)})
('result', {'size': 2, 'mapper': lambda x: L2CAP_LE_Credit_Based_Connection_Response.result_name(x)})
])
class L2CAP_LE_Credit_Based_Connection_Response(L2CAP_Control_Frame):
'''
@@ -505,7 +523,7 @@ class L2CAP_LE_Credit_Based_Connection_Response(L2CAP_Control_Frame):
}
@staticmethod
def map_result(result):
def result_name(result):
return name_or_number(L2CAP_LE_Credit_Based_Connection_Response.CONNECTION_RESULT_NAMES, result)
@@ -980,13 +998,13 @@ class ChannelManager:
def on_l2cap_information_request(self, connection, cid, request):
if request.info_type == L2CAP_Information_Request.CONNECTIONLESS_MTU:
result = L2CAP_Information_Request.SUCCESS
result = L2CAP_Information_Response.SUCCESS
data = struct.pack('<H', 1024) # TODO: don't use a fixed value
elif request.info_type == L2CAP_Information_Request.EXTENDED_FEATURES_SUPPORTED:
result = L2CAP_Information_Request.SUCCESS
result = L2CAP_Information_Response.SUCCESS
data = bytes.fromhex('00000000') # TODO: don't use a fixed value
elif request.info_type == L2CAP_Information_Request.FIXED_CHANNELS_SUPPORTED:
result = L2CAP_Information_Request.SUCCESS
result = L2CAP_Information_Response.SUCCESS
data = bytes.fromhex('FFFFFFFFFFFFFFFF') # TODO: don't use a fixed value
else:
result = L2CAP_Information_Request.NO_SUPPORTED

View File

@@ -464,13 +464,13 @@ class PairingDelegate:
async def accept(self):
return True
async def compare_numbers(self, number):
async def compare_numbers(self, number, digits=6):
return True
async def get_number(self):
return 0
async def display_number(self, number):
async def display_number(self, number, digits=6):
pass
@@ -699,7 +699,7 @@ class Session:
async def prompt():
logger.debug(f'verification code: {code}')
try:
response = await self.pairing_config.delegate.compare_numbers(code)
response = await self.pairing_config.delegate.compare_numbers(code, digits=6)
if response:
next_steps()
return
@@ -733,7 +733,7 @@ class Session:
self.tk = self.passkey.to_bytes(16, byteorder='little')
logger.debug(f'TK from passkey = {self.tk.hex()}')
asyncio.create_task(self.pairing_config.delegate.display_number(self.passkey))
asyncio.create_task(self.pairing_config.delegate.display_number(self.passkey, digits=6))
def input_passkey(self, next_steps=None):
# Prompt the user for the passkey displayed on the peer

View File

@@ -37,14 +37,17 @@ async def open_usb_transport(spec):
'''
Open a USB transport.
The parameter string has this syntax:
either <index> or <vendor>:<product>
either <index> or <vendor>:<product>[/<serial-number>]
With <index> as the 0-based index to select amongst all the devices that appear
to be supporting Bluetooth HCI (0 being the first one), or
Where <vendor> and <product> are the vendor ID and product ID in hexadecimal.
Where <vendor> and <product> are the vendor ID and product ID in hexadecimal. The
/<serial-number> suffix max be specified when more than one device with the same
vendor and product identifiers are present.
Examples:
0 --> the first BT USB dongle
04b4:f901 --> the BT USB dongle with vendor=04b4 and product=f901
04b4:f901/00E04C239987 --> the BT USB dongle with vendor=04b4 and product=f901 and serial number 00E04C239987
'''
USB_RECIPIENT_DEVICE = 0x00
@@ -268,22 +271,32 @@ async def open_usb_transport(spec):
found = None
if ':' in spec:
vendor_id, product_id = spec.split(':')
found = context.getByVendorIDAndProductID(int(vendor_id, 16), int(product_id, 16), skip_on_error=True)
if '/' in product_id:
product_id, serial_number = product_id.split('/')
for device in context.getDeviceIterator(skip_on_error=True):
if (
device.getVendorID() == int(vendor_id, 16) and
device.getProductID() == int(product_id, 16) and
device.getSerialNumber() == serial_number
):
found = device
break
device.close()
else:
found = context.getByVendorIDAndProductID(int(vendor_id, 16), int(product_id, 16), skip_on_error=True)
else:
device_index = int(spec)
device_iterator = context.getDeviceIterator(skip_on_error=True)
try:
for device in device_iterator:
if device.getDeviceClass() == USB_DEVICE_CLASS_WIRELESS_CONTROLLER and \
device.getDeviceSubClass() == USB_DEVICE_SUBCLASS_RF_CONTROLLER and \
device.getDeviceProtocol() == USB_DEVICE_PROTOCOL_BLUETOOTH_PRIMARY_CONTROLLER:
if device_index == 0:
found = device
break
device_index -= 1
device.close()
finally:
device_iterator.close()
for device in context.getDeviceIterator(skip_on_error=True):
if (
device.getDeviceClass() == USB_DEVICE_CLASS_WIRELESS_CONTROLLER and
device.getDeviceSubClass() == USB_DEVICE_SUBCLASS_RF_CONTROLLER and
device.getDeviceProtocol() == USB_DEVICE_PROTOCOL_BLUETOOTH_PRIMARY_CONTROLLER
):
if device_index == 0:
found = device
break
device_index -= 1
device.close()
if found is None:
context.close()

View File

@@ -1,10 +1,12 @@
site_name: Bumble
use_directory_urls: true
use_directory_urls: false
nav:
- Introduction: index.md
- Getting Started: getting_started.md
- Development:
- Python Environments: development/python_environments.md
- Use Cases:
- Overview: use_cases/index.md
- Use Case 1: use_cases/use_case_1.md
@@ -63,15 +65,18 @@ theme:
custom_dir: 'theme'
plugins:
- mkdocstrings
- mkdocstrings:
handlers:
python:
paths: [../..]
docs_dir: 'src'
edit_uri: ''
# Repo info
#repo_name: TBD
#repo_url: https://github.com/TBD
repo_name: 'GitHub'
repo_url: https://github.com/google/bumble
# Extensions
markdown_extensions:

View File

@@ -1,5 +1,5 @@
# This requirements file is for python3
mkdocs == 1.2
mkdocs == 1.2.3
mkdocs-material == 7.1.7
mkdocs-material-extensions == 1.0.1
pymdown-extensions == 8.2

View File

@@ -0,0 +1,2 @@
GATT DUMP TOOL
==============

View File

@@ -5,8 +5,10 @@ Included in the project are a few apps and tools, built on top of the core libra
These include:
* [Console](console.md) - an interactive text-based console
* [Pair](pair.md) - Pair/bond two devices (LE and Classic)
* [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
* [Golden Gate Bridge](gg_bridge.md) - a bridge between GATT and UDP to use with the Golden Gate "stack tool"
* [`Show`](show.md) - Parse a file with HCI packets and print the details of each packet in a human readable form
* [`Link Relay`](link_relay.md) - WebSocket relay for virtual RemoteLink instances to communicate with each other.
* [Show](show.md) - Parse a file with HCI packets and print the details of each packet in a human readable form
* [Link Relay](link_relay.md) - WebSocket relay for virtual RemoteLink instances to communicate with each other.

View File

@@ -0,0 +1,2 @@
PAIR TOOL
=========

View File

@@ -0,0 +1,2 @@
SHOW TOOL
=========

View File

@@ -0,0 +1,2 @@
UNBOND TOOL
===========

View File

@@ -0,0 +1,45 @@
PYTHON ENVIRONMENTS
===================
When you don't want to install Bumble in your main/default python environment,
using a virtual environment, where the package and its dependencies can be
installed, isolated from the rest, may be useful.
There are many flavors of python environments and dependency managers.
This page describes a few of the most common ones.
## venv
`venv` is a standard module that is included with python.
Visit the [`venv` documentation](https://docs.python.org/3/library/venv.html) page for details.
## Pyenv
`pyenv` lets you easily switch between multiple versions of Python. It's simple, unobtrusive, and follows the UNIX tradition of single-purpose tools that do one thing well.
Visit the [`pyenv` site](https://github.com/pyenv/pyenv) for instructions on how to install
and use `pyenv`
## Conda
Conda is a convenient package manager and virtual environment.
The file `environment.yml` is a Conda environment file that you can use to create
a new Conda environment. Once created, you can simply activate this environment when
working with Bumble.
Visit the [Conda site](https://docs.conda.io/en/latest/) for instructions on how to install
and use Conda.
A few useful commands:
### Create a new `bumble` Conda environment
```
$ conda env create -f environment.yml
```
This will create a new environment, named `bumble`, which you can then activate with:
```
$ conda activate bumble
```
### Update an existing `bumble` environment
```
$ conda env update -f environment.yml
```

View File

@@ -20,7 +20,7 @@ You may be simply using Bumble as a module for your own application or as a depe
module, or you may be working on modifying or contributing to the Bumble module or example code
itself.
# Working With Bumble As A Module
# Using Bumble As A Python Module
## Installing
@@ -29,52 +29,40 @@ manager, or from source.
!!! tip "Python Virtual Environments"
When you install Bumble, you have the option to install it as part of your default
python environment, or in a virtual environment, such as a `venv`, `pyenv` or `conda` environment
### venv
`venv` is a standard module that is included with python.
Visit the [`venv` documentation](https://docs.python.org/3/library/venv.html) page for details.
### Pyenv
`pyenv` lets you easily switch between multiple versions of Python. It's simple, unobtrusive, and follows the UNIX tradition of single-purpose tools that do one thing well.
Visit the [`pyenv` site](https://github.com/pyenv/pyenv) for instructions on how to install
and use `pyenv`
### Conda
Conda is a convenient package manager and virtual environment.
The file `environment.yml` is a Conda environment file that you can use to create
a new Conda environment. Once created, you can simply activate this environment when
working with Bumble.
Visit the [Conda side](https://docs.conda.io/en/latest/) for instructions on how to install
and use Conda.
A few useful commands:
#### Create a new `bumble` Conda environment
```
$ conda env create -f environment.yml
```
This will create a new environment, named `bumble`, which you can then activate with:
```
$ conda activate bumble
```
#### Update an existing `bumble` environment
```
$ conda env update -f environment.yml
```
python environment, or in a virtual environment, such as a `venv`, `pyenv` or `conda` environment.
See the [Python Environments page](development/python_environments.md) page for details.
### Install From Source
The instructions for working with virtual Python environments above also apply in this case.
Install with `pip`
Install with `pip`. Run in a command shell in the directory where you downloaded the source
distribution
```
$ python -m pip install -e .
```
### Install from GitHub
You can install directly from GitHub without first downloading the repo.
Install the latest commit from the main branch with `pip`:
```
$ python -m pip install git+https://github.com/google/bumble.git
```
You can specify a specific tag.
Install tag `v0.0.1` with `pip`:
```
$ python -m pip install git+https://github.com/google/bumble.git@v0.0.1
```
You can also specify a specific commit.
Install commit `27c0551` with `pip`:
```
$ python -m pip install git+https://github.com/google/bumble.git@27c0551
```
# Working On The Bumble Code
When you work on the Bumble code itself, and run some of the tests or example apps, or import the
module in your own code, you typically either install the package from source in "development mode" as described above, or you may choose to skip the install phase.
@@ -106,3 +94,13 @@ Setting `PYTHONPATH` locally with each command would look something like:
```
$ PYTHONPATH=. python examples/run_advertiser.py examples/device1.json serial:/dev/tty.usbmodem0006839912171
```
# Where To Go Next
Once you've installed or downloaded Bumble, you can either start using some of the
[Bundled apps and tools](apps_and_tools/index.md), or look at the [examples](examples/index.md)
to get a feel for how to use the APIs, and start writing your own applications.
Depending on the use case you're interested in exploring, you may need to use a physical Bluetooth
controller, like a USB dongle or a board with a Bluetooth radio. Visit the [Hardware page](hardware/index.md)
for more information on using a physical radio, and/or the [Transports page](transports/index.md) for more
details on interfacing with either hardware modules or virtual controllers over various transports.

View File

@@ -46,7 +46,7 @@ through it with Android applications as well as system-managed profiles.
To connect a Bumble host stack to a Root Canal virtual controller instance, use
the bumble `android-emulator` transport in `host` mode (the default).
!!! example "Running the example GATT server connected to the emulator"
!!! example "Run the example GATT server connected to the emulator"
``` shell
$ python run_gatt_server.py device1.json android-emulator
```
@@ -57,7 +57,7 @@ This is an advanced use case, which may not be officially supported, but should
versions of the emulator.
You will likely need to start the emulator from the command line, in order to specify the `-forward-vhci` option (unless the emulator offers a way to control that feature from a user/ui menu).
!!! example "Launching the emulator with VHCI forwarding"
!!! example "Launch the emulator with VHCI forwarding"
In this example, we launch an emulator AVD named "Tiramisu"
```shell
$ emulator -forward-vhci -avd Tiramisu
@@ -70,10 +70,20 @@ You will likely need to start the emulator from the command line, in order to sp
To connect a virtual controller to the Android Bluetooth stack, use the bumble `android-emulator` transport in `controller` mode. For example, using the default gRPC port, the transport name would be: `android-emulator:mode=controller`.
!!! example "connecting the Android emulator to the first USB Bluetooth dongle, using the `hci_bridge` application"
!!! example "Connect the Android emulator to the first USB Bluetooth dongle, using the `hci_bridge` application"
```shell
$ bumble-hci-bridge android-emulator:mode=controller usb:0
```
## Other Tools
The `show` application that's included with Bumble can be used to parse and pretty-print the HCI packets
from an Android HCI "snoop log" (see [this page](https://source.android.com/devices/bluetooth/verifying_debugging)
for details on how to obtain HCI snoop logs from an Android device).
Use the `--format snoop` option to specify that the file is in that specific format.
!!! example "Analyze an Android HCI snoop log file"
```shell
$ bumble-show --format snoop btsnoop_hci.log
```

View File

@@ -26,7 +26,6 @@ packages = bumble, bumble.transport, bumble.apps, bumble.apps.link_relay
package_dir =
bumble = bumble
bumble.apps = apps
bumble.apps.link_relay = apps/link_relay
install_requires =
aioconsole >= 0.4.1
ansicolors >= 1.1
@@ -37,6 +36,7 @@ install_requires =
grpcio >= 1.46; platform_system!='Emscripten'
libusb1 >= 2.0.1; platform_system!='Emscripten'
prompt_toolkit >= 3.0.16; platform_system!='Emscripten'
protobuf >= 3.12.4
pyee >= 8.2.2
pyserial-asyncio >= 0.5; platform_system!='Emscripten'
pyserial >= 3.5; platform_system!='Emscripten'
@@ -65,4 +65,4 @@ development =
documentation =
mkdocs >= 1.2.3
mkdocs-material >= 8.1.9
mkdocstrings >= 0.17.0
mkdocstrings[python] >= 0.19.0

View File

@@ -1,2 +1,3 @@
[pytest]
junit_logging = all
asyncio_mode = auto

View File

@@ -208,11 +208,11 @@ async def test_self_smp():
self.peer_delegate = None
self.number = asyncio.get_running_loop().create_future()
async def compare_numbers(self, number):
async def compare_numbers(self, number, digits):
if self.peer_delegate is None:
logger.warn(f'[{self.name}] no peer delegate')
return False
await self.display_number(number)
await self.display_number(number, digits=6)
logger.debug(f'[{self.name}] waiting for peer number')
peer_number = await self.peer_delegate.number
logger.debug(f'[{self.name}] comparing numbers: {number} and {peer_number}')
@@ -231,7 +231,7 @@ async def test_self_smp():
logger.debug(f'[{self.name}] returning number: {peer_number}')
return peer_number
async def display_number(self, number):
async def display_number(self, number, digits):
logger.debug(f'[{self.name}] displaying number: {number}')
self.number.set_result(number)
@@ -293,7 +293,7 @@ async def test_self_smp_wrong_pin():
def __init__(self):
super().__init__(PairingDelegate.DISPLAY_OUTPUT_AND_KEYBOARD_INPUT)
async def compare_numbers(self, number):
async def compare_numbers(self, number, digits):
return False
wrong_pin_pairing_config = PairingConfig(delegate = WrongPinDelegate())