Merge remote-tracking branch 'aosp/upstream-main' into master am: b74fa15783
Original change: https://android-review.googlesource.com/c/platform/external/python/bumble/+/2319303
Change-Id: If42b3697e4df9212e02a8c578ac68bfd1cdc1019
Signed-off-by: Automerger Merge Worker <[email protected]>
diff --git a/.github/workflows/python-build-test.yml b/.github/workflows/python-build-test.yml
index ca19386..5d955c3 100644
--- a/.github/workflows/python-build-test.yml
+++ b/.github/workflows/python-build-test.yml
@@ -21,7 +21,7 @@
- name: Get history and tags for SCM versioning to work
run: |
git fetch --prune --unshallow
- git fetch --depth=1 origin +refs/tags/*:refs/tags/*
+ git fetch --depth=1 origin +refs/tags/*:refs/tags/*
- name: Set up Python 3.10
uses: actions/setup-python@v3
with:
@@ -30,9 +30,9 @@
run: |
python -m pip install --upgrade pip
python -m pip install ".[build,test,development,documentation]"
- - name: Test with pytest
+ - name: Test
run: |
- pytest
+ invoke test
- name: Build
run: |
inv build
diff --git a/apps/console.py b/apps/console.py
index 7ddbae4..e0f93b0 100644
--- a/apps/console.py
+++ b/apps/console.py
@@ -20,19 +20,26 @@
# Imports
# -----------------------------------------------------------------------------
import asyncio
-from bumble.hci import HCI_Constant
-import os
-import os.path
import logging
-import click
+import os
+import random
+import re
from collections import OrderedDict
+
+import click
import colors
-from bumble.core import UUID, AdvertisingData
-from bumble.device import Device, Connection, Peer
+from bumble.core import UUID, AdvertisingData, TimeoutError, BT_LE_TRANSPORT
+from bumble.device import ConnectionParametersPreferences, Device, Connection, Peer
from bumble.utils import AsyncRunner
from bumble.transport import open_transport_or_link
from bumble.gatt import Characteristic
+from bumble.hci import (
+ HCI_Constant,
+ HCI_LE_1M_PHY,
+ HCI_LE_2M_PHY,
+ HCI_LE_CODED_PHY,
+)
from prompt_toolkit import Application
from prompt_toolkit.history import FileHistory
@@ -43,6 +50,7 @@
from prompt_toolkit.filters import Condition
from prompt_toolkit.widgets import TextArea, Frame
from prompt_toolkit.widgets.toolbars import FormattedTextToolbar
+from prompt_toolkit.data_structures import Point
from prompt_toolkit.layout import (
Layout,
HSplit,
@@ -51,17 +59,20 @@
Float,
FormattedTextControl,
FloatContainer,
- ConditionalContainer
+ ConditionalContainer,
+ Dimension
)
# -----------------------------------------------------------------------------
# Constants
# -----------------------------------------------------------------------------
-BUMBLE_USER_DIR = os.path.expanduser('~/.bumble')
-DEFAULT_PROMPT_HEIGHT = 20
-DEFAULT_RSSI_BAR_WIDTH = 20
-DISPLAY_MIN_RSSI = -100
-DISPLAY_MAX_RSSI = -30
+BUMBLE_USER_DIR = os.path.expanduser('~/.bumble')
+DEFAULT_RSSI_BAR_WIDTH = 20
+DEFAULT_CONNECTION_TIMEOUT = 30.0
+DISPLAY_MIN_RSSI = -100
+DISPLAY_MAX_RSSI = -30
+RSSI_MONITOR_INTERVAL = 5.0 # Seconds
+
# -----------------------------------------------------------------------------
# Globals
@@ -70,15 +81,56 @@
# -----------------------------------------------------------------------------
+# Utils
+# -----------------------------------------------------------------------------
+
+def le_phy_name(phy_id):
+ return {
+ HCI_LE_1M_PHY: '1M',
+ HCI_LE_2M_PHY: '2M',
+ HCI_LE_CODED_PHY: 'CODED'
+ }.get(phy_id, HCI_Constant.le_phy_name(phy_id))
+
+
+def rssi_bar(rssi):
+ blocks = ['', '▏', '▎', '▍', '▌', '▋', '▊', '▉']
+ bar_width = (rssi - DISPLAY_MIN_RSSI) / (DISPLAY_MAX_RSSI - DISPLAY_MIN_RSSI)
+ bar_width = min(max(bar_width, 0), 1)
+ bar_ticks = int(bar_width * DEFAULT_RSSI_BAR_WIDTH * 8)
+ bar_blocks = ('█' * int(bar_ticks / 8)) + blocks[bar_ticks % 8]
+ return f'{rssi:4} {bar_blocks}'
+
+
+def parse_phys(phys):
+ if phys.lower() == '*':
+ return None
+ else:
+ phy_list = []
+ elements = phys.lower().split(',')
+ for element in elements:
+ if element == '1m':
+ phy_list.append(HCI_LE_1M_PHY)
+ elif element == '2m':
+ phy_list.append(HCI_LE_2M_PHY)
+ elif element == 'coded':
+ phy_list.append(HCI_LE_CODED_PHY)
+ else:
+ raise ValueError('invalid PHY name')
+ return phy_list
+
+
+# -----------------------------------------------------------------------------
# Console App
# -----------------------------------------------------------------------------
class ConsoleApp:
def __init__(self):
- self.known_addresses = set()
+ self.known_addresses = set()
self.known_attributes = []
- self.device = None
- self.connected_peer = None
- self.top_tab = 'scan'
+ self.device = None
+ self.connected_peer = None
+ self.top_tab = 'device'
+ self.monitor_rssi = False
+ self.connection_rssi = None
style = Style.from_dict({
'output-field': 'bg:#000044 #ffffff',
@@ -100,17 +152,26 @@
return NestedCompleter.from_nested_dict({
'scan': {
'on': None,
- 'off': None
+ 'off': None,
+ 'clear': None
},
'advertise': {
'on': None,
'off': None
},
+ 'rssi': {
+ 'on': None,
+ 'off': None
+ },
'show': {
'scan': None,
'services': None,
'attributes': None,
- 'log': None
+ 'log': None,
+ 'device': None
+ },
+ 'filter': {
+ 'address': None,
},
'connect': LiveCompleter(self.known_addresses),
'update-parameters': None,
@@ -120,10 +181,17 @@
'services': None,
'attributes': None
},
+ 'request-mtu': None,
'read': LiveCompleter(self.known_attributes),
'write': LiveCompleter(self.known_attributes),
'subscribe': LiveCompleter(self.known_attributes),
'unsubscribe': LiveCompleter(self.known_attributes),
+ 'set-phy': {
+ '1m': None,
+ '2m': None,
+ 'coded': None
+ },
+ 'set-default-phy': None,
'quit': None,
'exit': None
})
@@ -139,14 +207,17 @@
self.input_field.accept_handler = self.accept_input
- self.output_height = 7
+ self.output_height = Dimension(min=7, max=7, weight=1)
self.output_lines = []
- self.output = FormattedTextControl()
+ self.output = FormattedTextControl(get_cursor_position=lambda: Point(0, max(0, len(self.output_lines) - 1)))
+ self.output_max_lines = 20
self.scan_results_text = FormattedTextControl()
self.services_text = FormattedTextControl()
self.attributes_text = FormattedTextControl()
- self.log_text = FormattedTextControl()
- self.log_height = 20
+ self.device_text = FormattedTextControl()
+ self.log_text = FormattedTextControl(get_cursor_position=lambda: Point(0, max(0, len(self.log_lines) - 1)))
+ self.log_height = Dimension(min=7, weight=4)
+ self.log_max_lines = 100
self.log_lines = []
container = HSplit([
@@ -163,11 +234,14 @@
filter=Condition(lambda: self.top_tab == 'attributes')
),
ConditionalContainer(
- Frame(Window(self.log_text), title='Log'),
+ Frame(Window(self.log_text, height=self.log_height), title='Log'),
filter=Condition(lambda: self.top_tab == 'log')
),
- Frame(Window(self.output), height=self.output_height),
- # HorizontalLine(),
+ ConditionalContainer(
+ Frame(Window(self.device_text), title='Device'),
+ filter=Condition(lambda: self.top_tab == 'device')
+ ),
+ Frame(Window(self.output, height=self.output_height)),
FormattedTextToolbar(text=self.get_status_bar_text, style='reverse'),
self.input_field
])
@@ -199,17 +273,26 @@
)
async def run_async(self, device_config, transport):
+ rssi_monitoring_task = asyncio.create_task(self.rssi_monitor_loop())
+
async with await open_transport_or_link(transport) as (hci_source, hci_sink):
if device_config:
self.device = Device.from_config_file_with_hci(device_config, hci_source, hci_sink)
else:
- self.device = Device.with_hci('Bumble', 'F0:F1:F2:F3:F4:F5', hci_source, hci_sink)
+ random_address = f"{random.randint(192,255):02X}" # address is static random
+ for c in random.sample(range(255), 5):
+ random_address += f":{c:02X}"
+ self.append_to_log(f"Setting random address: {random_address}")
+ self.device = Device.with_hci('Bumble', random_address, hci_source, hci_sink)
self.device.listener = DeviceListener(self)
await self.device.power_on()
+ self.show_device(self.device)
# Run the UI
await self.ui.run_async()
+ rssi_monitoring_task.cancel()
+
def add_known_address(self, address):
self.known_addresses.add(address)
@@ -224,22 +307,33 @@
connection_state = 'NONE'
encryption_state = ''
+ att_mtu = ''
+ rssi = '' if self.connection_rssi is None else rssi_bar(self.connection_rssi)
if self.device:
- if self.device.is_connecting:
+ if self.device.is_le_connecting:
connection_state = 'CONNECTING'
elif self.connected_peer:
connection = self.connected_peer.connection
- connection_parameters = f'{connection.parameters.connection_interval}/{connection.parameters.connection_latency}/{connection.parameters.supervision_timeout}'
- connection_state = f'{connection.peer_address} {connection_parameters} {connection.data_length}'
+ connection_parameters = f'{connection.parameters.connection_interval}/{connection.parameters.peripheral_latency}/{connection.parameters.supervision_timeout}'
+ if connection.transport == BT_LE_TRANSPORT:
+ phy_state = f' RX={le_phy_name(connection.phy.rx_phy)}/TX={le_phy_name(connection.phy.tx_phy)}'
+ else:
+ phy_state = ''
+ connection_state = f'{connection.peer_address} {connection_parameters} {connection.data_length}{phy_state}'
encryption_state = 'ENCRYPTED' if connection.is_encrypted else 'NOT ENCRYPTED'
+ att_mtu = f'ATT_MTU: {connection.att_mtu}'
return [
('ansigreen', f' SCAN: {scanning} '),
('', ' '),
('ansiblue', f' CONNECTION: {connection_state} '),
('', ' '),
- ('ansimagenta', f' {encryption_state} ')
+ ('ansimagenta', f' {encryption_state} '),
+ ('', ' '),
+ ('ansicyan', f' {att_mtu} '),
+ ('', ' '),
+ ('ansiyellow', f' {rssi} ')
]
def show_error(self, title, details = None):
@@ -274,7 +368,7 @@
self.services_text.text = lines
self.ui.invalidate()
- async def show_attributes(self, attributes):
+ def show_attributes(self, attributes):
lines = []
for attribute in attributes:
@@ -283,10 +377,48 @@
self.attributes_text.text = lines
self.ui.invalidate()
+ def show_device(self, device):
+ lines = []
+
+ lines.append(('ansicyan', 'Name: '))
+ lines.append(('', f'{device.name}\n'))
+ lines.append(('ansicyan', 'Public Address: '))
+ lines.append(('', f'{device.public_address}\n'))
+ lines.append(('ansicyan', 'Random Address: '))
+ lines.append(('', f'{device.random_address}\n'))
+ lines.append(('ansicyan', 'LE Enabled: '))
+ lines.append(('', f'{device.le_enabled}\n'))
+ lines.append(('ansicyan', 'Classic Enabled: '))
+ lines.append(('', f'{device.classic_enabled}\n'))
+ lines.append(('ansicyan', 'Classic SC Enabled: '))
+ lines.append(('', f'{device.classic_sc_enabled}\n'))
+ lines.append(('ansicyan', 'Classic SSP Enabled: '))
+ lines.append(('', f'{device.classic_ssp_enabled}\n'))
+ lines.append(('ansicyan', 'Classic Class: '))
+ lines.append(('', f'{device.class_of_device}\n'))
+ lines.append(('ansicyan', 'Discoverable: '))
+ lines.append(('', f'{device.discoverable}\n'))
+ lines.append(('ansicyan', 'Connectable: '))
+ lines.append(('', f'{device.connectable}\n'))
+ lines.append(('ansicyan', 'Advertising Data: '))
+ lines.append(('', f'{device.advertising_data}\n'))
+ lines.append(('ansicyan', 'Scan Response Data: '))
+ lines.append(('', f'{device.scan_response_data}\n'))
+ advertising_interval = (
+ device.advertising_interval_min
+ if device.advertising_interval_min == device.advertising_interval_max
+ else f"{device.advertising_interval_min} to {device.advertising_interval_max}"
+ )
+ lines.append(('ansicyan', 'Advertising Interval: '))
+ lines.append(('', f'{advertising_interval}\n'))
+
+ self.device_text.text = lines
+ self.ui.invalidate()
+
def append_to_output(self, line, invalidate=True):
if type(line) is str:
line = [('', line)]
- self.output_lines = self.output_lines[-(self.output_height - 3):]
+ self.output_lines = self.output_lines[-self.output_max_lines:]
self.output_lines.append(line)
formatted_text = []
for line in self.output_lines:
@@ -298,7 +430,7 @@
def append_to_log(self, lines, invalidate=True):
self.log_lines.extend(lines.split('\n'))
- self.log_lines = self.log_lines[-(self.log_height - 3):]
+ self.log_lines = self.log_lines[-self.log_max_lines:]
self.log_text.text = ANSI('\n'.join(self.log_lines))
if invalidate:
self.ui.invalidate()
@@ -311,7 +443,10 @@
# Discover all services, characteristics and descriptors
self.append_to_output('discovering services...')
await self.connected_peer.discover_services()
- self.append_to_output(f'found {len(self.connected_peer.services)} services, discovering charateristics...')
+ self.append_to_output(
+ f'found {len(self.connected_peer.services)} services,'
+ ' discovering characteristics...'
+ )
await self.connected_peer.discover_characteristics()
self.append_to_output('found characteristics, discovering descriptors...')
for service in self.connected_peer.services:
@@ -331,7 +466,7 @@
attributes = await self.connected_peer.discover_attributes()
self.append_to_output(f'discovered {len(attributes)} attributes...')
- await self.show_attributes(attributes)
+ self.show_attributes(attributes)
def find_characteristic(self, param):
parts = param.split('.')
@@ -351,6 +486,12 @@
if characteristic.handle == attribute_handle:
return characteristic
+ async def rssi_monitor_loop(self):
+ while True:
+ if self.monitor_rssi and self.connected_peer:
+ self.connection_rssi = await self.connected_peer.connection.get_rssi()
+ await asyncio.sleep(RSSI_MONITOR_INTERVAL)
+
async def command(self, command):
try:
(keyword, *params) = command.strip().split(' ')
@@ -372,46 +513,96 @@
else:
await self.device.start_scanning()
elif params[0] == 'on':
+ if len(params) == 2:
+ if not params[1].startswith("filter="):
+ self.show_error('invalid syntax', 'expected address filter=key1:value1,key2:value,... available filters: address')
+ # regex: (word):(any char except ,)
+ matches = re.findall(r"(\w+):([^,]+)", params[1])
+ for match in matches:
+ if match[0] == "address":
+ self.device.listener.address_filter = match[1]
+
await self.device.start_scanning()
self.top_tab = 'scan'
elif params[0] == 'off':
await self.device.stop_scanning()
+ elif params[0] == 'clear':
+ self.device.listener.scan_results.clear()
+ self.known_addresses.clear()
+ self.show_scan_results(self.device.listener.scan_results)
else:
self.show_error('unsupported arguments for scan command')
+ async def do_rssi(self, params):
+ if len(params) == 0:
+ # Toggle monitoring
+ self.monitor_rssi = not self.monitor_rssi
+ elif params[0] == 'on':
+ self.monitor_rssi = True
+ elif params[0] == 'off':
+ self.monitor_rssi = False
+ else:
+ self.show_error('unsupported arguments for rssi command')
+
async def do_connect(self, params):
- if len(params) != 1:
- self.show_error('invalid syntax', 'expected connect <address>')
+ if len(params) != 1 and len(params) != 2:
+ self.show_error('invalid syntax', 'expected connect <address> [phys]')
return
+ if len(params) == 1:
+ phys = None
+ else:
+ phys = parse_phys(params[1])
+ if phys is None:
+ connection_parameters_preferences = None
+ else:
+ connection_parameters_preferences = {
+ phy: ConnectionParametersPreferences()
+ for phy in phys
+ }
+
+ if self.device.is_scanning:
+ await self.device.stop_scanning()
+
self.append_to_output('connecting...')
- await self.device.connect(params[0])
- self.top_tab = 'services'
+
+ try:
+ await self.device.connect(
+ params[0],
+ connection_parameters_preferences=connection_parameters_preferences,
+ timeout=DEFAULT_CONNECTION_TIMEOUT
+ )
+ self.top_tab = 'services'
+ except TimeoutError:
+ self.show_error('connection timed out')
async def do_disconnect(self, params):
- if not self.connected_peer:
- self.show_error('not connected')
- return
+ if self.device.is_le_connecting:
+ await self.device.cancel_connection()
+ else:
+ if not self.connected_peer:
+ self.show_error('not connected')
+ return
- await self.connected_peer.connection.disconnect()
+ await self.connected_peer.connection.disconnect()
async def do_update_parameters(self, params):
if len(params) != 1 or len(params[0].split('/')) != 3:
- self.show_error('invalid syntax', 'expected update-parameters <interval-min>-<interval-max>/<latency>/<supervision>')
+ self.show_error('invalid syntax', 'expected update-parameters <interval-min>-<interval-max>/<max-latency>/<supervision>')
return
if not self.connected_peer:
self.show_error('not connected')
return
- connection_intervals, connection_latency, supervision_timeout = params[0].split('/')
+ connection_intervals, max_latency, supervision_timeout = params[0].split('/')
connection_interval_min, connection_interval_max = [int(x) for x in connection_intervals.split('-')]
- connection_latency = int(connection_latency)
+ max_latency = int(max_latency)
supervision_timeout = int(supervision_timeout)
await self.connected_peer.connection.update_parameters(
connection_interval_min,
connection_interval_max,
- connection_latency,
+ max_latency,
supervision_timeout
)
@@ -438,10 +629,29 @@
async def do_show(self, params):
if params:
- if params[0] in {'scan', 'services', 'attributes', 'log'}:
+ if params[0] in {'scan', 'services', 'attributes', 'log', 'device'}:
self.top_tab = params[0]
self.ui.invalidate()
+ async def do_get_phy(self, params):
+ if not self.connected_peer:
+ self.show_error('not connected')
+ return
+
+ phy = await self.connected_peer.connection.get_phy()
+ self.append_to_output(f'PHY: RX={HCI_Constant.le_phy_name(phy[0])}, TX={HCI_Constant.le_phy_name(phy[1])}')
+
+ async def do_request_mtu(self, params):
+ if len(params) != 1:
+ self.show_error('invalid syntax', 'expected request-mtu <mtu>')
+ return
+
+ if not self.connected_peer:
+ self.show_error('not connected')
+ return
+
+ await self.connected_peer.request_mtu(int(params[0]))
+
async def do_discover(self, params):
if not params:
self.show_error('invalid syntax', 'expected discover services|attributes')
@@ -454,14 +664,14 @@
await self.discover_attributes()
async def do_read(self, params):
- if not self.connected_peer:
- self.show_error('not connected')
- return
-
if len(params) != 1:
self.show_error('invalid syntax', 'expected read <attribute>')
return
+ if not self.connected_peer:
+ self.show_error('not connected')
+ return
+
characteristic = self.find_characteristic(params[0])
if characteristic is None:
self.show_error('no such characteristic')
@@ -530,12 +740,54 @@
await characteristic.unsubscribe()
+ async def do_set_phy(self, params):
+ if len(params) != 1:
+ self.show_error('invalid syntax', 'expected set-phy <tx_rx_phys>|<tx_phys>/<rx_phys>')
+ return
+
+ if not self.connected_peer:
+ self.show_error('not connected')
+ return
+
+ if '/' in params[0]:
+ tx_phys, rx_phys = params[0].split('/')
+ else:
+ tx_phys = params[0]
+ rx_phys = tx_phys
+
+ await self.connected_peer.connection.set_phy(
+ tx_phys=parse_phys(tx_phys),
+ rx_phys=parse_phys(rx_phys)
+ )
+
+ async def do_set_default_phy(self, params):
+ if len(params) != 1:
+ self.show_error('invalid syntax', 'expected set-default-phy <tx_rx_phys>|<tx_phys>/<rx_phys>')
+ return
+
+ if '/' in params[0]:
+ tx_phys, rx_phys = params[0].split('/')
+ else:
+ tx_phys = params[0]
+ rx_phys = tx_phys
+
+ await self.device.set_default_phy(
+ tx_phys=parse_phys(tx_phys),
+ rx_phys=parse_phys(rx_phys)
+ )
+
async def do_exit(self, params):
self.ui.exit()
async def do_quit(self, params):
self.ui.exit()
+ async def do_filter(self, params):
+ if params[0] == "address":
+ if len(params) != 2:
+ self.show_error('invalid syntax', 'expected filter address <pattern>')
+ return
+ self.device.listener.address_filter = params[1]
# -----------------------------------------------------------------------------
# Device and Connection Listener
@@ -544,16 +796,38 @@
def __init__(self, app):
self.app = app
self.scan_results = OrderedDict()
+ self.address_filter = None
+
+ @property
+ def address_filter(self):
+ return self._address_filter
+
+ @address_filter.setter
+ def address_filter(self, filter_addr):
+ if filter_addr is None:
+ self._address_filter = re.compile(r".*")
+ else:
+ self._address_filter = re.compile(filter_addr)
+ self.scan_results = OrderedDict(filter(lambda x: self.filter_address_match(x), self.scan_results))
+ self.app.show_scan_results(self.scan_results)
+
+ def filter_address_match(self, address):
+ """
+ Returns true if an address matches the filter
+ """
+ return bool(self.address_filter.match(address))
@AsyncRunner.run_in_task()
async def on_connection(self, connection):
self.app.connected_peer = Peer(connection)
+ self.app.connection_rssi = None
self.app.append_to_output(f'connected to {self.app.connected_peer}')
connection.listener = self
def on_disconnection(self, reason):
self.app.append_to_output(f'disconnected from {self.app.connected_peer}, reason: {HCI_Constant.error_name(reason)}')
self.app.connected_peer = None
+ self.app.connection_rssi = None
def on_connection_parameters_update(self):
self.app.append_to_output(f'connection parameters update: {self.app.connected_peer.connection.parameters}')
@@ -570,16 +844,19 @@
def on_connection_data_length_change(self):
self.app.append_to_output(f'connection data length change: {self.app.connected_peer.connection.data_length}')
- def on_advertisement(self, address, ad_data, rssi, connectable):
- entry_key = f'{address}/{address.address_type}'
+ def on_advertisement(self, advertisement):
+ if not self.filter_address_match(str(advertisement.address)):
+ return
+
+ entry_key = f'{advertisement.address}/{advertisement.address.address_type}'
entry = self.scan_results.get(entry_key)
if entry:
- entry.ad_data = ad_data
- entry.rssi = rssi
- entry.connectable = connectable
+ entry.ad_data = advertisement.data
+ entry.rssi = advertisement.rssi
+ entry.connectable = advertisement.is_connectable
else:
- self.app.add_known_address(str(address))
- self.scan_results[entry_key] = ScanResult(address, address.address_type, ad_data, rssi, connectable)
+ self.app.add_known_address(str(advertisement.address))
+ self.scan_results[entry_key] = ScanResult(advertisement.address, advertisement.address.address_type, advertisement.data, advertisement.rssi, advertisement.is_connectable)
self.app.show_scan_results(self.scan_results)
@@ -603,9 +880,9 @@
else:
type_color = colors.cyan
- name = self.ad_data.get(AdvertisingData.COMPLETE_LOCAL_NAME)
+ name = self.ad_data.get(AdvertisingData.COMPLETE_LOCAL_NAME, raw=True)
if name is None:
- name = self.ad_data.get(AdvertisingData.SHORTENED_LOCAL_NAME)
+ name = self.ad_data.get(AdvertisingData.SHORTENED_LOCAL_NAME, raw=True)
if name:
# Convert to string
try:
@@ -616,12 +893,7 @@
name = ''
# RSSI bar
- blocks = ['', '▏', '▎', '▍', '▌', '▋', '▊', '▉']
- bar_width = (self.rssi - DISPLAY_MIN_RSSI) / (DISPLAY_MAX_RSSI - DISPLAY_MIN_RSSI)
- bar_width = min(max(bar_width, 0), 1)
- bar_ticks = int(bar_width * DEFAULT_RSSI_BAR_WIDTH * 8)
- bar_blocks = ('█' * int(bar_ticks / 8)) + blocks[bar_ticks % 8]
- bar_string = f'{self.rssi} {bar_blocks}'
+ bar_string = rssi_bar(self.rssi)
bar_padding = ' ' * (DEFAULT_RSSI_BAR_WIDTH + 5 - len(bar_string))
return f'{address_color(str(self.address))} [{type_color(address_type_string)}] {bar_string} {bar_padding} {name}'
@@ -633,6 +905,7 @@
def __init__(self, app):
super().__init__()
self.app = app
+ self.setFormatter(logging.Formatter('[%(asctime)s][%(levelname)s] %(message)s'))
def emit(self, record):
message = self.format(record)
@@ -657,6 +930,7 @@
# logging.basicConfig(level = 'FATAL')
# logging.basicConfig(level = 'DEBUG')
root_logger = logging.getLogger()
+
root_logger.addHandler(LogHandler(app))
root_logger.setLevel(logging.DEBUG)
diff --git a/apps/controller_info.py b/apps/controller_info.py
index 74d4550..b65caab 100644
--- a/apps/controller_info.py
+++ b/apps/controller_info.py
@@ -25,15 +25,21 @@
from bumble.core import name_or_number
from bumble.hci import (
map_null_terminated_utf8_string,
- HCI_LE_SUPPORTED_FEATURES_NAMES,
HCI_SUCCESS,
+ HCI_LE_SUPPORTED_FEATURES_NAMES,
HCI_VERSION_NAMES,
LMP_VERSION_NAMES,
HCI_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,
- HCI_READ_LOCAL_NAME_COMMAND
+ HCI_LE_READ_MAXIMUM_DATA_LENGTH_COMMAND,
+ HCI_LE_Read_Maximum_Data_Length_Command,
+ HCI_LE_READ_NUMBER_OF_SUPPORTED_ADVERTISING_SETS_COMMAND,
+ HCI_LE_Read_Number_Of_Supported_Advertising_Sets_Command,
+ HCI_LE_READ_MAXIMUM_ADVERTISING_DATA_LENGTH_COMMAND,
+ HCI_LE_Read_Maximum_Advertising_Data_Length_Command
)
from bumble.host import Host
from bumble.transport import open_transport_or_link
@@ -57,6 +63,39 @@
# -----------------------------------------------------------------------------
async def get_le_info(host):
print()
+
+ if host.supports_command(HCI_LE_READ_NUMBER_OF_SUPPORTED_ADVERTISING_SETS_COMMAND):
+ response = await host.send_command(HCI_LE_Read_Number_Of_Supported_Advertising_Sets_Command())
+ if response.return_parameters.status == HCI_SUCCESS:
+ print(
+ color('LE Number Of Supported Advertising Sets:', 'yellow'),
+ response.return_parameters.num_supported_advertising_sets,
+ '\n'
+ )
+
+ if host.supports_command(HCI_LE_READ_MAXIMUM_ADVERTISING_DATA_LENGTH_COMMAND):
+ response = await host.send_command(HCI_LE_Read_Maximum_Advertising_Data_Length_Command())
+ if response.return_parameters.status == HCI_SUCCESS:
+ print(
+ color('LE Maximum Advertising Data Length:', 'yellow'),
+ response.return_parameters.max_advertising_data_length,
+ '\n'
+ )
+
+ if host.supports_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:
+ print(
+ color('Maximum Data Length:', 'yellow'),
+ (
+ f'tx:{response.return_parameters.supported_max_tx_octets}/'
+ f'{response.return_parameters.supported_max_tx_time}, '
+ f'rx:{response.return_parameters.supported_max_rx_octets}/'
+ f'{response.return_parameters.supported_max_rx_time}'
+ ),
+ '\n'
+ )
+
print(color('LE Features:', 'yellow'))
for feature in host.supported_le_features:
print(' ', name_or_number(HCI_LE_SUPPORTED_FEATURES_NAMES, feature))
diff --git a/apps/gg_bridge.py b/apps/gg_bridge.py
index d524913..ac3df8d 100644
--- a/apps/gg_bridge.py
+++ b/apps/gg_bridge.py
@@ -17,13 +17,14 @@
# -----------------------------------------------------------------------------
import asyncio
import os
+import struct
import logging
import click
from colors import color
from bumble.device import Device, Peer
from bumble.core import AdvertisingData
-from bumble.gatt import Service, Characteristic
+from bumble.gatt import Service, Characteristic, CharacteristicValue
from bumble.utils import AsyncRunner
from bumble.transport import open_transport_or_link
from bumble.hci import HCI_Constant
@@ -41,13 +42,59 @@
# -----------------------------------------------------------------------------
-class GattlinkHubBridge(Device.Listener):
+class GattlinkL2capEndpoint:
def __init__(self):
- self.peer = None
- self.rx_socket = None
- self.tx_socket = None
- self.rx_characteristic = None
- self.tx_characteristic = None
+ self.l2cap_channel = None
+ self.l2cap_packet = b''
+ self.l2cap_packet_size = 0
+
+ # Called when an L2CAP SDU has been received
+ def on_coc_sdu(self, sdu):
+ print(color(f'<<< [L2CAP SDU]: {len(sdu)} bytes', 'cyan'))
+ while len(sdu):
+ if self.l2cap_packet_size == 0:
+ # Expect a new packet
+ self.l2cap_packet_size = sdu[0] + 1
+ sdu = sdu[1:]
+ else:
+ bytes_needed = self.l2cap_packet_size - len(self.l2cap_packet)
+ chunk = min(bytes_needed, len(sdu))
+ self.l2cap_packet += sdu[:chunk]
+ sdu = sdu[chunk:]
+ if len(self.l2cap_packet) == self.l2cap_packet_size:
+ self.on_l2cap_packet(self.l2cap_packet)
+ self.l2cap_packet = b''
+ self.l2cap_packet_size = 0
+
+
+# -----------------------------------------------------------------------------
+class GattlinkHubBridge(GattlinkL2capEndpoint, Device.Listener):
+ def __init__(self, device, peer_address):
+ super().__init__()
+ self.device = device
+ self.peer_address = peer_address
+ self.peer = None
+ self.tx_socket = None
+ self.rx_characteristic = None
+ self.tx_characteristic = None
+ self.l2cap_psm_characteristic = None
+
+ device.listener = self
+
+ async def start(self):
+ # Connect to the peer
+ print(f'=== Connecting to {self.peer_address}...')
+ await self.device.connect(self.peer_address)
+
+ async def connect_l2cap(self, psm):
+ print(color(f'### Connecting with L2CAP on PSM = {psm}', 'yellow'))
+ try:
+ self.l2cap_channel = await self.peer.connection.open_l2cap_channel(psm)
+ print(color('*** Connected', 'yellow'), self.l2cap_channel)
+ self.l2cap_channel.sink = self.on_coc_sdu
+
+ except Exception as error:
+ print(color(f'!!! Connection failed: {error}', 'red'))
@AsyncRunner.run_in_task()
async def on_connection(self, connection):
@@ -80,15 +127,24 @@
self.rx_characteristic = characteristic
elif characteristic.uuid == GG_GATTLINK_TX_CHARACTERISTIC_UUID:
self.tx_characteristic = characteristic
+ elif characteristic.uuid == GG_GATTLINK_L2CAP_CHANNEL_PSM_CHARACTERISTIC_UUID:
+ self.l2cap_psm_characteristic = characteristic
print('RX:', self.rx_characteristic)
print('TX:', self.tx_characteristic)
+ print('PSM:', self.l2cap_psm_characteristic)
- # Subscribe to TX
- if self.tx_characteristic:
+ if self.l2cap_psm_characteristic:
+ # Subscribe to and then read the PSM value
+ await self.peer.subscribe(self.l2cap_psm_characteristic, self.on_l2cap_psm_received)
+ psm_bytes = await self.peer.read_value(self.l2cap_psm_characteristic)
+ psm = struct.unpack('<H', psm_bytes)[0]
+ await self.connect_l2cap(psm)
+ elif self.tx_characteristic:
+ # Subscribe to TX
await self.peer.subscribe(self.tx_characteristic, self.on_tx_received)
print(color('=== Subscribed to Gattlink TX', 'yellow'))
else:
- print(color('!!! Gattlink TX not found', 'red'))
+ print(color('!!! No Gattlink TX or PSM found', 'red'))
def on_connection_failure(self, error):
print(color(f'!!! Connection failed: {error}'))
@@ -99,31 +155,23 @@
self.rx_characteristic = None
self.peer = None
+ # Called when an L2CAP packet has been received
+ def on_l2cap_packet(self, packet):
+ print(color(f'<<< [L2CAP PACKET]: {len(packet)} bytes', 'cyan'))
+ print(color('>>> [UDP]', 'magenta'))
+ self.tx_socket.sendto(packet)
+
# Called by the GATT client when a notification is received
def on_tx_received(self, value):
- print(color('>>> TX:', 'magenta'), value.hex())
+ print(color(f'<<< [GATT TX]: {len(value)} bytes', 'cyan'))
if self.tx_socket:
+ print(color('>>> [UDP]', 'magenta'))
self.tx_socket.sendto(value)
# Called by asyncio when the UDP socket is created
- def connection_made(self, transport):
- pass
-
- # Called by asyncio when a UDP datagram is received
- def datagram_received(self, data, address):
- print(color('<<< RX:', 'magenta'), data.hex())
-
- # TODO: use a queue instead of creating a task everytime
- if self.peer and self.rx_characteristic:
- asyncio.create_task(self.peer.write_value(self.rx_characteristic, data))
-
-
-# -----------------------------------------------------------------------------
-class GattlinkNodeBridge(Device.Listener):
- def __init__(self):
- self.peer = None
- self.rx_socket = None
- self.tx_socket = None
+ def on_l2cap_psm_received(self, value):
+ psm = struct.unpack('<H', value)[0]
+ asyncio.create_task(self.connect_l2cap(psm))
# Called by asyncio when the UDP socket is created
def connection_made(self, transport):
@@ -131,21 +179,130 @@
# Called by asyncio when a UDP datagram is received
def datagram_received(self, data, address):
- print(color('<<< RX:', 'magenta'), data.hex())
+ print(color(f'<<< [UDP]: {len(data)} bytes', 'green'))
- # TODO: use a queue instead of creating a task everytime
- if self.peer and self.rx_characteristic:
+ if self.l2cap_channel:
+ print(color('>>> [L2CAP]', 'yellow'))
+ self.l2cap_channel.write(bytes([len(data) - 1]) + data)
+ elif self.peer and self.rx_characteristic:
+ print(color('>>> [GATT RX]', 'yellow'))
asyncio.create_task(self.peer.write_value(self.rx_characteristic, data))
# -----------------------------------------------------------------------------
-async def run(hci_transport, device_address, send_host, send_port, receive_host, receive_port):
+class GattlinkNodeBridge(GattlinkL2capEndpoint, Device.Listener):
+ def __init__(self, device):
+ super().__init__()
+ self.device = device
+ self.peer = None
+ self.tx_socket = None
+ self.tx_subscriber = None
+ self.rx_characteristic = None
+
+ # Register as a listener
+ device.listener = self
+
+ # Listen for incoming L2CAP CoC connections
+ psm = 0xFB
+ device.register_l2cap_channel_server(0xFB, self.on_coc)
+ print(f'### Listening for CoC connection on PSM {psm}')
+
+ # Setup the Gattlink service
+ self.rx_characteristic = Characteristic(
+ GG_GATTLINK_RX_CHARACTERISTIC_UUID,
+ Characteristic.WRITE_WITHOUT_RESPONSE,
+ Characteristic.WRITEABLE,
+ CharacteristicValue(write=self.on_rx_write)
+ )
+ self.tx_characteristic = Characteristic(
+ GG_GATTLINK_TX_CHARACTERISTIC_UUID,
+ Characteristic.NOTIFY,
+ Characteristic.READABLE
+ )
+ self.tx_characteristic.on('subscription', self.on_tx_subscription)
+ self.psm_characteristic = Characteristic(
+ GG_GATTLINK_L2CAP_CHANNEL_PSM_CHARACTERISTIC_UUID,
+ Characteristic.READ | Characteristic.NOTIFY,
+ Characteristic.READABLE,
+ bytes([psm, 0])
+ )
+ gattlink_service = Service(
+ GG_GATTLINK_SERVICE_UUID,
+ [
+ self.rx_characteristic,
+ self.tx_characteristic,
+ self.psm_characteristic
+ ]
+ )
+ device.add_services([gattlink_service])
+ device.advertising_data = bytes(
+ AdvertisingData([
+ (AdvertisingData.COMPLETE_LOCAL_NAME, bytes('Bumble GG', 'utf-8')),
+ (AdvertisingData.INCOMPLETE_LIST_OF_128_BIT_SERVICE_CLASS_UUIDS,
+ bytes(reversed(bytes.fromhex('ABBAFF00E56A484CB8328B17CF6CBFE8'))))
+ ])
+ )
+
+ async def start(self):
+ await self.device.start_advertising()
+
+ # Called by asyncio when the UDP socket is created
+ def connection_made(self, transport):
+ self.transport = transport
+
+ # Called by asyncio when a UDP datagram is received
+ def datagram_received(self, data, address):
+ print(color(f'<<< [UDP]: {len(data)} bytes', 'green'))
+
+ if self.l2cap_channel:
+ print(color('>>> [L2CAP]', 'yellow'))
+ self.l2cap_channel.write(bytes([len(data) - 1]) + data)
+ elif self.tx_subscriber:
+ print(color('>>> [GATT TX]', 'yellow'))
+ self.tx_characteristic.value = data
+ asyncio.create_task(self.device.notify_subscribers(self.tx_characteristic))
+
+ # Called when a write to the RX characteristic has been received
+ def on_rx_write(self, connection, data):
+ print(color(f'<<< [GATT RX]: {len(data)} bytes', 'cyan'))
+ print(color('>>> [UDP]', 'magenta'))
+ self.tx_socket.sendto(data)
+
+ # Called when the subscription to the TX characteristic has changed
+ def on_tx_subscription(self, peer, enabled):
+ print(f'### [GATT TX] subscription from {peer}: {"enabled" if enabled else "disabled"}')
+ if enabled:
+ self.tx_subscriber = peer
+ else:
+ self.tx_subscriber = None
+
+ # Called when an L2CAP packet is received
+ def on_l2cap_packet(self, packet):
+ print(color(f'<<< [L2CAP PACKET]: {len(packet)} bytes', 'cyan'))
+ print(color('>>> [UDP]', 'magenta'))
+ self.tx_socket.sendto(packet)
+
+ # Called when a new connection is established
+ def on_coc(self, channel):
+ print('*** CoC Connection', channel)
+ self.l2cap_channel = channel
+ channel.sink = self.on_coc_sdu
+
+
+# -----------------------------------------------------------------------------
+async def run(hci_transport, device_address, role_or_peer_address, send_host, send_port, receive_host, receive_port):
print('<<< connecting to HCI...')
async with await open_transport_or_link(hci_transport) as (hci_source, hci_sink):
print('<<< connected')
# Instantiate a bridge object
- bridge = GattlinkNodeBridge()
+ device = Device.with_hci('Bumble GG', device_address, hci_source, hci_sink)
+
+ # Instantiate a bridge object
+ if role_or_peer_address == 'node':
+ bridge = GattlinkNodeBridge(device)
+ else:
+ bridge = GattlinkHubBridge(device, role_or_peer_address)
# Create a UDP to RX bridge (receive from UDP, send to RX)
loop = asyncio.get_running_loop()
@@ -160,35 +317,8 @@
remote_addr=(send_host, send_port)
)
- # Create a device to manage the host, with a custom listener
- device = Device.with_hci('Bumble', 'F0:F1:F2:F3:F4:F5', hci_source, hci_sink)
- device.listener = bridge
await device.power_on()
-
- # Connect to the peer
- # print(f'=== Connecting to {device_address}...')
- # await device.connect(device_address)
-
- # TODO move to class
- gattlink_service = Service(
- GG_GATTLINK_SERVICE_UUID,
- [
- Characteristic(
- GG_GATTLINK_L2CAP_CHANNEL_PSM_CHARACTERISTIC_UUID,
- Characteristic.READ,
- Characteristic.READABLE,
- bytes([193, 0])
- )
- ]
- )
- device.add_services([gattlink_service])
- device.advertising_data = bytes(
- AdvertisingData([
- (AdvertisingData.COMPLETE_LOCAL_NAME, bytes('Bumble GG', 'utf-8')),
- (AdvertisingData.INCOMPLETE_LIST_OF_128_BIT_SERVICE_CLASS_UUIDS, bytes(reversed(bytes.fromhex('ABBAFF00E56A484CB8328B17CF6CBFE8'))))
- ])
- )
- await device.start_advertising()
+ await bridge.start()
# Wait until the source terminates
await hci_source.wait_for_termination()
@@ -197,15 +327,16 @@
@click.command()
@click.argument('hci_transport')
@click.argument('device_address')
[email protected]('role_or_peer_address')
@click.option('-sh', '--send-host', type=str, default='127.0.0.1', help='UDP host to send to')
@click.option('-sp', '--send-port', type=int, default=9001, help='UDP port to send to')
@click.option('-rh', '--receive-host', type=str, default='127.0.0.1', help='UDP host to receive on')
@click.option('-rp', '--receive-port', type=int, default=9000, help='UDP port to receive on')
-def main(hci_transport, device_address, send_host, send_port, receive_host, receive_port):
- logging.basicConfig(level = os.environ.get('BUMBLE_LOGLEVEL', 'INFO').upper())
- asyncio.run(run(hci_transport, device_address, send_host, send_port, receive_host, receive_port))
+def main(hci_transport, device_address, role_or_peer_address, send_host, send_port, receive_host, receive_port):
+ asyncio.run(run(hci_transport, device_address, role_or_peer_address, send_host, send_port, receive_host, receive_port))
# -----------------------------------------------------------------------------
+logging.basicConfig(level = os.environ.get('BUMBLE_LOGLEVEL', 'WARNING').upper())
if __name__ == '__main__':
main()
diff --git a/apps/l2cap_bridge.py b/apps/l2cap_bridge.py
new file mode 100644
index 0000000..ba658c2
--- /dev/null
+++ b/apps/l2cap_bridge.py
@@ -0,0 +1,331 @@
+# Copyright 2021-2022 Google LLC
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# https://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+# -----------------------------------------------------------------------------
+# Imports
+# -----------------------------------------------------------------------------
+import asyncio
+import click
+import logging
+import os
+from colors import color
+
+from bumble.transport import open_transport_or_link
+from bumble.device import Device
+from bumble.utils import FlowControlAsyncPipe
+from bumble.hci import HCI_Constant
+
+
+# -----------------------------------------------------------------------------
+class ServerBridge:
+ """
+ L2CAP CoC server bridge: waits for a peer to connect an L2CAP CoC channel
+ on a specified PSM. When the connection is made, the bridge connects a TCP
+ socket to a remote host and bridges the data in both directions, with flow
+ control.
+ When the L2CAP CoC channel is closed, the bridge disconnects the TCP socket
+ and waits for a new L2CAP CoC channel to be connected.
+ When the TCP connection is closed by the TCP server, XXXX
+ """
+ def __init__(
+ self,
+ psm,
+ max_credits,
+ mtu,
+ mps,
+ tcp_host,
+ tcp_port
+ ):
+ self.psm = psm
+ self.max_credits = max_credits
+ self.mtu = mtu
+ self.mps = mps
+ self.tcp_host = tcp_host
+ self.tcp_port = tcp_port
+
+ async def start(self, device):
+ # Listen for incoming L2CAP CoC connections
+ device.register_l2cap_channel_server(
+ psm = self.psm,
+ server = self.on_coc,
+ max_credits = self.max_credits,
+ mtu = self.mtu,
+ mps = self.mps
+ )
+ print(color(f'### Listening for CoC connection on PSM {self.psm}', 'yellow'))
+
+ def on_ble_connection(connection):
+ def on_ble_disconnection(reason):
+ print(color('@@@ Bluetooth disconnection:', 'red'), HCI_Constant.error_name(reason))
+
+ print(color('@@@ Bluetooth connection:', 'green'), connection)
+ connection.on('disconnection', on_ble_disconnection)
+
+ device.on('connection', on_ble_connection)
+
+ await device.start_advertising(auto_restart=True)
+
+ # Called when a new L2CAP connection is established
+ def on_coc(self, l2cap_channel):
+ print(color('*** L2CAP channel:', 'cyan'), l2cap_channel)
+
+ class Pipe:
+ def __init__(self, bridge, l2cap_channel):
+ self.bridge = bridge
+ self.tcp_transport = None
+ self.l2cap_channel = l2cap_channel
+
+ l2cap_channel.on('close', self.on_l2cap_close)
+ l2cap_channel.sink = self.on_coc_sdu
+
+ async def connect_to_tcp(self):
+ # Connect to the TCP server
+ print(color(f'### Connecting to TCP {self.bridge.tcp_host}:{self.bridge.tcp_port}...', 'yellow'))
+
+ class TcpClientProtocol(asyncio.Protocol):
+ def __init__(self, pipe):
+ self.pipe = pipe
+
+ def connection_lost(self, error):
+ print(color(f'!!! TCP connection lost: {error}', 'red'))
+ if self.pipe.l2cap_channel is not None:
+ asyncio.create_task(self.pipe.l2cap_channel.disconnect())
+
+ def data_received(self, data):
+ print(f'<<< Received on TCP: {len(data)}')
+ self.pipe.l2cap_channel.write(data)
+
+ try:
+ self.tcp_transport, _ = await asyncio.get_running_loop().create_connection(
+ lambda: TcpClientProtocol(self),
+ host=self.bridge.tcp_host,
+ port=self.bridge.tcp_port,
+ )
+ print(color('### Connected', 'green'))
+ except Exception as error:
+ print(color(f'!!! Connection failed: {error}', 'red'))
+ await self.l2cap_channel.disconnect()
+
+ def on_l2cap_close(self):
+ self.l2cap_channel = None
+ if self.tcp_transport is not None:
+ self.tcp_transport.close()
+
+ def on_coc_sdu(self, sdu):
+ print(color(f'<<< [L2CAP SDU]: {len(sdu)} bytes', 'cyan'))
+ if self.tcp_transport is None:
+ print(color('!!! TCP socket not open, dropping', 'red'))
+ return
+ self.tcp_transport.write(sdu)
+
+ pipe = Pipe(self, l2cap_channel)
+
+ asyncio.create_task(pipe.connect_to_tcp())
+
+
+# -----------------------------------------------------------------------------
+class ClientBridge:
+ """
+ L2CAP CoC client bridge: connects to a BLE device, then waits for an inbound
+ TCP connection on a specified port number. When a TCP client connects, an
+ L2CAP CoC channel connection to the BLE device is established, and the data
+ is bridged in both directions, with flow control.
+ When the TCP connection is closed by the client, the L2CAP CoC channel is
+ disconnected, but the connection to the BLE device remains, ready for a new
+ TCP client to connect.
+ When the L2CAP CoC channel is closed, XXXX
+ """
+
+ READ_CHUNK_SIZE = 4096
+
+ def __init__(
+ self,
+ psm,
+ max_credits,
+ mtu,
+ mps,
+ address,
+ tcp_host,
+ tcp_port
+ ):
+ self.psm = psm
+ self.max_credits = max_credits
+ self.mtu = mtu
+ self.mps = mps
+ self.address = address
+ self.tcp_host = tcp_host
+ self.tcp_port = tcp_port
+
+ async def start(self, device):
+ print(color(f'### Connecting to {self.address}...', 'yellow'))
+ connection = await device.connect(self.address)
+ print(color('### Connected', 'green'))
+
+ # Called when the BLE connection is disconnected
+ def on_ble_disconnection(reason):
+ print(color('@@@ Bluetooth disconnection:', 'red'), HCI_Constant.error_name(reason))
+
+ connection.on('disconnection', on_ble_disconnection)
+
+ # Called when a TCP connection is established
+ async def on_tcp_connection(reader, writer):
+ peername = writer.get_extra_info('peername')
+ print(color(f'<<< TCP connection from {peername}', 'magenta'))
+
+ def on_coc_sdu(sdu):
+ print(color(f'<<< [L2CAP SDU]: {len(sdu)} bytes', 'cyan'))
+ l2cap_to_tcp_pipe.write(sdu)
+
+ def on_l2cap_close():
+ print(color('*** L2CAP channel closed', 'red'))
+ l2cap_to_tcp_pipe.stop()
+ writer.close()
+
+ # Connect a new L2CAP channel
+ print(color(f'>>> Opening L2CAP channel on PSM = {self.psm}', 'yellow'))
+ try:
+ l2cap_channel = await connection.open_l2cap_channel(
+ psm = self.psm,
+ max_credits = self.max_credits,
+ mtu = self.mtu,
+ mps = self.mps
+ )
+ print(color('*** L2CAP channel:', 'cyan'), l2cap_channel)
+ except Exception as error:
+ print(color(f'!!! Connection failed: {error}', 'red'))
+ writer.close()
+ return
+
+ l2cap_channel.sink = on_coc_sdu
+ l2cap_channel.on('close', on_l2cap_close)
+
+ # Start a flow control pipe from L2CAP to TCP
+ l2cap_to_tcp_pipe = FlowControlAsyncPipe(
+ l2cap_channel.pause_reading,
+ l2cap_channel.resume_reading,
+ writer.write,
+ writer.drain
+ )
+ l2cap_to_tcp_pipe.start()
+
+ # Pipe data from TCP to L2CAP
+ while True:
+ try:
+ data = await reader.read(self.READ_CHUNK_SIZE)
+
+ if len(data) == 0:
+ print(color('!!! End of stream', 'red'))
+ await l2cap_channel.disconnect()
+ return
+
+ print(color(f'<<< [TCP DATA]: {len(data)} bytes', 'blue'))
+ l2cap_channel.write(data)
+ await l2cap_channel.drain()
+ except Exception as error:
+ print(f'!!! Exception: {error}')
+ break
+
+ writer.close()
+ print(color('~~~ Bye bye', 'magenta'))
+
+ await asyncio.start_server(
+ on_tcp_connection,
+ host=self.tcp_host if self.tcp_host != '_' else None,
+ port=self.tcp_port
+ )
+ print(color(f'### Listening for TCP connections on port {self.tcp_port}', 'magenta'))
+
+
+# -----------------------------------------------------------------------------
+async def run(device_config, hci_transport, bridge):
+ print('<<< connecting to HCI...')
+ async with await open_transport_or_link(hci_transport) as (hci_source, hci_sink):
+ print('<<< connected')
+
+ device = Device.from_config_file_with_hci(device_config, hci_source, hci_sink)
+
+ # Let's go
+ await device.power_on()
+ await bridge.start(device)
+
+ # Wait until the transport terminates
+ await hci_source.wait_for_termination()
+
+
+# -----------------------------------------------------------------------------
[email protected]()
[email protected]_context
[email protected]('--device-config', help='Device configuration file', required=True)
[email protected]('--hci-transport', help='HCI transport', required=True)
[email protected]('--psm', help='PSM for L2CAP CoC', type=int, default=1234)
[email protected]('--l2cap-coc-max-credits', help='Maximum L2CAP CoC Credits', type=click.IntRange(1, 65535), default=128)
[email protected]('--l2cap-coc-mtu', help='L2CAP CoC MTU', type=click.IntRange(23, 65535), default=1022)
[email protected]('--l2cap-coc-mps', help='L2CAP CoC MPS', type=click.IntRange(23, 65533), default=1024)
+def cli(context, device_config, hci_transport, psm, l2cap_coc_max_credits, l2cap_coc_mtu, l2cap_coc_mps):
+ context.ensure_object(dict)
+ context.obj['device_config'] = device_config
+ context.obj['hci_transport'] = hci_transport
+ context.obj['psm'] = psm
+ context.obj['max_credits'] = l2cap_coc_max_credits
+ context.obj['mtu'] = l2cap_coc_mtu
+ context.obj['mps'] = l2cap_coc_mps
+
+
+# -----------------------------------------------------------------------------
[email protected]()
[email protected]_context
[email protected]('--tcp-host', help='TCP host', default='localhost')
[email protected]('--tcp-port', help='TCP port', default=9544)
+def server(context, tcp_host, tcp_port):
+ bridge = ServerBridge(
+ context.obj['psm'],
+ context.obj['max_credits'],
+ context.obj['mtu'],
+ context.obj['mps'],
+ tcp_host,
+ tcp_port)
+ asyncio.run(run(
+ context.obj['device_config'],
+ context.obj['hci_transport'],
+ bridge
+ ))
+
+
+# -----------------------------------------------------------------------------
[email protected]()
[email protected]_context
[email protected]('bluetooth-address')
[email protected]('--tcp-host', help='TCP host', default='_')
[email protected]('--tcp-port', help='TCP port', default=9543)
+def client(context, bluetooth_address, tcp_host, tcp_port):
+ bridge = ClientBridge(
+ context.obj['psm'],
+ context.obj['max_credits'],
+ context.obj['mtu'],
+ context.obj['mps'],
+ bluetooth_address,
+ tcp_host,
+ tcp_port
+ )
+ asyncio.run(run(
+ context.obj['device_config'],
+ context.obj['hci_transport'],
+ bridge
+ ))
+
+
+# -----------------------------------------------------------------------------
+logging.basicConfig(level = os.environ.get('BUMBLE_LOGLEVEL', 'WARNING').upper())
+if __name__ == '__main__':
+ cli(obj={})
diff --git a/apps/scan.py b/apps/scan.py
index 045cb57..d6c1092 100644
--- a/apps/scan.py
+++ b/apps/scan.py
@@ -25,8 +25,8 @@
from bumble.transport import open_transport_or_link
from bumble.keys import JsonKeyStore
from bumble.smp import AddressResolver
-from bumble.hci import HCI_LE_Advertising_Report_Event
-from bumble.core import AdvertisingData
+from bumble.device import Advertisement
+from bumble.hci import HCI_Constant, HCI_LE_1M_PHY, HCI_LE_CODED_PHY
# -----------------------------------------------------------------------------
@@ -48,16 +48,19 @@
self.min_rssi = min_rssi
self.resolver = resolver
- def print_advertisement(self, address, address_color, ad_data, rssi):
- if self.min_rssi is not None and rssi < self.min_rssi:
+ def print_advertisement(self, advertisement):
+ address = advertisement.address
+ address_color = 'yellow' if advertisement.is_connectable else 'red'
+
+ if self.min_rssi is not None and advertisement.rssi < self.min_rssi:
return
address_qualifier = ''
resolution_qualifier = ''
- if self.resolver and address.is_resolvable:
- resolved = self.resolver.resolve(address)
+ if self.resolver and advertisement.address.is_resolvable:
+ resolved = self.resolver.resolve(advertisement.address)
if resolved is not None:
- resolution_qualifier = f'(resolved from {address})'
+ resolution_qualifier = f'(resolved from {advertisement.address})'
address = resolved
address_type_string = ('PUBLIC', 'RANDOM', 'PUBLIC_ID', 'RANDOM_ID')[address.address_type]
@@ -74,18 +77,30 @@
type_color = 'blue'
address_qualifier = '(non-resolvable)'
- rssi_bar = make_rssi_bar(rssi)
separator = '\n '
- print(f'>>> {color(address, address_color)} [{color(address_type_string, type_color)}]{address_qualifier}{resolution_qualifier}:{separator}RSSI:{rssi:4} {rssi_bar}{separator}{ad_data.to_string(separator)}\n')
+ rssi_bar = make_rssi_bar(advertisement.rssi)
+ if not advertisement.is_legacy:
+ phy_info = (
+ f'PHY: {HCI_Constant.le_phy_name(advertisement.primary_phy)}/'
+ f'{HCI_Constant.le_phy_name(advertisement.secondary_phy)} '
+ f'{separator}'
+ )
+ else:
+ phy_info = ''
- def on_advertisement(self, address, ad_data, rssi, connectable):
- address_color = 'yellow' if connectable else 'red'
- self.print_advertisement(address, address_color, ad_data, rssi)
+ print(
+ f'>>> {color(address, address_color)} '
+ f'[{color(address_type_string, type_color)}]{address_qualifier}{resolution_qualifier}:{separator}'
+ f'{phy_info}'
+ f'RSSI:{advertisement.rssi:4} {rssi_bar}{separator}'
+ f'{advertisement.data.to_string(separator)}\n')
- def on_advertising_report(self, address, ad_data, rssi, event_type):
- print(f'{color("EVENT", "green")}: {HCI_LE_Advertising_Report_Event.event_type_name(event_type)}')
- ad_data = AdvertisingData.from_bytes(ad_data)
- self.print_advertisement(address, 'yellow', ad_data, rssi)
+ def on_advertisement(self, advertisement):
+ self.print_advertisement(advertisement)
+
+ def on_advertising_report(self, report):
+ print(f'{color("EVENT", "green")}: {report.event_type_string()}')
+ self.print_advertisement(Advertisement.from_advertising_report(report))
# -----------------------------------------------------------------------------
@@ -94,6 +109,7 @@
passive,
scan_interval,
scan_window,
+ phy,
filter_duplicates,
raw,
keystore_file,
@@ -126,11 +142,18 @@
device.on('advertisement', printer.on_advertisement)
await device.power_on()
+
+ if phy is None:
+ scanning_phys = [HCI_LE_1M_PHY, HCI_LE_CODED_PHY]
+ else:
+ scanning_phys = [{'1m': HCI_LE_1M_PHY, 'coded': HCI_LE_CODED_PHY}[phy]]
+
await device.start_scanning(
active=(not passive),
scan_interval=scan_interval,
scan_window=scan_window,
- filter_duplicates=filter_duplicates
+ filter_duplicates=filter_duplicates,
+ scanning_phys=scanning_phys
)
await hci_source.wait_for_termination()
@@ -142,14 +165,15 @@
@click.option('--passive', is_flag=True, default=False, help='Perform passive scanning')
@click.option('--scan-interval', type=int, default=60, help='Scan interval')
@click.option('--scan-window', type=int, default=60, help='Scan window')
[email protected]('--phy', type=click.Choice(['1m', 'coded']), help='Only scan on the specified PHY')
@click.option('--filter-duplicates', type=bool, default=True, help='Filter duplicates at the controller level')
@click.option('--raw', is_flag=True, default=False, help='Listen for raw advertising reports instead of processed ones')
@click.option('--keystore-file', help='Keystore file to use when resolving addresses')
@click.option('--device-config', help='Device config file for the scanning device')
@click.argument('transport')
-def main(min_rssi, passive, scan_interval, scan_window, filter_duplicates, raw, keystore_file, device_config, transport):
+def main(min_rssi, passive, scan_interval, scan_window, phy, filter_duplicates, raw, keystore_file, device_config, transport):
logging.basicConfig(level = os.environ.get('BUMBLE_LOGLEVEL', 'WARNING').upper())
- asyncio.run(scan(min_rssi, passive, scan_interval, scan_window, filter_duplicates, raw, keystore_file, device_config, transport))
+ asyncio.run(scan(min_rssi, passive, scan_interval, scan_window, phy, filter_duplicates, raw, keystore_file, device_config, transport))
# -----------------------------------------------------------------------------
diff --git a/apps/show.py b/apps/show.py
index bba6c66..a4efe04 100644
--- a/apps/show.py
+++ b/apps/show.py
@@ -90,7 +90,7 @@
@click.command()
@click.option('--format', type=click.Choice(['h4', 'snoop']), default='h4', help='Format of the input file')
@click.argument('filename')
-def show(format, filename):
+def main(format, filename):
input = open(filename, 'rb')
if format == 'h4':
packet_reader = PacketReader(input)
@@ -117,4 +117,4 @@
# -----------------------------------------------------------------------------
if __name__ == '__main__':
- show()
+ main()
diff --git a/apps/usb_probe.py b/apps/usb_probe.py
index 9cded2b..26b7f40 100644
--- a/apps/usb_probe.py
+++ b/apps/usb_probe.py
@@ -28,6 +28,8 @@
# -----------------------------------------------------------------------------
import os
import logging
+import sys
+import click
import usb1
from colors import color
@@ -35,6 +37,7 @@
# -----------------------------------------------------------------------------
# Constants
# -----------------------------------------------------------------------------
+USB_DEVICE_CLASS_DEVICE = 0x00
USB_DEVICE_CLASS_WIRELESS_CONTROLLER = 0xE0
USB_DEVICE_SUBCLASS_RF_CONTROLLER = 0x01
USB_DEVICE_PROTOCOL_BLUETOOTH_PRIMARY_CONTROLLER = 0x01
@@ -75,9 +78,81 @@
0xFF: 'Vendor Specific'
}
+USB_ENDPOINT_IN = 0x80
+USB_ENDPOINT_TYPES = ['CONTROL', 'ISOCHRONOUS', 'BULK', 'INTERRUPT']
+
+USB_BT_HCI_CLASS_TUPLE = (
+ USB_DEVICE_CLASS_WIRELESS_CONTROLLER,
+ USB_DEVICE_SUBCLASS_RF_CONTROLLER,
+ USB_DEVICE_PROTOCOL_BLUETOOTH_PRIMARY_CONTROLLER
+)
+
# -----------------------------------------------------------------------------
-def main():
+def show_device_details(device):
+ for configuration in device:
+ print(f' Configuration {configuration.getConfigurationValue()}')
+ for interface in configuration:
+ for setting in interface:
+ alternateSetting = setting.getAlternateSetting()
+ suffix = f'/{alternateSetting}' if interface.getNumSettings() > 1 else ''
+ (class_string, subclass_string) = get_class_info(
+ setting.getClass(),
+ setting.getSubClass(),
+ setting.getProtocol()
+ )
+ details = f'({class_string}, {subclass_string})'
+ print(f' Interface: {setting.getNumber()}{suffix} {details}')
+ for endpoint in setting:
+ endpoint_type = USB_ENDPOINT_TYPES[endpoint.getAttributes() & 3]
+ endpoint_direction = 'OUT' if (endpoint.getAddress() & USB_ENDPOINT_IN == 0) else 'IN'
+ print(f' Endpoint 0x{endpoint.getAddress():02X}: {endpoint_type} {endpoint_direction}')
+
+
+# -----------------------------------------------------------------------------
+def get_class_info(cls, subclass, protocol):
+ class_info = USB_DEVICE_CLASSES.get(cls)
+ protocol_string = ''
+ if class_info is None:
+ class_string = f'0x{cls:02X}'
+ else:
+ if type(class_info) is tuple:
+ class_string = class_info[0]
+ subclass_info = class_info[1].get(subclass)
+ if subclass_info:
+ protocol_string = subclass_info.get(protocol)
+ if protocol_string is not None:
+ protocol_string = f' [{protocol_string}]'
+
+ else:
+ class_string = class_info
+
+ subclass_string = f'{subclass}/{protocol}{protocol_string}'
+
+ return (class_string, subclass_string)
+
+
+# -----------------------------------------------------------------------------
+def is_bluetooth_hci(device):
+ # Check if the device class indicates a match
+ if (device.getDeviceClass(), device.getDeviceSubClass(), device.getDeviceProtocol()) == USB_BT_HCI_CLASS_TUPLE:
+ return True
+
+ # If the device class is 'Device', look for a matching interface
+ if device.getDeviceClass() == USB_DEVICE_CLASS_DEVICE:
+ for configuration in device:
+ for interface in configuration:
+ for setting in interface:
+ if (setting.getClass(), setting.getSubClass(), setting.getProtocol()) == USB_BT_HCI_CLASS_TUPLE:
+ return True
+
+ return False
+
+
+# -----------------------------------------------------------------------------
[email protected]()
[email protected]('--verbose', is_flag=True, default=False, help='Print more details')
+def main(verbose):
logging.basicConfig(level = os.environ.get('BUMBLE_LOGLEVEL', 'WARNING').upper())
with usb1.USBContext() as context:
@@ -91,23 +166,28 @@
device_id = (device.getVendorID(), device.getProductID())
- device_is_bluetooth_hci = (
- device_class == USB_DEVICE_CLASS_WIRELESS_CONTROLLER and
- device_subclass == USB_DEVICE_SUBCLASS_RF_CONTROLLER and
- device_protocol == USB_DEVICE_PROTOCOL_BLUETOOTH_PRIMARY_CONTROLLER
+ (device_class_string, device_subclass_string) = get_class_info(
+ device_class,
+ device_subclass,
+ device_protocol
)
- device_class_details = ''
- device_class_info = USB_DEVICE_CLASSES.get(device_class)
- if device_class_info is not None:
- if type(device_class_info) is tuple:
- device_class = device_class_info[0]
- device_subclass_info = device_class_info[1].get(device_subclass)
- if device_subclass_info:
- device_class_details = f' [{device_subclass_info.get(device_protocol)}]'
- else:
- device_class = device_class_info
+ try:
+ device_serial_number = device.getSerialNumber()
+ except usb1.USBError:
+ device_serial_number = None
+ try:
+ device_manufacturer = device.getManufacturer()
+ except usb1.USBError:
+ device_manufacturer = None
+
+ try:
+ device_product = device.getProduct()
+ except usb1.USBError:
+ device_product = None
+
+ device_is_bluetooth_hci = is_bluetooth_hci(device)
if device_is_bluetooth_hci:
bluetooth_device_count += 1
fg_color = 'black'
@@ -123,33 +203,35 @@
if device_is_bluetooth_hci:
bumble_transport_names.append(f'usb:{bluetooth_device_count - 1}')
- serial_number_collision = False
- if device_id in devices:
- for device_serial in devices[device_id]:
- if device_serial == device.getSerialNumber():
- serial_number_collision = True
-
if device_id not in devices:
bumble_transport_names.append(basic_transport_name)
else:
bumble_transport_names.append(f'{basic_transport_name}#{len(devices[device_id])}')
- if device.getSerialNumber() and not serial_number_collision:
- bumble_transport_names.append(f'{basic_transport_name}/{device.getSerialNumber()}')
+ if device_serial_number is not None:
+ if device_id not in devices or device_serial_number not in devices[device_id]:
+ bumble_transport_names.append(f'{basic_transport_name}/{device_serial_number}')
+ # Print the results
print(color(f'ID {device.getVendorID():04X}:{device.getProductID():04X}', fg=fg_color, bg=bg_color))
if bumble_transport_names:
print(color(' Bumble Transport Names:', 'blue'), ' or '.join(color(x, 'cyan' if device_is_bluetooth_hci else 'red') for x in bumble_transport_names))
print(color(' Bus/Device: ', 'green'), f'{device.getBusNumber():03}/{device.getDeviceAddress():03}')
- if device.getSerialNumber():
- print(color(' Serial: ', 'green'), device.getSerialNumber())
- print(color(' Class: ', 'green'), device_class)
- print(color(' Subclass/Protocol: ', 'green'), f'{device_subclass}/{device_protocol}{device_class_details}')
- print(color(' Manufacturer: ', 'green'), device.getManufacturer())
- print(color(' Product: ', 'green'), device.getProduct())
+ print(color(' Class: ', 'green'), device_class_string)
+ print(color(' Subclass/Protocol: ', 'green'), device_subclass_string)
+ if device_serial_number is not None:
+ print(color(' Serial: ', 'green'), device_serial_number)
+ if device_manufacturer is not None:
+ print(color(' Manufacturer: ', 'green'), device_manufacturer)
+ if device_product is not None:
+ print(color(' Product: ', 'green'), device_product)
+
+ if verbose:
+ show_device_details(device)
+
print()
- devices.setdefault(device_id, []).append(device.getSerialNumber())
+ devices.setdefault(device_id, []).append(device_serial_number)
# -----------------------------------------------------------------------------
diff --git a/bumble/att.py b/bumble/att.py
index 22b683e..d83b3d1 100644
--- a/bumble/att.py
+++ b/bumble/att.py
@@ -700,16 +700,26 @@
else:
self.value = value
+ def encode_value(self, value):
+ return value
+
+ def decode_value(self, value_bytes):
+ return value_bytes
+
def read_value(self, connection):
if read := getattr(self.value, 'read', None):
try:
- return read(connection)
+ value = read(connection)
except ATT_Error as error:
raise ATT_Error(error_code=error.error_code, att_handle=self.handle)
else:
- return self.value
+ value = self.value
- def write_value(self, connection, value):
+ return self.encode_value(value)
+
+ def write_value(self, connection, value_bytes):
+ value = self.decode_value(value_bytes)
+
if write := getattr(self.value, 'write', None):
try:
write(connection, value)
@@ -721,7 +731,11 @@
self.emit('write', connection, value)
def __repr__(self):
- if len(self.value) > 0:
+ if type(self.value) is bytes:
+ value_str = self.value.hex()
+ else:
+ value_str = str(self.value)
+ if value_str:
value_string = f', value={self.value.hex()}'
else:
value_string = ''
diff --git a/bumble/avdtp.py b/bumble/avdtp.py
index 759e38c..7fe4fbb 100644
--- a/bumble/avdtp.py
+++ b/bumble/avdtp.py
@@ -351,7 +351,7 @@
logger.debug('pump canceled')
# Pump packets
- self.pump_task = asyncio.get_running_loop().create_task(pump_packets())
+ self.pump_task = asyncio.create_task(pump_packets())
async def stop(self):
# Stop the pump
@@ -1890,10 +1890,10 @@
self.configuration = configuration
def on_start_command(self):
- asyncio.get_running_loop().create_task(self.start())
+ asyncio.create_task(self.start())
def on_suspend_command(self):
- asyncio.get_running_loop().create_task(self.stop())
+ asyncio.create_task(self.stop())
# -----------------------------------------------------------------------------
diff --git a/bumble/controller.py b/bumble/controller.py
index c282416..7982e9b 100644
--- a/bumble/controller.py
+++ b/bumble/controller.py
@@ -76,7 +76,7 @@
self.supported_commands = bytes.fromhex('2000800000c000000000e40000002822000000000000040000f7ffff7f00000030f0f9ff01008004000000000000000000000000000000000000000000000000')
self.le_features = bytes.fromhex('ff49010000000000')
self.le_states = bytes.fromhex('ffff3fffff030000')
- self.avertising_channel_tx_power = 0
+ self.advertising_channel_tx_power = 0
self.filter_accept_list_size = 8
self.resolving_list_size = 8
self.supported_max_tx_octets = 27
@@ -259,15 +259,15 @@
# Then say that the connection has completed
self.send_hci_packet(HCI_LE_Connection_Complete_Event(
- status = HCI_SUCCESS,
- connection_handle = connection.handle,
- role = connection.role,
- peer_address_type = peer_address_type,
- peer_address = peer_address,
- conn_interval = 10, # FIXME
- conn_latency = 0, # FIXME
- supervision_timeout = 10, # FIXME
- master_clock_accuracy = 7 # FIXME
+ status = HCI_SUCCESS,
+ connection_handle = connection.handle,
+ role = connection.role,
+ peer_address_type = peer_address_type,
+ peer_address = peer_address,
+ connection_interval = 10, # FIXME
+ peripheral_latency = 0, # FIXME
+ supervision_timeout = 10, # FIXME
+ central_clock_accuracy = 7 # FIXME
))
def on_link_central_disconnected(self, peer_address, reason):
@@ -313,15 +313,15 @@
# Say that the connection has completed
self.send_hci_packet(HCI_LE_Connection_Complete_Event(
- status = status,
- connection_handle = connection.handle if connection else 0,
- role = BT_CENTRAL_ROLE,
- peer_address_type = le_create_connection_command.peer_address_type,
- peer_address = le_create_connection_command.peer_address,
- conn_interval = le_create_connection_command.conn_interval_min,
- conn_latency = le_create_connection_command.conn_latency,
- supervision_timeout = le_create_connection_command.supervision_timeout,
- master_clock_accuracy = 0
+ status = status,
+ connection_handle = connection.handle if connection else 0,
+ role = BT_CENTRAL_ROLE,
+ peer_address_type = le_create_connection_command.peer_address_type,
+ peer_address = le_create_connection_command.peer_address,
+ connection_interval = le_create_connection_command.connection_interval_min,
+ peripheral_latency = le_create_connection_command.max_latency,
+ supervision_timeout = le_create_connection_command.supervision_timeout,
+ central_clock_accuracy = 0
))
def on_link_peripheral_disconnection_complete(self, disconnection_command, status):
@@ -583,13 +583,15 @@
'''
See Bluetooth spec Vol 2, Part E - 7.4.1 Read Local Version Information Command
'''
- return struct.pack('<BBHBHH',
- HCI_SUCCESS,
- self.hci_version,
- self.hci_revision,
- self.lmp_version,
- self.manufacturer_name,
- self.lmp_subversion)
+ return struct.pack(
+ '<BBHBHH',
+ HCI_SUCCESS,
+ self.hci_version,
+ self.hci_revision,
+ self.lmp_version,
+ self.manufacturer_name,
+ self.lmp_subversion
+ )
def on_hci_read_local_supported_commands_command(self, command):
'''
@@ -650,7 +652,7 @@
'''
See Bluetooth spec Vol 2, Part E - 7.8.6 LE Read Advertising Channel Tx Power Command
'''
- return bytes([HCI_SUCCESS, self.avertising_channel_tx_power])
+ return bytes([HCI_SUCCESS, self.advertising_channel_tx_power])
def on_hci_le_set_advertising_data_command(self, command):
'''
@@ -857,9 +859,9 @@
See Bluetooth spec Vol 2, Part E - 7.8.44 LE Set Address Resolution Enable Command
'''
ret = HCI_SUCCESS
- if command.address_resolution == 1:
+ if command.address_resolution_enable == 1:
self.le_address_resolution = True
- elif command.address_resolution == 0:
+ elif command.address_resolution_enable == 0:
self.le_address_resolution = False
else:
ret = HCI_INVALID_HCI_COMMAND_PARAMETERS_ERROR
@@ -876,12 +878,26 @@
'''
See Bluetooth spec Vol 2, Part E - 7.8.46 LE Read Maximum Data Length Command
'''
- return struct.pack('<BHHHH',
- HCI_SUCCESS,
- self.supported_max_tx_octets,
- self.supported_max_tx_time,
- self.supported_max_rx_octets,
- self.supported_max_rx_time)
+ return struct.pack(
+ '<BHHHH',
+ HCI_SUCCESS,
+ self.supported_max_tx_octets,
+ self.supported_max_tx_time,
+ self.supported_max_rx_octets,
+ self.supported_max_rx_time
+ )
+
+ def on_hci_le_read_phy_command(self, command):
+ '''
+ See Bluetooth spec Vol 2, Part E - 7.8.47 LE Read PHY command
+ '''
+ return struct.pack(
+ '<BHBB',
+ HCI_SUCCESS,
+ command.connection_handle,
+ HCI_LE_1M_PHY,
+ HCI_LE_1M_PHY
+ )
def on_hci_le_set_default_phy_command(self, command):
'''
@@ -893,3 +909,4 @@
'rx_phys': command.rx_phys
}
return bytes([HCI_SUCCESS])
+
diff --git a/bumble/core.py b/bumble/core.py
index 746a601..302ee6a 100644
--- a/bumble/core.py
+++ b/bumble/core.py
@@ -91,6 +91,10 @@
""" Timeout Error """
+class CommandTimeoutError(Exception):
+ """ Command Timeout Error """
+
+
class InvalidStateError(Exception):
""" Invalid State Error """
@@ -100,6 +104,11 @@
FAILURE = 0x01
CONNECTION_REFUSED = 0x02
+ def __init__(self, error_code, transport, peer_address, error_namespace='', error_name='', details=''):
+ super().__init__(error_code, error_namespace, error_name, details)
+ self.transport = transport
+ self.peer_address = peer_address
+
# -----------------------------------------------------------------------------
# UUID
@@ -760,17 +769,20 @@
def ad_data_to_object(ad_type, ad_data):
if ad_type in {
AdvertisingData.COMPLETE_LIST_OF_16_BIT_SERVICE_CLASS_UUIDS,
- AdvertisingData.INCOMPLETE_LIST_OF_16_BIT_SERVICE_CLASS_UUIDS
+ AdvertisingData.INCOMPLETE_LIST_OF_16_BIT_SERVICE_CLASS_UUIDS,
+ AdvertisingData.LIST_OF_16_BIT_SERVICE_SOLICITATION_UUIDS
}:
return AdvertisingData.uuid_list_to_objects(ad_data, 2)
elif ad_type in {
AdvertisingData.COMPLETE_LIST_OF_32_BIT_SERVICE_CLASS_UUIDS,
- AdvertisingData.INCOMPLETE_LIST_OF_32_BIT_SERVICE_CLASS_UUIDS
+ AdvertisingData.INCOMPLETE_LIST_OF_32_BIT_SERVICE_CLASS_UUIDS,
+ AdvertisingData.LIST_OF_32_BIT_SERVICE_SOLICITATION_UUIDS
}:
return AdvertisingData.uuid_list_to_objects(ad_data, 4)
elif ad_type in {
AdvertisingData.COMPLETE_LIST_OF_128_BIT_SERVICE_CLASS_UUIDS,
- AdvertisingData.INCOMPLETE_LIST_OF_128_BIT_SERVICE_CLASS_UUIDS
+ AdvertisingData.INCOMPLETE_LIST_OF_128_BIT_SERVICE_CLASS_UUIDS,
+ AdvertisingData.LIST_OF_128_BIT_SERVICE_SOLICITATION_UUIDS
}:
return AdvertisingData.uuid_list_to_objects(ad_data, 16)
elif ad_type == AdvertisingData.SERVICE_DATA_16_BIT_UUID:
@@ -781,11 +793,24 @@
return (UUID.from_bytes(ad_data[:16]), ad_data[16:])
elif ad_type in {
AdvertisingData.SHORTENED_LOCAL_NAME,
- AdvertisingData.COMPLETE_LOCAL_NAME
+ AdvertisingData.COMPLETE_LOCAL_NAME,
+ AdvertisingData.URI
}:
return ad_data.decode("utf-8")
- elif ad_type == AdvertisingData.TX_POWER_LEVEL:
+ elif ad_type in {
+ AdvertisingData.TX_POWER_LEVEL,
+ AdvertisingData.FLAGS
+ }:
return ad_data[0]
+ elif ad_type in {
+ AdvertisingData.APPEARANCE,
+ AdvertisingData.ADVERTISING_INTERVAL
+ }:
+ return struct.unpack('<H', ad_data)[0]
+ elif ad_type == AdvertisingData.CLASS_OF_DEVICE:
+ return struct.unpack('<I', bytes([*ad_data, 0]))[0]
+ elif ad_type == AdvertisingData.PERIPHERAL_CONNECTION_INTERVAL_RANGE:
+ return struct.unpack('<HH', ad_data)
elif ad_type == AdvertisingData.MANUFACTURER_SPECIFIC_DATA:
return (struct.unpack_from('<H', ad_data, 0)[0], ad_data[2:])
else:
@@ -802,7 +827,7 @@
self.ad_structures.append((ad_type, ad_data))
offset += length
- def get(self, type_id, return_all=False, raw=True):
+ def get(self, type_id, return_all=False, raw=False):
'''
Get Advertising Data Structure(s) with a given type
@@ -831,13 +856,17 @@
# Connection Parameters
# -----------------------------------------------------------------------------
class ConnectionParameters:
- def __init__(self, connection_interval, connection_latency, supervision_timeout):
+ def __init__(self, connection_interval, peripheral_latency, supervision_timeout):
self.connection_interval = connection_interval
- self.connection_latency = connection_latency
+ self.peripheral_latency = peripheral_latency
self.supervision_timeout = supervision_timeout
def __str__(self):
- return f'ConnectionParameters(connection_interval={self.connection_interval}, connection_latency={self.connection_latency}, supervision_timeout={self.supervision_timeout}'
+ return (
+ f'ConnectionParameters(connection_interval={self.connection_interval}, '
+ f'peripheral_latency={self.peripheral_latency}, '
+ f'supervision_timeout={self.supervision_timeout}'
+ )
# -----------------------------------------------------------------------------
diff --git a/bumble/device.py b/bumble/device.py
index 230a1cf..300afd2 100644
--- a/bumble/device.py
+++ b/bumble/device.py
@@ -15,10 +15,12 @@
# -----------------------------------------------------------------------------
# Imports
# -----------------------------------------------------------------------------
+from enum import IntEnum
import json
import asyncio
import logging
-from contextlib import asynccontextmanager, AsyncExitStack
+from contextlib import asynccontextmanager, AsyncExitStack
+from dataclasses import dataclass
from .hci import *
from .host import Host
@@ -41,49 +43,219 @@
# -----------------------------------------------------------------------------
# Constants
# -----------------------------------------------------------------------------
-DEVICE_DEFAULT_ADDRESS = '00:00:00:00:00:00'
-DEVICE_DEFAULT_ADVERTISING_INTERVAL = 1000 # ms
-DEVICE_DEFAULT_ADVERTISING_DATA = ''
-DEVICE_DEFAULT_NAME = 'Bumble'
-DEVICE_DEFAULT_INQUIRY_LENGTH = 8 # 10.24 seconds
-DEVICE_DEFAULT_CLASS_OF_DEVICE = 0
-DEVICE_DEFAULT_SCAN_RESPONSE_DATA = b''
-DEVICE_DEFAULT_DATA_LENGTH = (27, 328, 27, 328)
-DEVICE_DEFAULT_SCAN_INTERVAL = 60 # ms
-DEVICE_DEFAULT_SCAN_WINDOW = 60 # ms
-DEVICE_MIN_SCAN_INTERVAL = 25
-DEVICE_MAX_SCAN_INTERVAL = 10240
-DEVICE_MIN_SCAN_WINDOW = 25
-DEVICE_MAX_SCAN_WINDOW = 10240
+DEVICE_MIN_SCAN_INTERVAL = 25
+DEVICE_MAX_SCAN_INTERVAL = 10240
+DEVICE_MIN_SCAN_WINDOW = 25
+DEVICE_MAX_SCAN_WINDOW = 10240
+DEVICE_MIN_LE_RSSI = -127
+DEVICE_MAX_LE_RSSI = 20
+
+DEVICE_DEFAULT_ADDRESS = '00:00:00:00:00:00'
+DEVICE_DEFAULT_ADVERTISING_INTERVAL = 1000 # ms
+DEVICE_DEFAULT_ADVERTISING_DATA = ''
+DEVICE_DEFAULT_NAME = 'Bumble'
+DEVICE_DEFAULT_INQUIRY_LENGTH = 8 # 10.24 seconds
+DEVICE_DEFAULT_CLASS_OF_DEVICE = 0
+DEVICE_DEFAULT_SCAN_RESPONSE_DATA = b''
+DEVICE_DEFAULT_DATA_LENGTH = (27, 328, 27, 328)
+DEVICE_DEFAULT_SCAN_INTERVAL = 60 # ms
+DEVICE_DEFAULT_SCAN_WINDOW = 60 # ms
+DEVICE_DEFAULT_CONNECT_TIMEOUT = None # No timeout
+DEVICE_DEFAULT_CONNECT_SCAN_INTERVAL = 60 # ms
+DEVICE_DEFAULT_CONNECT_SCAN_WINDOW = 60 # ms
+DEVICE_DEFAULT_CONNECTION_INTERVAL_MIN = 15 # ms
+DEVICE_DEFAULT_CONNECTION_INTERVAL_MAX = 30 # ms
+DEVICE_DEFAULT_CONNECTION_MAX_LATENCY = 0
+DEVICE_DEFAULT_CONNECTION_SUPERVISION_TIMEOUT = 720 # ms
+DEVICE_DEFAULT_CONNECTION_MIN_CE_LENGTH = 0 # ms
+DEVICE_DEFAULT_CONNECTION_MAX_CE_LENGTH = 0 # ms
+DEVICE_DEFAULT_L2CAP_COC_MTU = l2cap.L2CAP_LE_CREDIT_BASED_CONNECTION_DEFAULT_MTU
+DEVICE_DEFAULT_L2CAP_COC_MPS = l2cap.L2CAP_LE_CREDIT_BASED_CONNECTION_DEFAULT_MPS
+DEVICE_DEFAULT_L2CAP_COC_MAX_CREDITS = l2cap.L2CAP_LE_CREDIT_BASED_CONNECTION_DEFAULT_INITIAL_CREDITS
+
# -----------------------------------------------------------------------------
# Classes
# -----------------------------------------------------------------------------
+# -----------------------------------------------------------------------------
+class Advertisement:
+ TX_POWER_NOT_AVAILABLE = HCI_LE_Extended_Advertising_Report_Event.TX_POWER_INFORMATION_NOT_AVAILABLE
+ RSSI_NOT_AVAILABLE = HCI_LE_Extended_Advertising_Report_Event.RSSI_NOT_AVAILABLE
+
+ @classmethod
+ def from_advertising_report(cls, report):
+ if isinstance(report, HCI_LE_Advertising_Report_Event.Report):
+ return LegacyAdvertisement.from_advertising_report(report)
+ elif isinstance(report, HCI_LE_Extended_Advertising_Report_Event.Report):
+ return ExtendedAdvertisement.from_advertising_report(report)
+
+ def __init__(
+ self,
+ address,
+ rssi = HCI_LE_Extended_Advertising_Report_Event.RSSI_NOT_AVAILABLE,
+ is_legacy = False,
+ is_anonymous = False,
+ is_connectable = False,
+ is_directed = False,
+ is_scannable = False,
+ is_scan_response = False,
+ is_complete = True,
+ is_truncated = False,
+ primary_phy = 0,
+ secondary_phy = 0,
+ tx_power = HCI_LE_Extended_Advertising_Report_Event.TX_POWER_INFORMATION_NOT_AVAILABLE,
+ sid = 0,
+ data = b''
+ ):
+ self.address = address
+ self.rssi = rssi
+ self.is_legacy = is_legacy
+ self.is_anonymous = is_anonymous
+ self.is_connectable = is_connectable
+ self.is_directed = is_directed
+ self.is_scannable = is_scannable
+ self.is_scan_response = is_scan_response
+ self.is_complete = is_complete
+ self.is_truncated = is_truncated
+ self.primary_phy = primary_phy
+ self.secondary_phy = secondary_phy
+ self.tx_power = tx_power
+ self.sid = sid
+ self.data = AdvertisingData.from_bytes(data)
+
+
+# -----------------------------------------------------------------------------
+class LegacyAdvertisement(Advertisement):
+ @classmethod
+ def from_advertising_report(cls, report):
+ return cls(
+ address = report.address,
+ rssi = report.rssi,
+ is_legacy = True,
+ is_connectable = report.event_type in {
+ HCI_LE_Advertising_Report_Event.ADV_IND,
+ HCI_LE_Advertising_Report_Event.ADV_DIRECT_IND
+ },
+ is_directed = report.event_type == HCI_LE_Advertising_Report_Event.ADV_DIRECT_IND,
+ is_scannable = report.event_type in {
+ HCI_LE_Advertising_Report_Event.ADV_IND,
+ HCI_LE_Advertising_Report_Event.ADV_SCAN_IND
+ },
+ is_scan_response = report.event_type == HCI_LE_Advertising_Report_Event.SCAN_RSP,
+ data = report.data
+ )
+
+
+# -----------------------------------------------------------------------------
+class ExtendedAdvertisement(Advertisement):
+ @classmethod
+ def from_advertising_report(cls, report):
+ return cls(
+ address = report.address,
+ rssi = report.rssi,
+ is_legacy = report.event_type & (1 << HCI_LE_Extended_Advertising_Report_Event.LEGACY_ADVERTISING_PDU_USED) != 0,
+ is_anonymous = report.address.address_type == HCI_LE_Extended_Advertising_Report_Event.ANONYMOUS_ADDRESS_TYPE,
+ is_connectable = report.event_type & (1 << HCI_LE_Extended_Advertising_Report_Event.CONNECTABLE_ADVERTISING) != 0,
+ is_directed = report.event_type & (1 << HCI_LE_Extended_Advertising_Report_Event.DIRECTED_ADVERTISING) != 0,
+ is_scannable = report.event_type & (1 << HCI_LE_Extended_Advertising_Report_Event.SCANNABLE_ADVERTISING) != 0,
+ is_scan_response = report.event_type & (1 << HCI_LE_Extended_Advertising_Report_Event.SCAN_RESPONSE) != 0,
+ is_complete = (report.event_type >> 5 & 3) == HCI_LE_Extended_Advertising_Report_Event.DATA_COMPLETE,
+ is_truncated = (report.event_type >> 5 & 3) == HCI_LE_Extended_Advertising_Report_Event.DATA_INCOMPLETE_TRUNCATED_NO_MORE_TO_COME,
+ primary_phy = report.primary_phy,
+ secondary_phy = report.secondary_phy,
+ tx_power = report.tx_power,
+ sid = report.advertising_sid,
+ data = report.data
+ )
+
# -----------------------------------------------------------------------------
class AdvertisementDataAccumulator:
- def __init__(self):
- self.advertising_data = AdvertisingData()
- self.last_advertisement_type = None
- self.connectable = False
- self.flushable = False
+ def __init__(self, passive=False):
+ self.passive = passive
+ self.last_advertisement = None
+ self.last_data = b''
- def update(self, data, advertisement_type):
- if advertisement_type == HCI_LE_Advertising_Report_Event.SCAN_RSP:
- if self.last_advertisement_type != HCI_LE_Advertising_Report_Event.SCAN_RSP:
- self.advertising_data.append(data)
- self.flushable = True
+ def update(self, report):
+ advertisement = Advertisement.from_advertising_report(report)
+ result = None
+
+ if advertisement.is_scan_response:
+ if self.last_advertisement is not None and not self.last_advertisement.is_scan_response:
+ # This is the response to a scannable advertisement
+ result = Advertisement.from_advertising_report(report)
+ result.is_connectable = self.last_advertisement.is_connectable
+ result.is_scannable = True
+ result.data = AdvertisingData.from_bytes(self.last_data + report.data)
+ self.last_data = b''
else:
- self.advertising_data = AdvertisingData.from_bytes(data)
- self.flushable = self.last_advertisement_type != HCI_LE_Advertising_Report_Event.SCAN_RSP
+ if (
+ self.passive or
+ (not advertisement.is_scannable) or
+ (self.last_advertisement is not None and not self.last_advertisement.is_scan_response)
+ ):
+ # Don't wait for a scan response
+ result = Advertisement.from_advertising_report(report)
- if advertisement_type == HCI_LE_Advertising_Report_Event.ADV_IND or advertisement_type == HCI_LE_Advertising_Report_Event.ADV_DIRECT_IND:
- self.connectable = True
- elif advertisement_type == HCI_LE_Advertising_Report_Event.ADV_SCAN_IND or advertisement_type == HCI_LE_Advertising_Report_Event.ADV_NONCONN_IND:
- self.connectable = False
+ self.last_data = report.data
- self.last_advertisement_type = advertisement_type
+ self.last_advertisement = advertisement
+
+ return result
+
+
+# -----------------------------------------------------------------------------
+class AdvertisingType(IntEnum):
+ UNDIRECTED_CONNECTABLE_SCANNABLE = 0x00 # Undirected, connectable, scannable
+ DIRECTED_CONNECTABLE_HIGH_DUTY = 0x01 # Directed, connectable, non-scannable
+ UNDIRECTED_SCANNABLE = 0x02 # Undirected, non-connectable, scannable
+ UNDIRECTED = 0x03 # Undirected, non-connectable, non-scannable
+ DIRECTED_CONNECTABLE_LOW_DUTY = 0x04 # Directed, connectable, non-scannable
+
+ @property
+ def has_data(self):
+ return self in {
+ AdvertisingType.UNDIRECTED_CONNECTABLE_SCANNABLE,
+ AdvertisingType.UNDIRECTED_SCANNABLE,
+ AdvertisingType.UNDIRECTED
+ }
+
+ @property
+ def is_connectable(self):
+ return self in {
+ AdvertisingType.UNDIRECTED_CONNECTABLE_SCANNABLE,
+ AdvertisingType.DIRECTED_CONNECTABLE_HIGH_DUTY,
+ AdvertisingType.DIRECTED_CONNECTABLE_LOW_DUTY
+ }
+
+ @property
+ def is_scannable(self):
+ return self in {
+ AdvertisingType.UNDIRECTED_CONNECTABLE_SCANNABLE,
+ AdvertisingType.UNDIRECTED_SCANNABLE
+ }
+
+ @property
+ def is_directed(self):
+ return self in {
+ AdvertisingType.DIRECTED_CONNECTABLE_HIGH_DUTY,
+ AdvertisingType.DIRECTED_CONNECTABLE_LOW_DUTY
+ }
+
+
+# -----------------------------------------------------------------------------
+class LePhyOptions:
+ # Coded PHY preference
+ ANY_CODED_PHY = 0
+ PREFER_S_2_CODED_PHY = 1
+ PREFER_S_8_CODED_PHY = 2
+
+ def __init__(self, coded_phy_preference=0):
+ self.coded_phy_preference = coded_phy_preference
+
+ def __int__(self):
+ return self.coded_phy_preference & 3
# -----------------------------------------------------------------------------
@@ -100,7 +272,9 @@
return self.gatt_client.services
async def request_mtu(self, mtu):
- return await self.gatt_client.request_mtu(mtu)
+ mtu = await self.gatt_client.request_mtu(mtu)
+ self.connection.emit('connection_att_mtu_update')
+ return mtu
async def discover_service(self, uuid):
return await self.gatt_client.discover_service(uuid)
@@ -120,8 +294,8 @@
async def discover_attributes(self):
return await self.gatt_client.discover_attributes()
- async def subscribe(self, characteristic, subscriber=None):
- return await self.gatt_client.subscribe(characteristic, subscriber)
+ async def subscribe(self, characteristic, subscriber=None, prefer_notify=True):
+ return await self.gatt_client.subscribe(characteristic, subscriber, prefer_notify)
async def unsubscribe(self, characteristic, subscriber=None):
return await self.gatt_client.unsubscribe(characteristic, subscriber)
@@ -169,12 +343,25 @@
async def __aexit__(self, exc_type, exc_value, traceback):
pass
-
def __str__(self):
return f'{self.connection.peer_address} as {self.connection.role_name}'
# -----------------------------------------------------------------------------
+@dataclass
+class ConnectionParametersPreferences:
+ connection_interval_min: int = DEVICE_DEFAULT_CONNECTION_INTERVAL_MIN
+ connection_interval_max: int = DEVICE_DEFAULT_CONNECTION_INTERVAL_MAX
+ max_latency: int = DEVICE_DEFAULT_CONNECTION_MAX_LATENCY
+ supervision_timeout: int = DEVICE_DEFAULT_CONNECTION_SUPERVISION_TIMEOUT
+ min_ce_length: int = DEVICE_DEFAULT_CONNECTION_MIN_CE_LENGTH
+ max_ce_length: int = DEVICE_DEFAULT_CONNECTION_MAX_CE_LENGTH
+
+
+ConnectionParametersPreferences.default = ConnectionParametersPreferences()
+
+
+# -----------------------------------------------------------------------------
class Connection(CompositeEventEmitter):
@composite_listener
class Listener:
@@ -202,11 +389,23 @@
def on_connection_encryption_key_refresh(self):
pass
- def __init__(self, device, handle, transport, peer_address, peer_resolvable_address, role, parameters):
+ def __init__(
+ self,
+ device,
+ handle,
+ transport,
+ self_address,
+ peer_address,
+ peer_resolvable_address,
+ role,
+ parameters,
+ phy
+ ):
super().__init__()
self.device = device
self.handle = handle
self.transport = transport
+ self.self_address = self_address
self.peer_address = peer_address
self.peer_resolvable_address = peer_resolvable_address
self.peer_name = None # Classic only
@@ -214,7 +413,7 @@
self.parameters = parameters
self.encryption = 0
self.authenticated = False
- self.phy = ConnectionPHY(HCI_LE_1M_PHY, HCI_LE_1M_PHY)
+ self.phy = phy
self.att_mtu = ATT_DEFAULT_MTU
self.data_length = DEVICE_DEFAULT_DATA_LENGTH
self.gatt_client = None # Per-connection client
@@ -234,7 +433,16 @@
def create_l2cap_connector(self, psm):
return self.device.create_l2cap_connector(self, psm)
- async def disconnect(self, reason = HCI_REMOTE_USER_TERMINATED_CONNECTION_ERROR):
+ async def open_l2cap_channel(
+ self,
+ psm,
+ max_credits=DEVICE_DEFAULT_L2CAP_COC_MAX_CREDITS,
+ mtu=DEVICE_DEFAULT_L2CAP_COC_MTU,
+ mps=DEVICE_DEFAULT_L2CAP_COC_MPS
+ ):
+ return await self.device.open_l2cap_channel(self, psm, max_credits, mtu, mps)
+
+ async def disconnect(self, reason=HCI_REMOTE_USER_TERMINATED_CONNECTION_ERROR):
return await self.device.disconnect(self, reason)
async def pair(self):
@@ -267,19 +475,28 @@
async def update_parameters(
self,
- conn_interval_min,
- conn_interval_max,
- conn_latency,
+ connection_interval_min,
+ connection_interval_max,
+ max_latency,
supervision_timeout
):
return await self.device.update_connection_parameters(
self,
- conn_interval_min,
- conn_interval_max,
- conn_latency,
+ connection_interval_min,
+ connection_interval_max,
+ max_latency,
supervision_timeout
)
+ async def set_phy(self, tx_phys=None, rx_phys=None, phy_options=None):
+ return await self.device.set_connection_phy(self, tx_phys, rx_phys, phy_options)
+
+ async def get_rssi(self):
+ return await self.device.get_connection_rssi(self)
+
+ async def get_phy(self):
+ return await self.device.get_connection_phy(self)
+
# [Classic only]
async def request_remote_name(self):
return await self.device.request_remote_name(self)
@@ -315,6 +532,7 @@
self.le_simultaneous_enabled = True
self.classic_sc_enabled = True
self.classic_ssp_enabled = True
+ self.classic_accept_any = True
self.connectable = True
self.discoverable = True
self.advertising_data = bytes(
@@ -335,6 +553,7 @@
self.le_simultaneous_enabled = config.get('le_simultaneous_enabled', self.le_simultaneous_enabled)
self.classic_sc_enabled = config.get('classic_sc_enabled', self.classic_sc_enabled)
self.classic_ssp_enabled = config.get('classic_ssp_enabled', self.classic_ssp_enabled)
+ self.classic_accept_any = config.get('classic_accept_any', self.classic_accept_any)
self.connectable = config.get('connectable', self.connectable)
self.discoverable = config.get('discoverable', self.discoverable)
@@ -357,6 +576,7 @@
with open(filename, 'r') as file:
self.load_from_dict(json.load(file))
+
# -----------------------------------------------------------------------------
# Decorators used with the following Device class
# (we define them outside of the Device class, because defining decorators
@@ -385,6 +605,17 @@
return wrapper
+# Decorator that tries to convert the first argument from a bluetooth address to a connection
+def try_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, address, *args, **kwargs)
+ return function(self, None, address, *args, **kwargs)
+ return wrapper
+
+
# Decorator that adds a method to the list of event handlers for host events.
# This assumes that the method name starts with `on_`
def host_event_handler(function):
@@ -403,7 +634,7 @@
@composite_listener
class Listener:
- def on_advertisement(self, address, data, rssi, advertisement_type):
+ def on_advertisement(self, advertisement):
pass
def on_inquiry_result(self, address, class_of_device, data, rssi):
@@ -415,6 +646,9 @@
def on_connection_failure(self, error):
pass
+ def on_connection_request(self, bd_addr, class_of_device, link_type):
+ pass
+
def on_characteristic_subscription(self, connection, characteristic, notify_enabled, indicate_enabled):
pass
@@ -443,24 +677,33 @@
def __init__(self, name = None, address = None, config = None, host = None, generic_access_service = True):
super().__init__()
- self._host = None
- self.powered_on = False
- self.advertising = False
- self.auto_restart_advertising = False
- self.command_timeout = 10 # seconds
- self.gatt_server = gatt_server.Server(self)
- self.sdp_server = sdp.Server(self)
- self.l2cap_channel_manager = l2cap.ChannelManager(
- [l2cap.L2CAP_Information_Request.EXTENDED_FEATURE_FIXED_CHANNELS])
- self.advertisement_data = {}
- self.scanning = False
- self.discovering = False
- self.connecting = False
- self.disconnecting = False
- self.connections = {} # Connections, by connection handle
- self.classic_enabled = False
- self.inquiry_response = None
- self.address_resolver = None
+ self._host = None
+ self.powered_on = False
+ self.advertising = False
+ self.advertising_type = None
+ self.auto_restart_inquiry = True
+ self.auto_restart_advertising = False
+ self.command_timeout = 10 # seconds
+ self.gatt_server = gatt_server.Server(self)
+ self.sdp_server = sdp.Server(self)
+ self.l2cap_channel_manager = l2cap.ChannelManager(
+ [l2cap.L2CAP_Information_Request.EXTENDED_FEATURE_FIXED_CHANNELS]
+ )
+ self.advertisement_accumulators = {} # Accumulators, by address
+ self.scanning = False
+ self.scanning_is_passive = False
+ self.discovering = False
+ self.le_connecting = False
+ self.disconnecting = False
+ self.connections = {} # Connections, by connection handle
+ self.classic_enabled = False
+ self.inquiry_response = None
+ self.address_resolver = None
+ self.classic_pending_accepts = {Address.ANY: []} # Futures, by BD address OR [Futures] for Address.ANY
+
+ # Own address type cache
+ self.advertising_own_address_type = None
+ self.connect_own_address_type = None
# Use the initial config or a default
self.public_address = Address('00:00:00:00:00:00')
@@ -481,6 +724,7 @@
self.classic_sc_enabled = config.classic_sc_enabled
self.discoverable = config.discoverable
self.connectable = config.connectable
+ self.classic_accept_any = config.classic_accept_any
# If a name is passed, override the name from the config
if name:
@@ -493,8 +737,7 @@
self.random_address = address
# Setup SMP
- # TODO: allow using a public address
- self.smp_manager = smp.Manager(self, self.random_address)
+ self.smp_manager = smp.Manager(self)
self.l2cap_channel_manager.register_fixed_channel(
smp.SMP_CID, self.on_smp_pdu)
self.l2cap_channel_manager.register_fixed_channel(
@@ -551,29 +794,55 @@
if connection := self.connections.get(connection_handle):
return connection
- def find_connection_by_bd_addr(self, bd_addr, transport=None):
+ def find_connection_by_bd_addr(self, bd_addr, transport=None, check_address_type=False):
for connection in self.connections.values():
if connection.peer_address.get_bytes() == bd_addr.get_bytes():
+ if check_address_type and connection.peer_address.address_type != bd_addr.address_type:
+ continue
if transport is None or connection.transport == transport:
return connection
- def register_l2cap_server(self, psm, server):
- self.l2cap_channel_manager.register_server(psm, server)
-
def create_l2cap_connector(self, connection, psm):
return lambda: self.l2cap_channel_manager.connect(connection, psm)
def create_l2cap_registrar(self, psm):
return lambda handler: self.register_l2cap_server(psm, handler)
+ def register_l2cap_server(self, psm, server):
+ self.l2cap_channel_manager.register_server(psm, server)
+
+ def register_l2cap_channel_server(
+ self,
+ psm,
+ server,
+ max_credits=DEVICE_DEFAULT_L2CAP_COC_MAX_CREDITS,
+ mtu=DEVICE_DEFAULT_L2CAP_COC_MTU,
+ mps=DEVICE_DEFAULT_L2CAP_COC_MPS
+ ):
+ return self.l2cap_channel_manager.register_le_coc_server(psm, server, max_credits, mtu, mps)
+
+ async def open_l2cap_channel(
+ self,
+ connection,
+ psm,
+ max_credits=DEVICE_DEFAULT_L2CAP_COC_MAX_CREDITS,
+ mtu=DEVICE_DEFAULT_L2CAP_COC_MTU,
+ mps=DEVICE_DEFAULT_L2CAP_COC_MPS
+ ):
+ return await self.l2cap_channel_manager.open_le_coc(connection, psm, max_credits, mtu, mps)
+
def send_l2cap_pdu(self, connection_handle, cid, pdu):
self.host.send_l2cap_pdu(connection_handle, cid, pdu)
- async def send_command(self, command):
+ async def send_command(self, command, check_result=False):
try:
- return await asyncio.wait_for(self.host.send_command(command), self.command_timeout)
+ return await asyncio.wait_for(
+ self.host.send_command(command, check_result),
+ self.command_timeout
+ )
except asyncio.TimeoutError:
logger.warning('!!! Command timed out')
+ raise CommandTimeoutError()
async def power_on(self):
# Reset the controller
@@ -594,10 +863,10 @@
# Set the controller address
await self.send_command(HCI_LE_Set_Random_Address_Command(
random_address = self.random_address
- ))
+ ), check_result=True)
# Load the address resolving list
- if self.keystore:
+ if self.keystore and self.host.supports_command(HCI_LE_CLEAR_RESOLVING_LIST_COMMAND):
await self.send_command(HCI_LE_Clear_Resolving_List_Command())
resolving_keys = await self.keystore.get_resolving_keys()
@@ -644,51 +913,89 @@
# Done
self.powered_on = True
- async def start_advertising(self, auto_restart=False):
- self.auto_restart_advertising = auto_restart
+ def supports_le_feature(self, feature):
+ return self.host.supports_le_feature(feature)
+ def supports_le_phy(self, phy):
+ if phy == HCI_LE_1M_PHY:
+ return True
+
+ feature_map = {
+ HCI_LE_2M_PHY: HCI_LE_2M_PHY_LE_SUPPORTED_FEATURE,
+ HCI_LE_CODED_PHY: HCI_LE_CODED_PHY_LE_SUPPORTED_FEATURE
+ }
+ if phy not in feature_map:
+ raise ValueError('invalid PHY')
+
+ return self.host.supports_le_feature(feature_map[phy])
+
+ async def start_advertising(
+ self,
+ advertising_type=AdvertisingType.UNDIRECTED_CONNECTABLE_SCANNABLE,
+ target=None,
+ own_address_type=OwnAddressType.RANDOM,
+ auto_restart=False
+ ):
# If we're advertising, stop first
if self.advertising:
await self.stop_advertising()
- # Set/update the advertising data
- await self.send_command(HCI_LE_Set_Advertising_Data_Command(
- advertising_data = self.advertising_data
- ))
+ # Set/update the advertising data if the advertising type allows it
+ if advertising_type.has_data:
+ await self.send_command(HCI_LE_Set_Advertising_Data_Command(
+ advertising_data = self.advertising_data
+ ), check_result=True)
- # Set/update the scan response data
- await self.send_command(HCI_LE_Set_Scan_Response_Data_Command(
- scan_response_data = self.scan_response_data
- ))
+ # Set/update the scan response data if the advertising is scannable
+ if advertising_type.is_scannable:
+ await self.send_command(HCI_LE_Set_Scan_Response_Data_Command(
+ scan_response_data = self.scan_response_data
+ ), check_result=True)
+
+ # Decide what peer address to use
+ if advertising_type.is_directed:
+ if target is None:
+ raise ValueError('directed advertising requires a target address')
+
+ peer_address = target
+ peer_address_type = target.address_type
+ else:
+ peer_address = Address('00:00:00:00:00:00')
+ peer_address_type = Address.PUBLIC_DEVICE_ADDRESS
# Set the advertising parameters
await self.send_command(HCI_LE_Set_Advertising_Parameters_Command(
- # TODO: use real values, not fixed ones
advertising_interval_min = self.advertising_interval_min,
advertising_interval_max = self.advertising_interval_max,
- advertising_type = HCI_LE_Set_Advertising_Parameters_Command.ADV_IND,
- own_address_type = Address.RANDOM_DEVICE_ADDRESS, # TODO: allow using the public address
- peer_address_type = Address.PUBLIC_DEVICE_ADDRESS,
- peer_address = Address('00:00:00:00:00:00'),
+ advertising_type = int(advertising_type),
+ own_address_type = own_address_type,
+ peer_address_type = peer_address_type,
+ peer_address = peer_address,
advertising_channel_map = 7,
advertising_filter_policy = 0
- ))
+ ), check_result=True)
# Enable advertising
await self.send_command(HCI_LE_Set_Advertising_Enable_Command(
advertising_enable = 1
- ))
+ ), check_result=True)
- self.advertising = True
+ self.advertising_own_address_type = own_address_type
+ self.auto_restart_advertising = auto_restart
+ self.advertising_type = advertising_type
+ self.advertising = True
async def stop_advertising(self):
# Disable advertising
if self.advertising:
await self.send_command(HCI_LE_Set_Advertising_Enable_Command(
advertising_enable = 0
- ))
+ ), check_result=True)
- self.advertising = False
+ self.advertising_own_address_type = None
+ self.advertising = False
+ self.advertising_type = None
+ self.auto_restart_advertising = False
@property
def is_advertising(self):
@@ -696,11 +1003,13 @@
async def start_scanning(
self,
+ legacy=False,
active=True,
scan_interval=DEVICE_DEFAULT_SCAN_INTERVAL, # Scan interval in ms
scan_window=DEVICE_DEFAULT_SCAN_WINDOW, # Scan window in ms
- own_address_type=Address.RANDOM_DEVICE_ADDRESS,
- filter_duplicates=False
+ own_address_type=OwnAddressType.RANDOM,
+ filter_duplicates=False,
+ scanning_phys=(HCI_LE_1M_PHY, HCI_LE_CODED_PHY)
):
# Check that the arguments are legal
if scan_interval < scan_window:
@@ -710,28 +1019,79 @@
if scan_window < DEVICE_MIN_SCAN_WINDOW or scan_window > DEVICE_MAX_SCAN_WINDOW:
raise ValueError('scan_interval out of range')
- # Set the scanning parameters
- scan_type = HCI_LE_Set_Scan_Parameters_Command.ACTIVE_SCANNING if active else HCI_LE_Set_Scan_Parameters_Command.PASSIVE_SCANNING
- await self.send_command(HCI_LE_Set_Scan_Parameters_Command(
- le_scan_type = scan_type,
- le_scan_interval = int(scan_window / 0.625),
- le_scan_window = int(scan_window / 0.625),
- own_address_type = own_address_type,
- scanning_filter_policy = HCI_LE_Set_Scan_Parameters_Command.BASIC_UNFILTERED_POLICY
- ))
+ # Reset the accumulators
+ self.advertisement_accumulator = {}
# Enable scanning
- await self.send_command(HCI_LE_Set_Scan_Enable_Command(
- le_scan_enable = 1,
- filter_duplicates = 1 if filter_duplicates else 0
- ))
- self.scanning = True
+ if not legacy and self.supports_le_feature(HCI_LE_EXTENDED_ADVERTISING_LE_SUPPORTED_FEATURE):
+ # Set the scanning parameters
+ scan_type = HCI_LE_Set_Extended_Scan_Parameters_Command.ACTIVE_SCANNING if active else HCI_LE_Set_Extended_Scan_Parameters_Command.PASSIVE_SCANNING
+ scanning_filter_policy = HCI_LE_Set_Extended_Scan_Parameters_Command.BASIC_UNFILTERED_POLICY # TODO: support other types
+
+ scanning_phy_count = 0
+ scanning_phys_bits = 0
+ if HCI_LE_1M_PHY in scanning_phys:
+ scanning_phys_bits |= 1 << HCI_LE_1M_PHY_BIT
+ scanning_phy_count += 1
+ if HCI_LE_CODED_PHY in scanning_phys:
+ if self.supports_le_feature(HCI_LE_CODED_PHY_LE_SUPPORTED_FEATURE):
+ scanning_phys_bits |= 1 << HCI_LE_CODED_PHY_BIT
+ scanning_phy_count += 1
+
+ if scanning_phy_count == 0:
+ raise ValueError('at least one scanning PHY must be enabled')
+
+ await self.send_command(HCI_LE_Set_Extended_Scan_Parameters_Command(
+ own_address_type = own_address_type,
+ scanning_filter_policy = scanning_filter_policy,
+ scanning_phys = scanning_phys_bits,
+ scan_types = [scan_type] * scanning_phy_count,
+ scan_intervals = [int(scan_window / 0.625)] * scanning_phy_count,
+ scan_windows = [int(scan_window / 0.625)] * scanning_phy_count
+ ), check_result=True)
+
+ # Enable scanning
+ await self.send_command(HCI_LE_Set_Extended_Scan_Enable_Command(
+ enable = 1,
+ filter_duplicates = 1 if filter_duplicates else 0,
+ duration = 0, # TODO allow other values
+ period = 0 # TODO allow other values
+ ), check_result=True)
+ else:
+ # Set the scanning parameters
+ scan_type = HCI_LE_Set_Scan_Parameters_Command.ACTIVE_SCANNING if active else HCI_LE_Set_Scan_Parameters_Command.PASSIVE_SCANNING
+ await self.send_command(HCI_LE_Set_Scan_Parameters_Command(
+ le_scan_type = scan_type,
+ le_scan_interval = int(scan_window / 0.625),
+ le_scan_window = int(scan_window / 0.625),
+ own_address_type = own_address_type,
+ scanning_filter_policy = HCI_LE_Set_Scan_Parameters_Command.BASIC_UNFILTERED_POLICY
+ ), check_result=True)
+
+ # Enable scanning
+ await self.send_command(HCI_LE_Set_Scan_Enable_Command(
+ le_scan_enable = 1,
+ filter_duplicates = 1 if filter_duplicates else 0
+ ), check_result=True)
+
+ self.scanning_is_passive = not active
+ self.scanning = True
async def stop_scanning(self):
- await self.send_command(HCI_LE_Set_Scan_Enable_Command(
- le_scan_enable = 0,
- filter_duplicates = 0
- ))
+ # Disable scanning
+ if self.supports_le_feature(HCI_LE_EXTENDED_ADVERTISING_LE_SUPPORTED_FEATURE):
+ await self.send_command(HCI_LE_Set_Extended_Scan_Enable_Command(
+ enable = 0,
+ filter_duplicates = 0,
+ duration = 0,
+ period = 0
+ ), check_result=True)
+ else:
+ await self.send_command(HCI_LE_Set_Scan_Enable_Command(
+ le_scan_enable = 0,
+ filter_duplicates = 0
+ ), check_result=True)
+
self.scanning = False
@property
@@ -739,22 +1099,17 @@
return self.scanning
@host_event_handler
- def on_advertising_report(self, address, data, rssi, advertisement_type):
- if not (accumulator := self.advertisement_data.get(address)):
- accumulator = AdvertisementDataAccumulator()
- self.advertisement_data[address] = accumulator
- accumulator.update(data, advertisement_type)
- if accumulator.flushable:
- self.emit(
- 'advertisement',
- address,
- accumulator.advertising_data,
- rssi,
- accumulator.connectable
- )
+ def on_advertising_report(self, report):
+ if not (accumulator := self.advertisement_accumulators.get(report.address)):
+ accumulator = AdvertisementDataAccumulator(passive=self.scanning_is_passive)
+ self.advertisement_accumulators[report.address] = accumulator
+ if advertisement := accumulator.update(report):
+ self.emit('advertisement', advertisement)
- async def start_discovery(self):
- await self.host.send_command(HCI_Write_Inquiry_Mode_Command(inquiry_mode=HCI_EXTENDED_INQUIRY_MODE))
+ async def start_discovery(self, auto_restart=True):
+ await self.send_command(HCI_Write_Inquiry_Mode_Command(
+ inquiry_mode=HCI_EXTENDED_INQUIRY_MODE
+ ), check_result=True)
response = await self.send_command(HCI_Inquiry_Command(
lap = HCI_GENERAL_INQUIRY_LAP,
@@ -765,11 +1120,14 @@
self.discovering = False
raise HCI_StatusError(response)
- self.discovering = True
+ self.auto_restart_inquiry = auto_restart
+ self.discovering = True
async def stop_discovery(self):
- await self.send_command(HCI_Inquiry_Cancel_Command())
- self.discovering = False
+ if self.discovering:
+ await self.send_command(HCI_Inquiry_Cancel_Command(), check_result=True)
+ self.auto_restart_inquiry = True
+ self.discovering = False
@host_event_handler
def on_inquiry_result(self, address, class_of_device, data, rssi):
@@ -805,11 +1163,12 @@
)
# Update the controller
- await self.host.send_command(
+ await self.send_command(
HCI_Write_Extended_Inquiry_Response_Command(
fec_required = 0,
extended_inquiry_response = self.inquiry_response
- )
+ ),
+ check_result=True
)
await self.set_scan_enable(
inquiry_scan_enabled = self.discoverable,
@@ -824,12 +1183,29 @@
page_scan_enabled = self.connectable
)
- async def connect(self, peer_address, transport=BT_LE_TRANSPORT):
+ async def connect(
+ self,
+ peer_address,
+ transport=BT_LE_TRANSPORT,
+ connection_parameters_preferences=None,
+ own_address_type=OwnAddressType.RANDOM,
+ timeout=DEVICE_DEFAULT_CONNECT_TIMEOUT
+ ):
'''
Request a connection to a peer.
- This method cannot be called if there is already a pending connection.
+ When transport is BLE, this method cannot be called if there is already a pending connection.
+
+ connection_parameters_preferences: (BLE only, ignored for BR/EDR)
+ * None: use all PHYs with default parameters
+ * map: each entry has a PHY as key and a ConnectionParametersPreferences object as value
+
+ own_address_type: (BLE only)
'''
+ # Check parameters
+ if transport not in {BT_LE_TRANSPORT, BT_BR_EDR_TRANSPORT}:
+ raise ValueError('invalid transport')
+
# Adjust the transport automatically if we need to
if transport == BT_LE_TRANSPORT and not self.le_enabled:
transport = BT_BR_EDR_TRANSPORT
@@ -837,62 +1213,235 @@
transport = BT_LE_TRANSPORT
# Check that there isn't already a pending connection
- if self.is_connecting:
+ if transport == BT_LE_TRANSPORT and self.is_le_connecting:
raise InvalidStateError('connection already pending')
if type(peer_address) is str:
try:
+ peer_address = Address.from_string_for_transport(peer_address, transport)
+ except ValueError:
+ # If the address is not parsable, assume it is a name instead
+ logger.debug('looking for peer by name')
+ peer_address = await self.find_peer_by_name(peer_address, transport) # TODO: timeout
+ else:
+ # All BR/EDR addresses should be public addresses
+ if transport == BT_BR_EDR_TRANSPORT and peer_address.address_type != Address.PUBLIC_DEVICE_ADDRESS:
+ raise ValueError('BR/EDR addresses must be PUBLIC')
+
+ def on_connection(connection):
+ if transport == BT_LE_TRANSPORT or (
+ # match BR/EDR connection event against peer address
+ connection.transport == transport and connection.peer_address == peer_address
+ ):
+ pending_connection.set_result(connection)
+
+ def on_connection_failure(error):
+ if transport == BT_LE_TRANSPORT or (
+ # match BR/EDR connection failure event against peer address
+ error.transport == transport and error.peer_address == peer_address
+ ):
+ pending_connection.set_exception(error)
+
+ # Create a future so that we can wait for the connection's result
+ pending_connection = asyncio.get_running_loop().create_future()
+ self.on('connection', on_connection)
+ self.on('connection_failure', on_connection_failure)
+
+ try:
+ # Tell the controller to connect
+ if transport == BT_LE_TRANSPORT:
+ if connection_parameters_preferences is None:
+ if connection_parameters_preferences is None:
+ connection_parameters_preferences = {
+ HCI_LE_1M_PHY: ConnectionParametersPreferences.default,
+ HCI_LE_2M_PHY: ConnectionParametersPreferences.default,
+ HCI_LE_CODED_PHY: ConnectionParametersPreferences.default
+ }
+
+ self.connect_own_address_type = own_address_type
+
+ if self.host.supports_command(HCI_LE_EXTENDED_CREATE_CONNECTION_COMMAND):
+ # Only keep supported PHYs
+ phys = sorted(list(set(filter(self.supports_le_phy, connection_parameters_preferences.keys()))))
+ if not phys:
+ raise ValueError('least one supported PHY needed')
+
+ phy_count = len(phys)
+ initiating_phys = phy_list_to_bits(phys)
+
+ connection_interval_mins = [
+ int(connection_parameters_preferences[phy].connection_interval_min / 1.25) for phy in phys
+ ]
+ connection_interval_maxs = [
+ int(connection_parameters_preferences[phy].connection_interval_max / 1.25) for phy in phys
+ ]
+ max_latencies = [
+ connection_parameters_preferences[phy].max_latency for phy in phys
+ ]
+ supervision_timeouts = [
+ int(connection_parameters_preferences[phy].supervision_timeout / 10) for phy in phys
+ ]
+ min_ce_lengths = [
+ int(connection_parameters_preferences[phy].min_ce_length / 0.625) for phy in phys
+ ]
+ max_ce_lengths = [
+ int(connection_parameters_preferences[phy].max_ce_length / 0.625) for phy in phys
+ ]
+
+ result = await self.send_command(HCI_LE_Extended_Create_Connection_Command(
+ initiator_filter_policy = 0,
+ own_address_type = own_address_type,
+ peer_address_type = peer_address.address_type,
+ peer_address = peer_address,
+ initiating_phys = initiating_phys,
+ scan_intervals = (int(DEVICE_DEFAULT_CONNECT_SCAN_INTERVAL / 0.625),) * phy_count,
+ scan_windows = (int(DEVICE_DEFAULT_CONNECT_SCAN_WINDOW / 0.625),) * phy_count,
+ connection_interval_mins = connection_interval_mins,
+ connection_interval_maxs = connection_interval_maxs,
+ max_latencies = max_latencies,
+ supervision_timeouts = supervision_timeouts,
+ min_ce_lengths = min_ce_lengths,
+ max_ce_lengths = max_ce_lengths
+ ))
+ else:
+ if HCI_LE_1M_PHY not in connection_parameters_preferences:
+ raise ValueError('1M PHY preferences required')
+
+ prefs = connection_parameters_preferences[HCI_LE_1M_PHY]
+ result = await self.send_command(HCI_LE_Create_Connection_Command(
+ le_scan_interval = int(DEVICE_DEFAULT_CONNECT_SCAN_INTERVAL / 0.625),
+ le_scan_window = int(DEVICE_DEFAULT_CONNECT_SCAN_WINDOW / 0.625),
+ initiator_filter_policy = 0,
+ peer_address_type = peer_address.address_type,
+ peer_address = peer_address,
+ own_address_type = own_address_type,
+ connection_interval_min = int(prefs.connection_interval_min / 1.25),
+ connection_interval_max = int(prefs.connection_interval_max / 1.25),
+ max_latency = prefs.max_latency,
+ supervision_timeout = int(prefs.supervision_timeout / 10),
+ min_ce_length = int(prefs.min_ce_length / 0.625),
+ max_ce_length = int(prefs.max_ce_length / 0.625),
+ ))
+ else:
+ # TODO: allow passing other settings
+ result = await self.send_command(HCI_Create_Connection_Command(
+ bd_addr = peer_address,
+ packet_type = 0xCC18, # FIXME: change
+ page_scan_repetition_mode = HCI_R2_PAGE_SCAN_REPETITION_MODE,
+ clock_offset = 0x0000,
+ allow_role_switch = 0x01,
+ reserved = 0
+ ))
+
+ if result.status != HCI_Command_Status_Event.PENDING:
+ raise HCI_StatusError(result)
+
+ # Wait for the connection process to complete
+ if transport == BT_LE_TRANSPORT:
+ self.le_connecting = True
+ if timeout is None:
+ return await pending_connection
+ else:
+ try:
+ return await asyncio.wait_for(asyncio.shield(pending_connection), timeout)
+ except asyncio.TimeoutError:
+ if transport == BT_LE_TRANSPORT:
+ await self.send_command(HCI_LE_Create_Connection_Cancel_Command())
+ else:
+ await self.send_command(HCI_Create_Connection_Cancel_Command(bd_addr=peer_address))
+
+ try:
+ return await pending_connection
+ except ConnectionError:
+ raise TimeoutError()
+ finally:
+ self.remove_listener('connection', on_connection)
+ self.remove_listener('connection_failure', on_connection_failure)
+ if transport == BT_LE_TRANSPORT:
+ self.le_connecting = False
+ self.connect_own_address_type = None
+
+ async def accept(
+ self,
+ peer_address=Address.ANY,
+ role=BT_PERIPHERAL_ROLE,
+ timeout=DEVICE_DEFAULT_CONNECT_TIMEOUT
+ ):
+ '''
+ Wait and accept any incoming connection or a connection from `peer_address` when set.
+
+ Notes:
+ * A `connect` to the same peer will also complete this call.
+ * The `timeout` parameter is only handled while waiting for the connection request,
+ once received and accepeted, the controller shall issue a connection complete event.
+ '''
+
+ if type(peer_address) is str:
+ try:
peer_address = Address(peer_address)
except ValueError:
# If the address is not parsable, assume it is a name instead
logger.debug('looking for peer by name')
- peer_address = await self.find_peer_by_name(peer_address, transport)
+ peer_address = await self.find_peer_by_name(peer_address, BT_BR_EDR_TRANSPORT) # TODO: timeout
+
+ if peer_address == Address.NIL:
+ raise ValueError('accept on nil address')
+
+ # Create a future so that we can wait for the request
+ pending_request = asyncio.get_running_loop().create_future()
+
+ if peer_address == Address.ANY:
+ self.classic_pending_accepts[Address.ANY].append(pending_request)
+ elif peer_address in self.classic_pending_accepts:
+ raise InvalidStateError('accept connection already pending')
+ else:
+ self.classic_pending_accepts[peer_address] = pending_request
+
+ try:
+ # Wait for a request or a completed connection
+ result = await (asyncio.wait_for(pending_request, timeout) if timeout else pending_request)
+ except Exception:
+ # Remove future from device context
+ if peer_address == Address.ANY:
+ self.classic_pending_accepts[Address.ANY].remove(pending_request)
+ else:
+ self.classic_pending_accepts.pop(peer_address)
+ raise
+
+ # Result may already be a completed connection,
+ # see `on_connection` for details
+ if isinstance(result, Connection):
+ return result
+
+ # Otherwise, result came from `on_connection_request`
+ peer_address, class_of_device, link_type = result
+
+ def on_connection(connection):
+ if connection.transport == BT_BR_EDR_TRANSPORT and connection.peer_address == peer_address:
+ pending_connection.set_result(connection)
+
+ def on_connection_failure(error):
+ if error.transport == BT_BR_EDR_TRANSPORT and error.peer_address == peer_address:
+ pending_connection.set_exception(error)
# Create a future so that we can wait for the connection's result
pending_connection = asyncio.get_running_loop().create_future()
- self.on('connection', pending_connection.set_result)
- self.on('connection_failure', pending_connection.set_exception)
-
- # Tell the controller to connect
- if transport == BT_LE_TRANSPORT:
- # TODO: use real values, not fixed ones
- result = await self.send_command(HCI_LE_Create_Connection_Command(
- le_scan_interval = 96,
- le_scan_window = 96,
- initiator_filter_policy = 0,
- peer_address_type = peer_address.address_type,
- peer_address = peer_address,
- own_address_type = Address.RANDOM_DEVICE_ADDRESS,
- conn_interval_min = 12,
- conn_interval_max = 24,
- conn_latency = 0,
- supervision_timeout = 72,
- minimum_ce_length = 0,
- maximum_ce_length = 0
- ))
- else:
- # TODO: use real values, not fixed ones
- result = await self.send_command(HCI_Create_Connection_Command(
- bd_addr = peer_address,
- packet_type = 0xCC18, # FIXME: change
- page_scan_repetition_mode = HCI_R2_PAGE_SCAN_REPETITION_MODE,
- clock_offset = 0x0000,
- allow_role_switch = 0x01,
- reserved = 0
- ))
+ self.on('connection', on_connection)
+ self.on('connection_failure', on_connection_failure)
try:
- if result.status != HCI_Command_Status_Event.PENDING:
- raise HCI_StatusError(result)
+ # Accept connection request
+ await self.send_command(HCI_Accept_Connection_Request_Command(
+ bd_addr = peer_address,
+ role = role
+ ))
- # Wait for the connection process to complete
- self.connecting = True
+ # Wait for connection complete
return await pending_connection
finally:
- self.remove_listener('connection', pending_connection.set_result)
- self.remove_listener('connection_failure', pending_connection.set_exception)
- self.connecting = False
+ self.remove_listener('connection', on_connection)
+ self.remove_listener('connection_failure', on_connection_failure)
@asynccontextmanager
async def connect_as_gatt(self, peer_address):
@@ -903,17 +1452,32 @@
yield peer
@property
- def is_connecting(self):
- return self.connecting
+ def is_le_connecting(self):
+ return self.le_connecting
@property
def is_disconnecting(self):
return self.disconnecting
- async def cancel_connection(self):
- if not self.is_connecting:
- return
- await self.send_command(HCI_LE_Create_Connection_Cancel_Command())
+ async def cancel_connection(self, peer_address=None):
+ # Low-energy: cancel ongoing connection
+ if peer_address is None:
+ if not self.is_le_connecting:
+ return
+ await self.send_command(HCI_LE_Create_Connection_Cancel_Command(), check_result=True)
+
+ # BR/EDR: try to cancel to ongoing connection
+ # NOTE: This API does not prevent from trying to cancel a connection which is not currently being created
+ else:
+ if type(peer_address) is str:
+ try:
+ peer_address = Address(peer_address)
+ except ValueError:
+ # If the address is not parsable, assume it is a name instead
+ logger.debug('looking for peer by name')
+ peer_address = await self.find_peer_by_name(peer_address, BT_BR_EDR_TRANSPORT) # TODO: timeout
+
+ await self.send_command(HCI_Create_Connection_Cancel_Command(bd_addr=peer_address), check_result=True)
async def disconnect(self, connection, reason):
# Create a future so that we can wait for the disconnection's result
@@ -922,7 +1486,9 @@
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))
+ result = await self.send_command(HCI_Disconnect_Command(
+ connection_handle = connection.handle, reason = reason
+ ))
try:
if result.status != HCI_Command_Status_Event.PENDING:
@@ -939,26 +1505,66 @@
async def update_connection_parameters(
self,
connection,
- conn_interval_min,
- conn_interval_max,
- conn_latency,
+ connection_interval_min,
+ connection_interval_max,
+ max_latency,
supervision_timeout,
- minimum_ce_length = 0,
- maximum_ce_length = 0
+ min_ce_length = 0,
+ max_ce_length = 0
):
'''
NOTE: the name of the parameters may look odd, but it just follows the names used in the Bluetooth spec.
'''
await self.send_command(HCI_LE_Connection_Update_Command(
- connection_handle = connection.handle,
- conn_interval_min = conn_interval_min,
- conn_interval_max = conn_interval_max,
- conn_latency = conn_latency,
- supervision_timeout = supervision_timeout,
- minimum_ce_length = minimum_ce_length,
- maximum_ce_length = maximum_ce_length
- ))
- # TODO: check result
+ connection_handle = connection.handle,
+ connection_interval_min = connection_interval_min,
+ connection_interval_max = connection_interval_max,
+ max_latency = max_latency,
+ supervision_timeout = supervision_timeout,
+ min_ce_length = min_ce_length,
+ max_ce_length = max_ce_length
+ ), check_result=True)
+
+ async def get_connection_rssi(self, connection):
+ result = await self.send_command(HCI_Read_RSSI_Command(handle = connection.handle), check_result=True)
+ return result.return_parameters.rssi
+
+ async def get_connection_phy(self, connection):
+ result = await self.send_command(
+ HCI_LE_Read_PHY_Command(connection_handle = connection.handle),
+ check_result=True
+ )
+ return (result.return_parameters.tx_phy, result.return_parameters.rx_phy)
+
+ async def set_connection_phy(
+ self,
+ connection,
+ tx_phys=None,
+ rx_phys=None,
+ phy_options=None
+ ):
+ all_phys_bits = (1 if tx_phys is None else 0) | ((1 if rx_phys is None else 0) << 1)
+
+ return await self.send_command(
+ HCI_LE_Set_PHY_Command(
+ connection_handle = connection.handle,
+ all_phys = all_phys_bits,
+ tx_phys = phy_list_to_bits(tx_phys),
+ rx_phys = phy_list_to_bits(rx_phys),
+ phy_options = 0 if phy_options is None else int(phy_options)
+ ), check_result=True
+ )
+
+ async def set_default_phy(self, tx_phys=None, rx_phys=None):
+ all_phys_bits = (1 if tx_phys is None else 0) | ((1 if rx_phys is None else 0) << 1)
+
+ return await self.send_command(
+ HCI_LE_Set_Default_PHY_Command(
+ all_phys = all_phys_bits,
+ tx_phys = phy_list_to_bits(tx_phys),
+ rx_phys = phy_list_to_bits(rx_phys)
+ ), check_result=True
+ )
async def find_peer_by_name(self, name, transport=BT_LE_TRANSPORT):
"""
@@ -970,9 +1576,9 @@
# Scan/inquire with event handlers to handle scan/inquiry results
def on_peer_found(address, ad_data):
- local_name = ad_data.get(AdvertisingData.COMPLETE_LOCAL_NAME)
+ local_name = ad_data.get(AdvertisingData.COMPLETE_LOCAL_NAME, raw=True)
if local_name is None:
- local_name = ad_data.get(AdvertisingData.SHORTENED_LOCAL_NAME)
+ local_name = ad_data.get(AdvertisingData.SHORTENED_LOCAL_NAME, raw=True)
if local_name is not None:
if local_name.decode('utf-8') == name:
peer_address.set_result(address)
@@ -982,8 +1588,7 @@
event_name = 'advertisement'
handler = self.on(
event_name,
- lambda address, ad_data, rssi, connectable:
- on_peer_found(address, ad_data)
+ lambda advertisement: on_peer_found(advertisement.address, advertisement.data)
)
was_scanning = self.scanning
@@ -1155,23 +1760,37 @@
connection.remove_listener('connection_encryption_failure', on_encryption_failure)
# [Classic only]
- async def request_remote_name(self, connection):
+ async def request_remote_name(self, remote): # remote: Connection | Address
# 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)
+ if type(remote) == Address:
+ peer_address = remote
+ handler = self.on(
+ 'remote_name',
+ lambda address, remote_name:
+ pending_name.set_result(remote_name) if address == remote else None
+ )
+ failure_handler = self.on(
+ 'remote_name_failure',
+ lambda address, error_code:
+ pending_name.set_exception(HCI_Error(error_code)) if address == remote else None
+ )
+ else:
+ peer_address = remote.peer_address
+ handler = remote.on(
+ 'remote_name',
+ lambda: pending_name.set_result(remote.peer_name)
+ )
+ failure_handler = remote.on(
+ 'remote_name_failure',
+ lambda error_code: pending_name.set_exception(HCI_Error(error_code))
+ )
try:
result = await self.send_command(
HCI_Remote_Name_Request_Command(
- bd_addr = connection.peer_address,
+ bd_addr = 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
@@ -1185,8 +1804,12 @@
# 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)
+ if type(remote) == Address:
+ self.remove_listener('remote_name', handler)
+ self.remove_listener('remote_name_failure', failure_handler)
+ else:
+ remote.remove_listener('remote_name', handler)
+ remote.remove_listener('remote_name_failure', failure_handler)
# [Classic only]
@host_event_handler
@@ -1210,17 +1833,17 @@
def add_services(self, services):
self.gatt_server.add_services(services)
- async def notify_subscriber(self, connection, attribute, force=False):
- await self.gatt_server.notify_subscriber(connection, attribute, force)
+ async def notify_subscriber(self, connection, attribute, value=None, force=False):
+ await self.gatt_server.notify_subscriber(connection, attribute, value, force)
- async def notify_subscribers(self, attribute, force=False):
- await self.gatt_server.notify_subscribers(attribute, force)
+ async def notify_subscribers(self, attribute, value=None, force=False):
+ await self.gatt_server.notify_subscribers(attribute, value, force)
- async def indicate_subscriber(self, connection, attribute, force=False):
- await self.gatt_server.indicate_subscriber(connection, attribute, force)
+ async def indicate_subscriber(self, connection, attribute, value=None, force=False):
+ await self.gatt_server.indicate_subscriber(connection, attribute, value, force)
- async def indicate_subscribers(self, attribute):
- await self.gatt_server.indicate_subscribers(attribute)
+ async def indicate_subscribers(self, attribute, value=None, force=False):
+ await self.gatt_server.indicate_subscribers(attribute, value, force)
@host_event_handler
def on_connection(self, connection_handle, transport, peer_address, peer_resolvable_address, role, connection_parameters):
@@ -1228,43 +1851,140 @@
if connection_handle in self.connections:
logger.warn('new connection reuses the same handle as a previous connection')
- # Resolve the peer address if we can
- if self.address_resolver:
- if peer_address.is_resolvable:
- resolved_address = self.address_resolver.resolve(peer_address)
- if resolved_address is not None:
- logger.debug(f'*** Address resolved as {resolved_address}')
- peer_resolvable_address = peer_address
- peer_address = resolved_address
+ if transport == BT_BR_EDR_TRANSPORT:
+ # Create a new connection
+ connection = Connection(
+ self,
+ connection_handle,
+ transport,
+ self.public_address,
+ peer_address,
+ peer_resolvable_address,
+ role,
+ connection_parameters,
+ phy=None
+ )
+ self.connections[connection_handle] = connection
- # Create a new connection
- connection = Connection(
- self,
- connection_handle,
- transport,
- peer_address,
- peer_resolvable_address,
- role,
- connection_parameters
- )
- self.connections[connection_handle] = connection
+ # We may have an accept ongoing waiting for a connection request for `peer_address`.
+ # Typicaly happen when using `connect` to the same `peer_address` we are waiting with
+ # an `accept` for.
+ # 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)
- # We are no longer advertising
- self.advertising = False
+ # Emit an event to notify listeners of the new connection
+ self.emit('connection', connection)
+ else:
+ # Resolve the peer address if we can
+ if self.address_resolver:
+ if peer_address.is_resolvable:
+ resolved_address = self.address_resolver.resolve(peer_address)
+ if resolved_address is not None:
+ logger.debug(f'*** Address resolved as {resolved_address}')
+ peer_resolvable_address = peer_address
+ peer_address = resolved_address
- # Emit an event to notify listeners of the new connection
- self.emit('connection', connection)
+ # Guess which own address type is used for this connection.
+ # This logic is somewhat correct but may need to be improved
+ # when multiple advertising are run simultaneously.
+ if self.connect_own_address_type is not None:
+ own_address_type = self.connect_own_address_type
+ else:
+ own_address_type = self.advertising_own_address_type
+
+ # We are no longer advertising
+ self.advertising_own_address_type = None
+ self.advertising = False
+
+ # Create and notify of the new connection asynchronously
+ async def new_connection():
+ # Figure out which PHY we're connected with
+ if self.host.supports_command(HCI_LE_READ_PHY_COMMAND):
+ result = await self.send_command(
+ HCI_LE_Read_PHY_Command(connection_handle=connection_handle),
+ check_result=True
+ )
+ phy = ConnectionPHY(result.return_parameters.tx_phy, result.return_parameters.rx_phy)
+ else:
+ phy = ConnectionPHY(HCI_LE_1M_PHY, HCI_LE_1M_PHY)
+
+ self_address = self.random_address
+ if own_address_type in (OwnAddressType.PUBLIC, OwnAddressType.RESOLVABLE_OR_PUBLIC):
+ self_address = self.public_address
+
+ # Create a new connection
+ connection = Connection(
+ self,
+ connection_handle,
+ transport,
+ self_address,
+ peer_address,
+ peer_resolvable_address,
+ role,
+ connection_parameters,
+ phy
+ )
+ self.connections[connection_handle] = connection
+
+ # Emit an event to notify listeners of the new connection
+ self.emit('connection', connection)
+
+ asyncio.create_task(new_connection())
@host_event_handler
- def on_connection_failure(self, error_code):
- logger.debug(f'*** Connection failed: {error_code}')
+ def on_connection_failure(self, transport, peer_address, error_code):
+ logger.debug(f'*** Connection failed: {HCI_Constant.error_name(error_code)}')
+
+ # For directed advertising, this means a timeout
+ if transport == BT_LE_TRANSPORT and self.advertising and self.advertising_type.is_directed:
+ self.advertising_own_address_type = None
+ self.advertising = False
+
+ # Notify listeners
error = ConnectionError(
error_code,
+ transport,
+ peer_address,
'hci',
HCI_Constant.error_name(error_code)
)
self.emit('connection_failure', error)
+ # FIXME: Explore a delegate-model for BR/EDR wait connection #56.
+ @host_event_handler
+ def on_connection_request(self, bd_addr, class_of_device, link_type):
+ logger.debug(f'*** Connection request: {bd_addr}')
+
+ # match a pending future using `bd_addr`
+ if bd_addr in self.classic_pending_accepts:
+ future = self.classic_pending_accepts.pop(bd_addr)
+ future.set_result((bd_addr, class_of_device, link_type))
+
+ # match first pending future for ANY address
+ elif len(self.classic_pending_accepts[Address.ANY]) > 0:
+ future = self.classic_pending_accepts[Address.ANY].pop(0)
+ future.set_result((bd_addr, class_of_device, link_type))
+
+ # device configuration is set to accept any incoming connection
+ elif self.classic_accept_any:
+ self.host.send_command_sync(
+ HCI_Accept_Connection_Request_Command(
+ bd_addr = bd_addr,
+ role = 0x01 # Remain the peripheral
+ )
+ )
+
+ # reject incoming connection
+ else:
+ self.host.send_command_sync(
+ HCI_Reject_Connection_Request_Command(
+ bd_addr = bd_addr,
+ reason = HCI_CONNECTION_REJECTED_DUE_TO_LIMITED_RESOURCES_ERROR
+ )
+ )
+
@host_event_handler
@with_connection_from_handle
def on_disconnection(self, connection, reason):
@@ -1280,7 +2000,10 @@
# Restart advertising if auto-restart is enabled
if self.auto_restart_advertising:
logger.debug('restarting advertising')
- asyncio.create_task(self.start_advertising(auto_restart=self.auto_restart_advertising))
+ asyncio.create_task(self.start_advertising(
+ advertising_type = self.advertising_type,
+ auto_restart = True
+ ))
@host_event_handler
@with_connection_from_handle
@@ -1288,6 +2011,8 @@
logger.debug(f'*** Disconnection failed: {error_code}')
error = ConnectionError(
error_code,
+ connection.transport,
+ connection.peer_address,
'hci',
HCI_Constant.error_name(error_code)
)
@@ -1296,9 +2021,13 @@
@host_event_handler
@AsyncRunner.run_in_task()
async def on_inquiry_complete(self):
- if self.discovering:
+ if self.auto_restart_inquiry:
# Inquire again
- await self.start_discovery()
+ await self.start_discovery(auto_restart=True)
+ else:
+ self.auto_restart_inquiry = True
+ self.discovering = False
+ self.emit('inquiry_complete')
@host_event_handler
@with_connection_from_handle
@@ -1364,13 +2093,13 @@
# 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 {
+ can_compare = 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:
+ if can_compare:
async def compare_numbers():
numbers_match = await pairing_config.delegate.compare_numbers(code, digits=6)
if numbers_match:
@@ -1384,9 +2113,18 @@
asyncio.create_task(compare_numbers())
else:
- self.host.send_command_sync(
- HCI_User_Confirmation_Request_Reply_Command(bd_addr=connection.peer_address)
- )
+ async def confirm():
+ confirm = await pairing_config.delegate.confirm()
+ if confirm:
+ 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(confirm())
# [Classic only]
@host_event_handler
@@ -1401,7 +2139,7 @@
}
# Respond
- if can_input and pairing_config.delegate:
+ if can_input:
async def get_number():
number = await pairing_config.delegate.get_number()
if number is not None:
@@ -1424,20 +2162,39 @@
# [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)
+ def on_authentication_user_passkey_notification(self, connection, passkey):
+ # Ask what the pairing config should be for this connection
+ pairing_config = self.pairing_config_factory(connection)
+
+ asyncio.create_task(pairing_config.delegate.display_number(passkey))
# [Classic only]
@host_event_handler
- @with_connection_from_address
- def on_remote_name_failure(self, connection, error):
- connection.emit('remote_name_failure', error)
+ @try_with_connection_from_address
+ def on_remote_name(self, connection, address, remote_name):
+ # Try to decode the name
+ try:
+ remote_name = remote_name.decode('utf-8')
+ if connection:
+ connection.peer_name = remote_name
+ connection.emit('remote_name')
+ else:
+ self.emit('remote_name', address, remote_name)
+ except UnicodeDecodeError as error:
+ logger.warning('peer name is not valid UTF-8')
+ if connection:
+ connection.emit('remote_name_failure', error)
+ else:
+ self.emit('remote_name_failure', address, error)
+
+ # [Classic only]
+ @host_event_handler
+ @try_with_connection_from_address
+ def on_remote_name_failure(self, connection, address, error):
+ if connection:
+ connection.emit('remote_name_failure', error)
+ else:
+ self.emit('remote_name_failure', address, error)
@host_event_handler
@with_connection_from_handle
diff --git a/bumble/gatt.py b/bumble/gatt.py
index 90867cd..889eaa4 100644
--- a/bumble/gatt.py
+++ b/bumble/gatt.py
@@ -23,8 +23,10 @@
# Imports
# -----------------------------------------------------------------------------
import asyncio
+import enum
import types
import logging
+from pyee import EventEmitter
from colors import color
from .core import *
@@ -262,7 +264,7 @@
def get_descriptor(self, descriptor_type):
for descriptor in self.descriptors:
- if descriptor.uuid == descriptor_type:
+ if descriptor.type == descriptor_type:
return descriptor
def __str__(self):
@@ -303,6 +305,7 @@
'''
def __init__(self, characteristic):
self.wrapped_characteristic = characteristic
+ self.subscribers = {} # Map from subscriber to proxy subscriber
if (
asyncio.iscoroutinefunction(characteristic.read_value) and
@@ -317,11 +320,21 @@
if hasattr(self.wrapped_characteristic, 'subscribe'):
self.subscribe = self.wrapped_subscribe
+ if hasattr(self.wrapped_characteristic, 'unsubscribe'):
+ self.unsubscribe = self.wrapped_unsubscribe
+
def __getattr__(self, name):
return getattr(self.wrapped_characteristic, name)
def __setattr__(self, name, value):
- if name in {'wrapped_characteristic', 'read_value', 'write_value', 'subscribe'}:
+ if name in {
+ 'wrapped_characteristic',
+ 'subscribers',
+ 'read_value',
+ 'write_value',
+ 'subscribe',
+ 'unsubscribe'
+ }:
super().__setattr__(name, value)
else:
setattr(self.wrapped_characteristic, name, value)
@@ -335,8 +348,11 @@
async def read_decoded_value(self):
return self.decode_value(await self.wrapped_characteristic.read_value())
- async def write_decoded_value(self, value):
- return await self.wrapped_characteristic.write_value(self.encode_value(value))
+ async def write_decoded_value(self, value, with_response=False):
+ return await self.wrapped_characteristic.write_value(
+ self.encode_value(value),
+ with_response
+ )
def encode_value(self, value):
return value
@@ -345,9 +361,26 @@
return value
def wrapped_subscribe(self, subscriber=None):
- return self.wrapped_characteristic.subscribe(
- None if subscriber is None else lambda value: subscriber(self.decode_value(value))
- )
+ if subscriber is not None:
+ if subscriber in self.subscribers:
+ # We already have a proxy subscriber
+ subscriber = self.subscribers[subscriber]
+ else:
+ # Create and register a proxy that will decode the value
+ original_subscriber = subscriber
+
+ def on_change(value):
+ original_subscriber(self.decode_value(value))
+ self.subscribers[subscriber] = on_change
+ subscriber = on_change
+
+ return self.wrapped_characteristic.subscribe(subscriber)
+
+ def wrapped_unsubscribe(self, subscriber=None):
+ if subscriber in self.subscribers:
+ subscriber = self.subscribers.pop(subscriber)
+
+ return self.wrapped_characteristic.unsubscribe(subscriber)
def __str__(self):
wrapped = str(self.wrapped_characteristic)
@@ -442,3 +475,12 @@
def __str__(self):
return f'Descriptor(handle=0x{self.handle:04X}, type={self.type}, value={self.read_value(None).hex()})'
+
+
+class ClientCharacteristicConfigurationBits(enum.IntFlag):
+ '''
+ See Vol 3, Part G - 3.3.3.3 - Table 3.11 Client Characteristic Configuration bit field definition
+ '''
+ DEFAULT = 0x0000
+ NOTIFICATION = 0x0001
+ INDICATION = 0x0002
diff --git a/bumble/gatt_client.py b/bumble/gatt_client.py
index 94799a3..1ba1e2a 100644
--- a/bumble/gatt_client.py
+++ b/bumble/gatt_client.py
@@ -26,19 +26,17 @@
import asyncio
import logging
import struct
+
from colors import color
-from .core import ProtocolError, TimeoutError
-from .hci import *
from .att import *
-from .gatt import (
- GATT_CLIENT_CHARACTERISTIC_CONFIGURATION_DESCRIPTOR,
- GATT_REQUEST_TIMEOUT,
- GATT_PRIMARY_SERVICE_ATTRIBUTE_TYPE,
- GATT_SECONDARY_SERVICE_ATTRIBUTE_TYPE,
- GATT_CHARACTERISTIC_ATTRIBUTE_TYPE,
- Characteristic
-)
+from .core import InvalidStateError, ProtocolError, TimeoutError
+from .gatt import (GATT_CHARACTERISTIC_ATTRIBUTE_TYPE,
+ GATT_CLIENT_CHARACTERISTIC_CONFIGURATION_DESCRIPTOR,
+ GATT_PRIMARY_SERVICE_ATTRIBUTE_TYPE, GATT_REQUEST_TIMEOUT,
+ GATT_SECONDARY_SERVICE_ATTRIBUTE_TYPE, Characteristic,
+ ClientCharacteristicConfigurationBits)
+from .hci import *
# -----------------------------------------------------------------------------
# Logging
@@ -58,10 +56,16 @@
self.type = attribute_type
async def read_value(self, no_long_read=False):
- return await self.client.read_value(self.handle, no_long_read)
+ return self.decode_value(await self.client.read_value(self.handle, no_long_read))
async def write_value(self, value, with_response=False):
- return await self.client.write_value(self.handle, value, with_response)
+ return await self.client.write_value(self.handle, self.encode_value(value), with_response)
+
+ def encode_value(self, value):
+ return value
+
+ def decode_value(self, value_bytes):
+ return value_bytes
def __str__(self):
return f'Attribute(handle=0x{self.handle:04X}, type={self.uuid})'
@@ -98,6 +102,7 @@
self.properties = properties
self.descriptors = []
self.descriptors_discovered = False
+ self.subscribers = {} # Map from subscriber to proxy subscriber
def get_descriptor(self, descriptor_type):
for descriptor in self.descriptors:
@@ -107,10 +112,26 @@
async def discover_descriptors(self):
return await self.client.discover_descriptors(self)
- async def subscribe(self, subscriber=None):
- return await self.client.subscribe(self, subscriber)
+ async def subscribe(self, subscriber=None, prefer_notify=True):
+ if subscriber is not None:
+ if subscriber in self.subscribers:
+ # We already have a proxy subscriber
+ subscriber = self.subscribers[subscriber]
+ else:
+ # Create and register a proxy that will decode the value
+ original_subscriber = subscriber
+
+ def on_change(value):
+ original_subscriber(self.decode_value(value))
+ self.subscribers[subscriber] = on_change
+ subscriber = on_change
+
+ return await self.client.subscribe(self, subscriber, prefer_notify)
async def unsubscribe(self, subscriber=None):
+ if subscriber in self.subscribers:
+ subscriber = self.subscribers.pop(subscriber)
+
return await self.client.unsubscribe(self, subscriber)
def __str__(self):
@@ -140,7 +161,6 @@
class Client:
def __init__(self, connection):
self.connection = connection
- self.mtu = ATT_DEFAULT_MTU
self.mtu_exchange_done = False
self.request_semaphore = asyncio.Semaphore(1)
self.pending_request = None
@@ -162,8 +182,8 @@
# Wait until we can send (only one pending command at a time for the connection)
response = None
async with self.request_semaphore:
- assert(self.pending_request is None)
- assert(self.pending_response is None)
+ assert self.pending_request is None
+ assert self.pending_response is None
# Create a future value to hold the eventual response
self.pending_response = asyncio.get_running_loop().create_future()
@@ -194,7 +214,7 @@
# We can only send one request per connection
if self.mtu_exchange_done:
- return
+ return self.connection.att_mtu
# Send the request
self.mtu_exchange_done = True
@@ -207,8 +227,10 @@
response
)
- self.mtu = max(ATT_DEFAULT_MTU, response.server_rx_mtu)
- return self.mtu
+ # Compute the final MTU
+ self.connection.att_mtu = min(mtu, response.server_rx_mtu)
+
+ return self.connection.att_mtu
def get_services_by_uuid(self, uuid):
return [service for service in self.services if service.uuid == uuid]
@@ -249,7 +271,7 @@
if response.op_code == ATT_ERROR_RESPONSE:
if response.error_code != ATT_ATTRIBUTE_NOT_FOUND_ERROR:
# Unexpected end
- logger.waning(f'!!! unexpected error while discovering services: {HCI_Constant.error_name(response.error_code)}')
+ logger.warning(f'!!! unexpected error while discovering services: {HCI_Constant.error_name(response.error_code)}')
# TODO raise appropriate exception
return
break
@@ -313,7 +335,7 @@
if response.op_code == ATT_ERROR_RESPONSE:
if response.error_code != ATT_ATTRIBUTE_NOT_FOUND_ERROR:
# Unexpected end
- logger.waning(f'!!! unexpected error while discovering services: {HCI_Constant.error_name(response.error_code)}')
+ logger.warning(f'!!! unexpected error while discovering services: {HCI_Constant.error_name(response.error_code)}')
# TODO raise appropriate exception
return
break
@@ -522,7 +544,7 @@
return attributes
- async def subscribe(self, characteristic, subscriber=None):
+ async def subscribe(self, characteristic, subscriber=None, prefer_notify=True):
# If we haven't already discovered the descriptors for this characteristic, do it now
if not characteristic.descriptors_discovered:
await self.discover_descriptors(characteristic)
@@ -533,23 +555,32 @@
logger.warning('subscribing to characteristic with no CCCD descriptor')
return
- # Set the subscription bits and select the subscriber set
- bits = 0
- subscriber_sets = []
- if characteristic.properties & Characteristic.NOTIFY:
- bits |= 0x0001
- subscriber_sets.append(self.notification_subscribers.setdefault(characteristic.handle, set()))
- if characteristic.properties & Characteristic.INDICATE:
- bits |= 0x0002
- subscriber_sets.append(self.indication_subscribers.setdefault(characteristic.handle, set()))
+ if (
+ characteristic.properties & Characteristic.NOTIFY
+ and characteristic.properties & Characteristic.INDICATE
+ ):
+ if prefer_notify:
+ bits = ClientCharacteristicConfigurationBits.NOTIFICATION
+ subscribers = self.notification_subscribers
+ else:
+ bits = ClientCharacteristicConfigurationBits.INDICATION
+ subscribers = self.indication_subscribers
+ elif characteristic.properties & Characteristic.NOTIFY:
+ bits = ClientCharacteristicConfigurationBits.NOTIFICATION
+ subscribers = self.notification_subscribers
+ elif characteristic.properties & Characteristic.INDICATE:
+ bits = ClientCharacteristicConfigurationBits.INDICATION
+ subscribers = self.indication_subscribers
+ else:
+ raise InvalidStateError("characteristic is not notify or indicate")
# Add subscribers to the sets
- for subscriber_set in subscriber_sets:
- if subscriber is not None:
- subscriber_set.add(subscriber)
- # Add the characteristic as a subscriber, which will result in the characteristic
- # emitting an 'update' event when a notification or indication is received
- subscriber_set.add(characteristic)
+ subscriber_set = subscribers.setdefault(characteristic.handle, set())
+ if subscriber is not None:
+ subscriber_set.add(subscriber)
+ # Add the characteristic as a subscriber, which will result in the characteristic
+ # emitting an 'update' event when a notification or indication is received
+ subscriber_set.add(characteristic)
await self.write_value(cccd, struct.pack('<H', bits), with_response=True)
@@ -570,12 +601,18 @@
subscribers = subscriber_set.get(characteristic.handle, [])
if subscriber in subscribers:
subscribers.remove(subscriber)
+
+ # Cleanup if we removed the last one
+ if not subscribers:
+ subscriber_set.remove(characteristic.handle)
else:
# Remove all subscribers for this attribute from the sets!
self.notification_subscribers.pop(characteristic.handle, None)
self.indication_subscribers.pop(characteristic.handle, None)
- await self.write_value(cccd, b'\x00\x00', with_response=True)
+ if not self.notification_subscribers and not self.indication_subscribers:
+ # No more subscribers left
+ await self.write_value(cccd, b'\x00\x00', with_response=True)
async def read_value(self, attribute, no_long_read=False):
'''
@@ -600,7 +637,7 @@
# If the value is the max size for the MTU, try to read more unless the caller
# specifically asked not to do that
attribute_value = response.attribute_value
- if not no_long_read and len(attribute_value) == self.mtu - 1:
+ if not no_long_read and len(attribute_value) == self.connection.att_mtu - 1:
logger.debug('using READ BLOB to get the rest of the value')
offset = len(attribute_value)
while True:
@@ -622,7 +659,7 @@
part = response.part_attribute_value
attribute_value += part
- if len(part) < self.mtu - 1:
+ if len(part) < self.connection.att_mtu - 1:
break
offset += len(part)
diff --git a/bumble/gatt_server.py b/bumble/gatt_server.py
index 8297f11..5b6fea3 100644
--- a/bumble/gatt_server.py
+++ b/bumble/gatt_server.py
@@ -41,6 +41,12 @@
# -----------------------------------------------------------------------------
+# Constants
+# -----------------------------------------------------------------------------
+GATT_SERVER_DEFAULT_MAX_MTU = 517
+
+
+# -----------------------------------------------------------------------------
# GATT Server
# -----------------------------------------------------------------------------
class Server(EventEmitter):
@@ -49,9 +55,8 @@
self.device = device
self.attributes = [] # Attributes, ordered by increasing handle values
self.attributes_by_handle = {} # Map for fast attribute access by handle
- self.max_mtu = 23 # FIXME: 517 # The max MTU we're willing to negotiate
+ self.max_mtu = GATT_SERVER_DEFAULT_MAX_MTU # The max MTU we're willing to negotiate
self.subscribers = {} # Map of subscriber states by connection handle and attribute handle
- self.mtus = {} # Map of ATT MTU values by connection handle
self.indication_semaphores = defaultdict(lambda: asyncio.Semaphore(1))
self.pending_confirmations = defaultdict(lambda: None)
@@ -150,7 +155,7 @@
return cccd or bytes([0, 0])
def write_cccd(self, connection, characteristic, value):
- logger.debug(f'Subscription update for connection={connection.handle:04X}, handle={characteristic.handle:04X}: {value.hex()}')
+ logger.debug(f'Subscription update for connection=0x{connection.handle:04X}, handle=0x{characteristic.handle:04X}: {value.hex()}')
# Sanity check
if len(value) != 2:
@@ -169,7 +174,7 @@
logger.debug(f'GATT Response from server: [0x{connection.handle:04X}] {response}')
self.send_gatt_pdu(connection.handle, response.to_bytes())
- async def notify_subscriber(self, connection, attribute, force=False):
+ async def notify_subscriber(self, connection, attribute, value=None, force=False):
# Check if there's a subscriber
if not force:
subscribers = self.subscribers.get(connection.handle)
@@ -184,13 +189,12 @@
logger.debug(f'not notifying, cccd={cccd.hex()}')
return
- # Get the value
- value = attribute.read_value(connection)
+ # Get or encode the value
+ value = attribute.read_value(connection) if value is None else attribute.encode_value(value)
# Truncate if needed
- mtu = self.get_mtu(connection)
- if len(value) > mtu - 3:
- value = value[:mtu - 3]
+ if len(value) > connection.att_mtu - 3:
+ value = value[:connection.att_mtu - 3]
# Notify
notification = ATT_Handle_Value_Notification(
@@ -198,27 +202,9 @@
attribute_value = value
)
logger.debug(f'GATT Notify from server: [0x{connection.handle:04X}] {notification}')
- self.send_gatt_pdu(connection.handle, notification.to_bytes())
+ self.send_gatt_pdu(connection.handle, bytes(notification))
- async def notify_subscribers(self, attribute, force=False):
- # Get all the connections for which there's at least one subscription
- connections = [
- connection for connection in [
- self.device.lookup_connection(connection_handle)
- for (connection_handle, subscribers) in self.subscribers.items()
- if force or subscribers.get(attribute.handle)
- ]
- if connection is not None
- ]
-
- # Notify for each connection
- if connections:
- await asyncio.wait([
- self.notify_subscriber(connection, attribute, force)
- for connection in connections
- ])
-
- async def indicate_subscriber(self, connection, attribute, force=False):
+ async def indicate_subscriber(self, connection, attribute, value=None, force=False):
# Check if there's a subscriber
if not force:
subscribers = self.subscribers.get(connection.handle)
@@ -233,13 +219,12 @@
logger.debug(f'not indicating, cccd={cccd.hex()}')
return
- # Get the value
- value = attribute.read_value(connection)
+ # Get or encode the value
+ value = attribute.read_value(connection) if value is None else attribute.encode_value(value)
# Truncate if needed
- mtu = self.get_mtu(connection)
- if len(value) > mtu - 3:
- value = value[:mtu - 3]
+ if len(value) > connection.att_mtu - 3:
+ value = value[:connection.att_mtu - 3]
# Indicate
indication = ATT_Handle_Value_Indication(
@@ -264,27 +249,32 @@
finally:
self.pending_confirmations[connection.handle] = None
- async def indicate_subscribers(self, attribute):
+ async def notify_or_indicate_subscribers(self, indicate, attribute, value=None, force=False):
# Get all the connections for which there's at least one subscription
connections = [
connection for connection in [
self.device.lookup_connection(connection_handle)
for (connection_handle, subscribers) in self.subscribers.items()
- if subscribers.get(attribute.handle)
+ if force or subscribers.get(attribute.handle)
]
if connection is not None
]
- # Indicate for each connection
+ # Indicate or notify for each connection
if connections:
+ coroutine = self.indicate_subscriber if indicate else self.notify_subscriber
await asyncio.wait([
- self.indicate_subscriber(connection, attribute)
+ asyncio.create_task(coroutine(connection, attribute, value, force))
for connection in connections
])
+ async def notify_subscribers(self, attribute, value=None, force=False):
+ return await self.notify_or_indicate_subscribers(False, attribute, value, force)
+
+ async def indicate_subscribers(self, attribute, value=None, force=False):
+ return await self.notify_or_indicate_subscribers(True, attribute, value, force)
+
def on_disconnection(self, connection):
- if connection.handle in self.mtus:
- del self.mtus[connection.handle]
if connection.handle in self.subscribers:
del self.subscribers[connection.handle]
if connection.handle in self.indication_semaphores:
@@ -325,9 +315,6 @@
# Just ignore
logger.warning(f'{color("--- Ignoring GATT Request from [0x{connection.handle:04X}]:", "red")} {att_pdu}')
- def get_mtu(self, connection):
- return self.mtus.get(connection.handle, ATT_DEFAULT_MTU)
-
#######################################################
# ATT handlers
#######################################################
@@ -347,12 +334,16 @@
'''
See Bluetooth spec Vol 3, Part F - 3.4.2.1 Exchange MTU Request
'''
- mtu = max(ATT_DEFAULT_MTU, min(self.max_mtu, request.client_rx_mtu))
- self.mtus[connection.handle] = mtu
- self.send_response(connection, ATT_Exchange_MTU_Response(server_rx_mtu = mtu))
+ self.send_response(connection, ATT_Exchange_MTU_Response(server_rx_mtu = self.max_mtu))
- # Notify the device
- self.device.on_connection_att_mtu_update(connection.handle, mtu)
+ # Compute the final MTU
+ if request.client_rx_mtu >= ATT_DEFAULT_MTU:
+ mtu = min(self.max_mtu, request.client_rx_mtu)
+
+ # Notify the device
+ self.device.on_connection_att_mtu_update(connection.handle, mtu)
+ else:
+ logger.warning('invalid client_rx_mtu received, MTU not changed')
def on_att_find_information_request(self, connection, request):
'''
@@ -369,7 +360,7 @@
return
# Build list of returned attributes
- pdu_space_available = self.get_mtu(connection) - 2
+ pdu_space_available = connection.att_mtu - 2
attributes = []
uuid_size = 0
for attribute in (
@@ -420,7 +411,7 @@
'''
# Build list of returned attributes
- pdu_space_available = self.get_mtu(connection) - 2
+ pdu_space_available = connection.att_mtu - 2
attributes = []
for attribute in (
attribute for attribute in self.attributes if
@@ -468,8 +459,7 @@
See Bluetooth spec Vol 3, Part F - 3.4.4.1 Read By Type Request
'''
- mtu = self.get_mtu(connection)
- pdu_space_available = mtu - 2
+ pdu_space_available = connection.att_mtu - 2
attributes = []
for attribute in (
attribute for attribute in self.attributes if
@@ -482,7 +472,7 @@
# Check the attribute value size
attribute_value = attribute.read_value(connection)
- max_attribute_size = min(mtu - 4, 253)
+ max_attribute_size = min(connection.att_mtu - 4, 253)
if len(attribute_value) > max_attribute_size:
# We need to truncate
attribute_value = attribute_value[:max_attribute_size]
@@ -522,7 +512,7 @@
if attribute := self.get_attribute(request.attribute_handle):
# TODO: check permissions
value = attribute.read_value(connection)
- value_size = min(self.get_mtu(connection) - 1, len(value))
+ value_size = min(connection.att_mtu - 1, len(value))
response = ATT_Read_Response(
attribute_value = value[:value_size]
)
@@ -541,7 +531,6 @@
if attribute := self.get_attribute(request.attribute_handle):
# TODO: check permissions
- mtu = self.get_mtu(connection)
value = attribute.read_value(connection)
if request.value_offset > len(value):
response = ATT_Error_Response(
@@ -549,14 +538,14 @@
attribute_handle_in_error = request.attribute_handle,
error_code = ATT_INVALID_OFFSET_ERROR
)
- elif len(value) <= mtu - 1:
+ 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(mtu - 1, len(value) - request.value_offset)
+ 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]
)
@@ -585,8 +574,7 @@
self.send_response(connection, response)
return
- mtu = self.get_mtu(connection)
- pdu_space_available = mtu - 2
+ pdu_space_available = connection.att_mtu - 2
attributes = []
for attribute in (
attribute for attribute in self.attributes if
@@ -597,7 +585,7 @@
):
# Check the attribute value size
attribute_value = attribute.read_value(connection)
- max_attribute_size = min(mtu - 6, 251)
+ max_attribute_size = min(connection.att_mtu - 6, 251)
if len(attribute_value) > max_attribute_size:
# We need to truncate
attribute_value = attribute_value[:max_attribute_size]
diff --git a/bumble/hci.py b/bumble/hci.py
index 9858c04..be5973b 100644
--- a/bumble/hci.py
+++ b/bumble/hci.py
@@ -62,6 +62,18 @@
return f'[{class_of_device:06X}] Services({",".join(DeviceClass.service_class_labels(service_classes))}),Class({DeviceClass.major_device_class_name(major_device_class)}|{DeviceClass.minor_device_class_name(major_device_class, minor_device_class)})'
+def phy_list_to_bits(phys):
+ if phys is None:
+ return 0
+ else:
+ phy_bits = 0
+ for phy in phys:
+ if phy not in HCI_LE_PHY_TYPE_TO_BIT:
+ raise ValueError('invalid PHY')
+ phy_bits |= (1 << HCI_LE_PHY_TYPE_TO_BIT[phy])
+ return phy_bits
+
+
# -----------------------------------------------------------------------------
# Constants
# -----------------------------------------------------------------------------
@@ -670,10 +682,22 @@
HCI_LE_PHY_NAMES = {
HCI_LE_1M_PHY: 'LE 1M',
- HCI_LE_2M_PHY: 'L2 2M',
+ HCI_LE_2M_PHY: 'LE 2M',
HCI_LE_CODED_PHY: 'LE Coded'
}
+HCI_LE_1M_PHY_BIT = 0
+HCI_LE_2M_PHY_BIT = 1
+HCI_LE_CODED_PHY_BIT = 2
+
+HCI_LE_PHY_BIT_NAMES = ['LE_1M_PHY', 'LE_2M_PHY', 'LE_CODED_PHY']
+
+HCI_LE_PHY_TYPE_TO_BIT = {
+ HCI_LE_1M_PHY: HCI_LE_1M_PHY_BIT,
+ HCI_LE_2M_PHY: HCI_LE_2M_PHY_BIT,
+ HCI_LE_CODED_PHY: HCI_LE_CODED_PHY_BIT
+}
+
# Connection Parameters
HCI_CONNECTION_INTERVAL_MS_PER_UNIT = 1.25
HCI_CONNECTION_LATENCY_MS_PER_UNIT = 1.25
@@ -1373,11 +1397,14 @@
super().__init__(error_code, 'hci', HCI_Constant.error_name(error_code))
+# -----------------------------------------------------------------------------
class HCI_StatusError(ProtocolError):
def __init__(self, response):
- super().__init__(response.status,
- error_namespace=HCI_Command.command_name(response.command_opcode),
- error_name=HCI_Constant.status_name(response.status))
+ super().__init__(
+ response.status,
+ error_namespace=HCI_Command.command_name(response.command_opcode),
+ error_name=HCI_Constant.status_name(response.status)
+ )
# -----------------------------------------------------------------------------
@@ -1402,7 +1429,7 @@
def dict_from_bytes(data, offset, fields):
result = collections.OrderedDict()
for (field_name, field_type) in fields:
- # The field_type may be a dictionnary with a mapper, parser, and/or size
+ # The field_type may be a dictionary with a mapper, parser, and/or size
if type(field_type) is dict:
if 'size' in field_type:
field_type = field_type['size']
@@ -1464,7 +1491,7 @@
def dict_to_bytes(object, fields):
result = bytearray()
for (field_name, field_type) in fields:
- # The field_type may be a dictionnary with a mapper, parser, serializer, and/or size
+ # The field_type may be a dictionary with a mapper, parser, serializer, and/or size
serializer = None
if type(field_type) is dict:
if 'serializer' in field_type:
@@ -1523,9 +1550,9 @@
return bytes(result)
- @staticmethod
- def from_bytes(data, offset, fields):
- return HCI_Object(fields, **HCI_Object.dict_from_bytes(data, offset, fields))
+ @classmethod
+ def from_bytes(cls, data, offset, fields):
+ return cls(fields, **cls.dict_from_bytes(data, offset, fields))
def to_bytes(self):
return HCI_Object.dict_to_bytes(self.__dict__, self.fields)
@@ -1625,11 +1652,29 @@
ADDRESS_TYPE_SPEC = {'size': 1, 'mapper': lambda x: Address.address_type_name(x)}
+ @classmethod
+ @property
+ def ANY(cls):
+ return cls(b"\xff\xff\xff\xff\xff\xff", cls.PUBLIC_DEVICE_ADDRESS)
+
+ @classmethod
+ @property
+ def NIL(cls):
+ return cls(b"\x00\x00\x00\x00\x00\x00", cls.PUBLIC_DEVICE_ADDRESS)
+
@staticmethod
def address_type_name(address_type):
return name_or_number(Address.ADDRESS_TYPE_NAMES, address_type)
@staticmethod
+ def from_string_for_transport(string, transport):
+ if transport == BT_BR_EDR_TRANSPORT:
+ address_type = Address.PUBLIC_DEVICE_ADDRESS
+ else:
+ address_type = Address.RANDOM_DEVICE_ADDRESS
+ return Address(string, address_type)
+
+ @staticmethod
def parse_address(data, offset):
# Fix the type to a default value. This is used for parsing type-less Classic addresses
return Address.parse_address_with_type(data, offset, Address.PUBLIC_DEVICE_ADDRESS)
@@ -1669,6 +1714,9 @@
self.address_type = address_type
+ def clone(self):
+ return Address(self.address_bytes, self.address_type)
+
@property
def is_public(self):
return self.address_type == self.PUBLIC_DEVICE_ADDRESS or self.address_type == self.PUBLIC_IDENTITY_ADDRESS
@@ -1705,10 +1753,33 @@
'''
String representation of the address, MSB first
'''
- return ':'.join([f'{x:02X}' for x in reversed(self.address_bytes)])
+ str = ':'.join([f'{x:02X}' for x in reversed(self.address_bytes)])
+ if not self.is_public:
+ return str
+ return str + '/P'
# -----------------------------------------------------------------------------
+class OwnAddressType:
+ PUBLIC = 0
+ RANDOM = 1
+ RESOLVABLE_OR_PUBLIC = 2
+ RESOLVABLE_OR_RANDOM = 3
+
+ TYPE_NAMES = {
+ PUBLIC: 'PUBLIC',
+ RANDOM: 'RANDOM',
+ RESOLVABLE_OR_PUBLIC: 'RESOLVABLE_OR_PUBLIC',
+ RESOLVABLE_OR_RANDOM: 'RESOLVABLE_OR_RANDOM'
+ }
+
+ @staticmethod
+ def type_name(type):
+ return name_or_number(OwnAddressType.TYPE_NAMES, type)
+
+ TYPE_SPEC = {'size': 1, 'mapper': lambda x: OwnAddressType.type_name(x)}
+
+# -----------------------------------------------------------------------------
class HCI_Packet:
'''
Abstract Base class for HCI packets
@@ -1882,6 +1953,22 @@
# -----------------------------------------------------------------------------
+@HCI_Command.command(
+ fields=[
+ ('bd_addr', Address.parse_address)
+ ],
+ return_parameters_fields=[
+ ('status', STATUS_SPEC),
+ ('bd_addr', Address.parse_address)
+ ]
+)
+class HCI_Create_Connection_Cancel_Command(HCI_Command):
+ '''
+ See Bluetooth spec @ 7.1.7 Create Connection Cancel Command
+ '''
+
+
+# -----------------------------------------------------------------------------
@HCI_Command.command([
('bd_addr', Address.parse_address),
('role', 1)
@@ -1894,6 +1981,17 @@
# -----------------------------------------------------------------------------
@HCI_Command.command([
+ ('bd_addr', Address.parse_address),
+ ('reason', {'size': 1, 'mapper': HCI_Constant.error_name})
+])
+class HCI_Reject_Connection_Request_Command(HCI_Command):
+ '''
+ See Bluetooth spec @ 7.1.9 Reject Connection Request Command
+ '''
+
+
+# -----------------------------------------------------------------------------
+@HCI_Command.command([
('bd_addr', Address.parse_address),
('link_key', 16)
])
@@ -2297,7 +2395,7 @@
# -----------------------------------------------------------------------------
@HCI_Command.command([
- ('conn_accept_timeout', 2)
+ ('connection_accept_timeout', 2)
])
class HCI_Write_Connection_Accept_Timeout_Command(HCI_Command):
'''
@@ -2402,9 +2500,10 @@
# -----------------------------------------------------------------------------
+@HCI_Command.command()
class HCI_Read_Synchronous_Flow_Control_Enable_Command(HCI_Command):
'''
- See Bluetooth spec @ 7.3.36 Write Synchronous Flow Control Enable Command
+ See Bluetooth spec @ 7.3.36 Read Synchronous Flow Control Enable Command
'''
@@ -2693,6 +2792,23 @@
# -----------------------------------------------------------------------------
@HCI_Command.command(
fields=[
+ ('handle', 2)
+ ],
+ return_parameters_fields=[
+ ('status', STATUS_SPEC),
+ ('handle', 2),
+ ('rssi', -1)
+ ]
+)
+class HCI_Read_RSSI_Command(HCI_Command):
+ '''
+ See Bluetooth spec @ 7.5.4 Read RSSI Command
+ '''
+
+
+# -----------------------------------------------------------------------------
+@HCI_Command.command(
+ fields=[
('connection_handle', 2)
],
return_parameters_fields=[
@@ -2755,7 +2871,7 @@
('advertising_interval_min', 2),
('advertising_interval_max', 2),
('advertising_type', {'size': 1, 'mapper': lambda x: HCI_LE_Set_Advertising_Parameters_Command.advertising_type_name(x)}),
- ('own_address_type', Address.ADDRESS_TYPE_SPEC),
+ ('own_address_type', OwnAddressType.TYPE_SPEC),
('peer_address_type', Address.ADDRESS_TYPE_SPEC),
('peer_address', Address.parse_address_preceded_by_type),
('advertising_channel_map', 1),
@@ -2766,18 +2882,18 @@
See Bluetooth spec @ 7.8.5 LE Set Advertising Parameters Command
'''
- ADV_IND = 0x00
- ADV_DIRECT_IND = 0x01
- ADV_SCAN_IND = 0x02
- ADV_NONCONN_IND = 0x03
- ADV_DIRECT_IND = 0x04
+ ADV_IND = 0x00
+ ADV_DIRECT_IND = 0x01
+ ADV_SCAN_IND = 0x02
+ ADV_NONCONN_IND = 0x03
+ ADV_DIRECT_IND_LOW_DUTY = 0x04
ADVERTISING_TYPE_NAMES = {
- ADV_IND: 'ADV_IND',
- ADV_DIRECT_IND: 'ADV_DIRECT_IND',
- ADV_SCAN_IND: 'ADV_SCAN_IND',
- ADV_NONCONN_IND: 'ADV_NONCONN_IND',
- ADV_DIRECT_IND: 'ADV_DIRECT_IND'
+ ADV_IND: 'ADV_IND',
+ ADV_DIRECT_IND: 'ADV_DIRECT_IND',
+ ADV_SCAN_IND: 'ADV_SCAN_IND',
+ ADV_NONCONN_IND: 'ADV_NONCONN_IND',
+ ADV_DIRECT_IND_LOW_DUTY: 'ADV_DIRECT_IND_LOW_DUTY'
}
@classmethod
@@ -2834,7 +2950,7 @@
('le_scan_type', 1),
('le_scan_interval', 2),
('le_scan_window', 2),
- ('own_address_type', Address.ADDRESS_TYPE_SPEC),
+ ('own_address_type', OwnAddressType.TYPE_SPEC),
('scanning_filter_policy', 1)
])
class HCI_LE_Set_Scan_Parameters_Command(HCI_Command):
@@ -2868,13 +2984,13 @@
('initiator_filter_policy', 1),
('peer_address_type', Address.ADDRESS_TYPE_SPEC),
('peer_address', Address.parse_address_preceded_by_type),
- ('own_address_type', Address.ADDRESS_TYPE_SPEC),
- ('conn_interval_min', 2),
- ('conn_interval_max', 2),
- ('conn_latency', 2),
+ ('own_address_type', OwnAddressType.TYPE_SPEC),
+ ('connection_interval_min', 2),
+ ('connection_interval_max', 2),
+ ('max_latency', 2),
('supervision_timeout', 2),
- ('minimum_ce_length', 2),
- ('maximum_ce_length', 2)
+ ('min_ce_length', 2),
+ ('max_ce_length', 2)
])
class HCI_LE_Create_Connection_Command(HCI_Command):
'''
@@ -2930,13 +3046,13 @@
# -----------------------------------------------------------------------------
@HCI_Command.command([
- ('connection_handle', 2),
- ('conn_interval_min', 2),
- ('conn_interval_max', 2),
- ('conn_latency', 2),
- ('supervision_timeout', 2),
- ('minimum_ce_length', 2),
- ('maximum_ce_length', 2)
+ ('connection_handle', 2),
+ ('connection_interval_min', 2),
+ ('connection_interval_max', 2),
+ ('max_latency', 2),
+ ('supervision_timeout', 2),
+ ('min_ce_length', 2),
+ ('max_ce_length', 2)
])
class HCI_LE_Connection_Update_Command(HCI_Command):
'''
@@ -3002,10 +3118,10 @@
('connection_handle', 2),
('interval_min', 2),
('interval_max', 2),
- ('latency', 2),
+ ('max_latency', 2),
('timeout', 2),
- ('minimum_ce_length', 2),
- ('maximum_ce_length', 2)
+ ('min_ce_length', 2),
+ ('max_ce_length', 2)
])
class HCI_LE_Remote_Connection_Parameter_Request_Reply_Command(HCI_Command):
'''
@@ -3025,6 +3141,36 @@
# -----------------------------------------------------------------------------
+@HCI_Command.command(
+ fields=[
+ ('connection_handle', 2),
+ ('tx_octets', 2),
+ ('tx_time', 2),
+ ],
+ return_parameters_fields=[
+ ('status', STATUS_SPEC),
+ ('connection_handle', 2)
+ ]
+)
+class HCI_LE_Set_Data_Length_Command(HCI_Command):
+ '''
+ See Bluetooth spec @ 7.8.33 LE Set Data Length Command
+ '''
+
+
+# -----------------------------------------------------------------------------
+@HCI_Command.command(return_parameters_fields=[
+ ('status', STATUS_SPEC),
+ ('suggested_max_tx_octets', 2),
+ ('suggested_max_tx_time', 2),
+])
+class HCI_LE_Read_Suggested_Default_Data_Length_Command(HCI_Command):
+ '''
+ See Bluetooth spec @ 7.8.34 LE Read Suggested Default Data Length Command
+ '''
+
+
+# -----------------------------------------------------------------------------
@HCI_Command.command([
('suggested_max_tx_octets', 2),
('suggested_max_tx_time', 2)
@@ -3058,18 +3204,6 @@
# -----------------------------------------------------------------------------
@HCI_Command.command([
- ('all_phys', 1),
- ('tx_phys', 1),
- ('rx_phys', 1)
-])
-class HCI_LE_Set_Default_PHY_Command(HCI_Command):
- '''
- See Bluetooth spec @ 7.8.48 LE Set Default PHY Command
- '''
-
-
-# -----------------------------------------------------------------------------
-@HCI_Command.command([
('address_resolution_enable', 1)
])
class HCI_LE_Set_Address_Resolution_Enable_Command(HCI_Command):
@@ -3089,6 +3223,313 @@
# -----------------------------------------------------------------------------
+@HCI_Command.command(return_parameters_fields=[
+ ('status', STATUS_SPEC),
+ ('supported_max_tx_octets', 2),
+ ('supported_max_tx_time', 2),
+ ('supported_max_rx_octets', 2),
+ ('supported_max_rx_time', 2)
+])
+class HCI_LE_Read_Maximum_Data_Length_Command(HCI_Command):
+ '''
+ See Bluetooth spec @ 7.8.46 LE Read Maximum Data Length Command
+ '''
+
+
+# -----------------------------------------------------------------------------
+@HCI_Command.command(
+ fields=[
+ ('connection_handle', 2)
+ ],
+ return_parameters_fields=[
+ ('status', STATUS_SPEC),
+ ('connection_handle', 2),
+ ('tx_phy', {'size': 1, 'mapper': HCI_Constant.le_phy_name}),
+ ('rx_phy', {'size': 1, 'mapper': HCI_Constant.le_phy_name})
+ ])
+class HCI_LE_Read_PHY_Command(HCI_Command):
+ '''
+ See Bluetooth spec @ 7.8.47 LE Read PHY Command
+ '''
+
+
+# -----------------------------------------------------------------------------
+@HCI_Command.command([
+ ('all_phys', {'size': 1, 'mapper': lambda x: bit_flags_to_strings(x, HCI_LE_Set_Default_PHY_Command.ANY_PHY_BIT_NAMES)}),
+ ('tx_phys', {'size': 1, 'mapper': lambda x: bit_flags_to_strings(x, HCI_LE_PHY_BIT_NAMES)}),
+ ('rx_phys', {'size': 1, 'mapper': lambda x: bit_flags_to_strings(x, HCI_LE_PHY_BIT_NAMES)})
+])
+class HCI_LE_Set_Default_PHY_Command(HCI_Command):
+ '''
+ See Bluetooth spec @ 7.8.48 LE Set Default PHY Command
+ '''
+ ANY_TX_PHY_BIT = 0
+ ANY_RX_PHY_BIT = 1
+
+ ANY_PHY_BIT_NAMES = ['Any TX', 'Any RX']
+
+
+# -----------------------------------------------------------------------------
+@HCI_Command.command([
+ ('connection_handle', 2),
+ ('all_phys', {'size': 1, 'mapper': lambda x: bit_flags_to_strings(x, HCI_LE_Set_PHY_Command.ANY_PHY_BIT_NAMES)}),
+ ('tx_phys', {'size': 1, 'mapper': lambda x: bit_flags_to_strings(x, HCI_LE_PHY_BIT_NAMES)}),
+ ('rx_phys', {'size': 1, 'mapper': lambda x: bit_flags_to_strings(x, HCI_LE_PHY_BIT_NAMES)}),
+ ('phy_options', 2)
+])
+class HCI_LE_Set_PHY_Command(HCI_Command):
+ '''
+ See Bluetooth spec @ 7.8.49 LE Set PHY Command
+ '''
+ ANY_TX_PHY_BIT = 0
+ ANY_RX_PHY_BIT = 1
+
+ ANY_PHY_BIT_NAMES = ['Any TX', 'Any RX']
+
+
+# -----------------------------------------------------------------------------
+@HCI_Command.command([
+ ('advertising_handle', 1),
+ ('random_address', lambda data, offset: Address.parse_address_with_type(data, offset, Address.RANDOM_DEVICE_ADDRESS))
+])
+class HCI_LE_Set_Advertising_Set_Random_Address_Command(HCI_Command):
+ '''
+ See Bluetooth spec @ 7.8.52 LE Set Advertising Set Random Address Command
+ '''
+
+
+# -----------------------------------------------------------------------------
+@HCI_Command.command(
+ fields=[
+ ('advertising_handle', 1),
+ ('advertising_event_properties', {'size': 2, 'mapper': lambda x: HCI_LE_Set_Extended_Advertising_Parameters_Command.advertising_properties_string(x)}),
+ ('primary_advertising_interval_min', 3),
+ ('primary_advertising_interval_max', 3),
+ ('primary_advertising_channel_map', {'size': 1, 'mapper': lambda x: HCI_LE_Set_Extended_Advertising_Parameters_Command.channel_map_string(x)}),
+ ('own_address_type', OwnAddressType.TYPE_SPEC),
+ ('peer_address_type', Address.ADDRESS_TYPE_SPEC),
+ ('peer_address', Address.parse_address_preceded_by_type),
+ ('advertising_filter_policy', 1),
+ ('advertising_tx_power', 1),
+ ('primary_advertising_phy', {'size': 1, 'mapper': HCI_Constant.le_phy_name}),
+ ('secondary_advertising_max_skip', 1),
+ ('secondary_advertising_phy', {'size': 1, 'mapper': HCI_Constant.le_phy_name}),
+ ('advertising_sid', 1),
+ ('scan_request_notification_enable', 1)
+ ],
+ return_parameters_fields=[
+ ('status', STATUS_SPEC),
+ ('selected_tx__power', 1)
+ ]
+)
+class HCI_LE_Set_Extended_Advertising_Parameters_Command(HCI_Command):
+ '''
+ See Bluetooth spec @ 7.8.53 LE Set Extended Advertising Parameters Command
+ '''
+
+ CONNECTABLE_ADVERTISING = 0
+ SCANNABLE_ADVERTISING = 1
+ DIRECTED_ADVERTISING = 2
+ HIGH_DUTY_CYCLE_DIRECTED_CONNECTABLE_ADVERTISING = 3
+ USE_LEGACY_ADVERTISING_PDUS = 4
+ ANONYMOUS_ADVERTISING = 5
+ INCLUDE_TX_POWER = 6
+
+ ADVERTISING_PROPERTIES_NAMES = (
+ 'CONNECTABLE_ADVERTISING',
+ 'SCANNABLE_ADVERTISING',
+ 'DIRECTED_ADVERTISING',
+ 'HIGH_DUTY_CYCLE_DIRECTED_CONNECTABLE_ADVERTISING',
+ 'USE_LEGACY_ADVERTISING_PDUS',
+ 'ANONYMOUS_ADVERTISING',
+ 'INCLUDE_TX_POWER'
+ )
+
+ CHANNEL_37 = 0
+ CHANNEL_38 = 1
+ CHANNEL_39 = 2
+
+ CHANNEL_NAMES = ('37', '38', '39')
+
+ @classmethod
+ def advertising_properties_string(cls, properties):
+ return f'[{",".join(bit_flags_to_strings(properties, cls.ADVERTISING_PROPERTIES_NAMES))}]'
+
+ @classmethod
+ def channel_map_string(cls, channel_map):
+ return f'[{",".join(bit_flags_to_strings(channel_map, cls.CHANNEL_NAMES))}]'
+
+
+# -----------------------------------------------------------------------------
+@HCI_Command.command([
+ ('advertising_handle', 1),
+ ('operation', {'size': 1, 'mapper': lambda x: HCI_LE_Set_Extended_Advertising_Data_Command.operation_name(x)}),
+ ('fragment_preference', 1),
+ ('advertising_data', {
+ 'parser': HCI_Object.parse_length_prefixed_bytes,
+ 'serializer': functools.partial(HCI_Object.serialize_length_prefixed_bytes)
+ })
+])
+class HCI_LE_Set_Extended_Advertising_Data_Command(HCI_Command):
+ '''
+ See Bluetooth spec @ 7.8.54 LE Set Extended Advertising Data Command
+ '''
+
+ INTERMEDIATE_FRAGMENT = 0x00
+ FIRST_FRAGMENT = 0x01
+ LAST_FRAGMENT = 0x02
+ COMPLETE_DATA = 0x03
+ UNCHANGED_DATA = 0x04
+
+ OPERATION_NAMES = {
+ INTERMEDIATE_FRAGMENT: 'INTERMEDIATE_FRAGMENT',
+ FIRST_FRAGMENT: 'FIRST_FRAGMENT',
+ LAST_FRAGMENT: 'LAST_FRAGMENT',
+ COMPLETE_DATA: 'COMPLETE_DATA',
+ UNCHANGED_DATA: 'UNCHANGED_DATA'
+ }
+
+ @classmethod
+ def operation_name(cls, operation):
+ return name_or_number(cls.OPERATION_NAMES, operation)
+
+
+# -----------------------------------------------------------------------------
+@HCI_Command.command([
+ ('advertising_handle', 1),
+ ('operation', {'size': 1, 'mapper': lambda x: HCI_LE_Set_Extended_Advertising_Data_Command.operation_name(x)}),
+ ('fragment_preference', 1),
+ ('scan_response_data', {
+ 'parser': HCI_Object.parse_length_prefixed_bytes,
+ 'serializer': functools.partial(HCI_Object.serialize_length_prefixed_bytes)
+ })
+])
+class HCI_LE_Set_Extended_Scan_Response_Data_Command(HCI_Command):
+ '''
+ See Bluetooth spec @ 7.8.55 LE Set Extended Scan Response Data Command
+ '''
+
+ INTERMEDIATE_FRAGMENT = 0x00
+ FIRST_FRAGMENT = 0x01
+ LAST_FRAGMENT = 0x02
+ COMPLETE_DATA = 0x03
+
+ OPERATION_NAMES = {
+ INTERMEDIATE_FRAGMENT: 'INTERMEDIATE_FRAGMENT',
+ FIRST_FRAGMENT: 'FIRST_FRAGMENT',
+ LAST_FRAGMENT: 'LAST_FRAGMENT',
+ COMPLETE_DATA: 'COMPLETE_DATA'
+ }
+
+ @classmethod
+ def operation_name(cls, operation):
+ return name_or_number(cls.OPERATION_NAMES, operation)
+
+
+# -----------------------------------------------------------------------------
+@HCI_Command.command(fields=None)
+class HCI_LE_Set_Extended_Advertising_Enable_Command(HCI_Command):
+ '''
+ See Bluetooth spec @ 7.8.56 LE Set Extended Advertising Enable Command
+ '''
+
+ @classmethod
+ def from_parameters(cls, parameters):
+ enable = parameters[0]
+ num_sets = parameters[1]
+ advertising_handles = []
+ durations = []
+ max_extended_advertising_events = []
+ offset = 2
+ for _ in range(num_sets):
+ advertising_handles.append(parameters[offset])
+ durations.append(struct.unpack_from('<H', parameters, offset + 1)[0])
+ max_extended_advertising_events.append(parameters[offset + 3])
+ offset += 4
+
+ return cls(enable, advertising_handles, durations, max_extended_advertising_events)
+
+ def __init__(self, enable, advertising_handles, durations, max_extended_advertising_events):
+ super().__init__(HCI_LE_SET_EXTENDED_ADVERTISING_ENABLE_COMMAND)
+ self.enable = enable
+ self.advertising_handles = advertising_handles
+ self.durations = durations
+ self.max_extended_advertising_events = max_extended_advertising_events
+
+ self.parameters = bytes([enable, len(advertising_handles)]) + b''.join([
+ struct.pack(
+ '<BHB',
+ advertising_handles[i],
+ durations[i],
+ max_extended_advertising_events[i]
+ )
+ for i in range(len(advertising_handles))
+ ])
+
+ def __str__(self):
+ fields = [('enable:', self.enable)]
+ for i in range(len(self.advertising_handles)):
+ fields.append((f'advertising_handle[{i}]: ', self.advertising_handles[i]))
+ fields.append((f'duration[{i}]: ', self.durations[i]))
+ fields.append((f'max_extended_advertising_events[{i}]:', self.max_extended_advertising_events[i]))
+
+ return color(self.name, 'green') + ':\n' + '\n'.join(
+ [color(field[0], 'cyan') + ' ' + str(field[1]) for field in fields]
+ )
+
+
+# -----------------------------------------------------------------------------
+@HCI_Command.command(return_parameters_fields=[
+ ('status', STATUS_SPEC),
+ ('max_advertising_data_length', 2)
+])
+class HCI_LE_Read_Maximum_Advertising_Data_Length_Command(HCI_Command):
+ '''
+ See Bluetooth spec @ 7.8.57 LE Read Maximum Advertising Data Length Command
+ '''
+
+
+# -----------------------------------------------------------------------------
+@HCI_Command.command(return_parameters_fields=[
+ ('status', STATUS_SPEC),
+ ('num_supported_advertising_sets', 1)
+])
+class HCI_LE_Read_Number_Of_Supported_Advertising_Sets_Command(HCI_Command):
+ '''
+ See Bluetooth spec @ 7.8.58 LE Read Number of Supported Advertising Sets Command
+ '''
+
+
+# -----------------------------------------------------------------------------
+@HCI_Command.command([
+ ('advertising_handle', 1)
+])
+class HCI_LE_Remove_Advertising_Set_Command(HCI_Command):
+ '''
+ See Bluetooth spec @ 7.8.59 LE Remove Advertising Set Command
+ '''
+
+
+# -----------------------------------------------------------------------------
+@HCI_Command.command()
+class HCI_LE_Clear_Advertising_Sets_Command(HCI_Command):
+ '''
+ See Bluetooth spec @ 7.8.60 LE Clear Advertising Sets Command
+ '''
+
+
+# -----------------------------------------------------------------------------
+@HCI_Command.command([
+ ('enable', 1),
+ ('advertising_handle', 1)
+])
+class HCI_LE_Set_Periodic_Advertising_Enable_Command(HCI_Command):
+ '''
+ See Bluetooth spec @ 7.8.63 LE Set Periodic Advertising Enable Command
+ '''
+
+
+# -----------------------------------------------------------------------------
@HCI_Command.command(fields=None)
class HCI_LE_Set_Extended_Scan_Parameters_Command(HCI_Command):
'''
@@ -3102,11 +3543,8 @@
EXTENDED_UNFILTERED_POLICY = 0x02
EXTENDED_FILTERED_POLICY = 0x03
- LE_1M_PHY = 0x00
- LE_CODED_PHY = 0x02
-
- @staticmethod
- def from_parameters(parameters):
+ @classmethod
+ def from_parameters(cls, parameters):
own_address_type = parameters[0]
scanning_filter_policy = parameters[1]
scanning_phys = parameters[2]
@@ -3116,11 +3554,11 @@
scan_intervals = []
scan_windows = []
for i in range(phy_bits_set):
- scan_types.append(parameters[3 + (3 * i)])
- scan_intervals.append(parameters[3 + (3 * i) + 1])
- scan_windows.append(parameters[3 + (3 * i) + 2])
+ scan_types.append(parameters[3 + (5 * i)])
+ scan_intervals.append(struct.unpack_from('<H', parameters, 3 + (5 * i) + 1)[0])
+ scan_windows.append(struct.unpack_from('<H', parameters, 3 + (5 * i) + 3)[0])
- return HCI_LE_Set_Extended_Scan_Parameters_Command(
+ return cls(
own_address_type = own_address_type,
scanning_filter_policy = scanning_filter_policy,
scanning_phys = scanning_phys,
@@ -3149,20 +3587,10 @@
self.parameters = bytes([own_address_type, scanning_filter_policy, scanning_phys])
phy_bits_set = bin(scanning_phys).count('1')
for i in range(phy_bits_set):
- self.parameters += bytes([scan_types[i], scan_intervals[i], scan_windows[i]])
+ self.parameters += struct.pack('<BHH', scan_types[i], scan_intervals[i], scan_windows[i])
def __str__(self):
- scanning_phys_strs = []
-
- for bit in range(8):
- if self.scanning_phys & (1 << bit) != 0:
- if bit == 0:
- scanning_phys_strs.append('LE_1M_PHY')
- elif bit == 2:
- scanning_phys_strs.append('LE_CODED_PHY')
- else:
- scanning_phys_strs.append(f'0x{(1 << bit):02X}')
-
+ scanning_phys_strs = bit_flags_to_strings(self.scanning_phys, HCI_LE_PHY_BIT_NAMES)
fields = [
('own_address_type: ', Address.address_type_name(self.own_address_type)),
('scanning_filter_policy:', self.scanning_filter_policy),
@@ -3179,6 +3607,165 @@
# -----------------------------------------------------------------------------
+@HCI_Command.command([
+ ('enable', 1),
+ ('filter_duplicates', 1),
+ ('duration', 2),
+ ('period', 2)
+])
+class HCI_LE_Set_Extended_Scan_Enable_Command(HCI_Command):
+ '''
+ See Bluetooth spec @ 7.8.65 LE Set Extended Scan Enable Command
+ '''
+
+
+# -----------------------------------------------------------------------------
+@HCI_Command.command(fields=None)
+class HCI_LE_Extended_Create_Connection_Command(HCI_Command):
+ '''
+ See Bluetooth spec @ 7.8.66 LE Extended Create Connection Command
+ '''
+
+ @classmethod
+ def from_parameters(cls, parameters):
+ initiator_filter_policy = parameters[0]
+ own_address_type = parameters[1]
+ peer_address_type = parameters[2]
+ peer_address = Address.parse_address_preceded_by_type(parameters, 3)[1]
+ initiating_phys = parameters[9]
+
+ phy_bits_set = bin(initiating_phys).count('1')
+
+ def read_parameter_list(offset):
+ return [struct.unpack_from('<H', parameters, offset + 16 * i)[0] for i in range(phy_bits_set)]
+
+ return cls(
+ initiator_filter_policy = initiator_filter_policy,
+ own_address_type = own_address_type,
+ peer_address_type = peer_address_type,
+ peer_address = peer_address,
+ initiating_phys = initiating_phys,
+ scan_intervals = read_parameter_list(10),
+ scan_windows = read_parameter_list(12),
+ connection_interval_mins = read_parameter_list(14),
+ connection_interval_maxs = read_parameter_list(16),
+ max_latencies = read_parameter_list(18),
+ supervision_timeouts = read_parameter_list(20),
+ min_ce_lengths = read_parameter_list(22),
+ max_ce_lengths = read_parameter_list(24)
+ )
+
+ def __init__(
+ self,
+ initiator_filter_policy,
+ own_address_type,
+ peer_address_type,
+ peer_address,
+ initiating_phys,
+ scan_intervals,
+ scan_windows,
+ connection_interval_mins,
+ connection_interval_maxs,
+ max_latencies,
+ supervision_timeouts,
+ min_ce_lengths,
+ max_ce_lengths
+ ):
+ super().__init__(HCI_LE_EXTENDED_CREATE_CONNECTION_COMMAND)
+ self.initiator_filter_policy = initiator_filter_policy
+ self.own_address_type = own_address_type
+ self.peer_address_type = peer_address_type
+ self.peer_address = peer_address
+ self.initiating_phys = initiating_phys
+ self.scan_intervals = scan_intervals
+ self.scan_windows = scan_windows
+ self.connection_interval_mins = connection_interval_mins
+ self.connection_interval_maxs = connection_interval_maxs
+ self.max_latencies = max_latencies
+ self.supervision_timeouts = supervision_timeouts
+ self.min_ce_lengths = min_ce_lengths
+ self.max_ce_lengths = max_ce_lengths
+
+ self.parameters = bytes([
+ initiator_filter_policy,
+ own_address_type,
+ peer_address_type
+ ]) + bytes(peer_address) + bytes([initiating_phys])
+
+ phy_bits_set = bin(initiating_phys).count('1')
+ for i in range(phy_bits_set):
+ self.parameters += struct.pack(
+ '<HHHHHHHH',
+ scan_intervals[i],
+ scan_windows[i],
+ connection_interval_mins[i],
+ connection_interval_maxs[i],
+ max_latencies[i],
+ supervision_timeouts[i],
+ min_ce_lengths[i],
+ max_ce_lengths[i]
+ )
+
+ def __str__(self):
+ initiating_phys_strs = bit_flags_to_strings(self.initiating_phys, HCI_LE_PHY_BIT_NAMES)
+ fields = [
+ ('initiator_filter_policy:', self.initiator_filter_policy),
+ ('own_address_type: ', OwnAddressType.type_name(self.own_address_type)),
+ ('peer_address_type: ', Address.address_type_name(self.peer_address_type)),
+ ('peer_address: ', str(self.peer_address)),
+ ('initiating_phys: ', ','.join(initiating_phys_strs)),
+ ]
+ for (i, initiating_phys_str) in enumerate(initiating_phys_strs):
+ fields.append((f'{initiating_phys_str}.scan_interval: ', self.scan_intervals[i])),
+ fields.append((f'{initiating_phys_str}.scan_window: ', self.scan_windows[i])),
+ fields.append((f'{initiating_phys_str}.connection_interval_min:', self.connection_interval_mins[i])),
+ fields.append((f'{initiating_phys_str}.connection_interval_max:', self.connection_interval_maxs[i])),
+ fields.append((f'{initiating_phys_str}.max_latency: ', self.max_latencies[i])),
+ fields.append((f'{initiating_phys_str}.supervision_timeout: ', self.supervision_timeouts[i])),
+ fields.append((f'{initiating_phys_str}.min_ce_length: ', self.min_ce_lengths[i])),
+ fields.append((f'{initiating_phys_str}.max_ce_length: ', self.max_ce_lengths[i]))
+
+ return color(self.name, 'green') + ':\n' + '\n'.join(
+ [color(field[0], 'cyan') + ' ' + str(field[1]) for field in fields]
+ )
+
+
+# -----------------------------------------------------------------------------
+@HCI_Command.command([
+ ('peer_identity_address_type', Address.ADDRESS_TYPE_SPEC),
+ ('peer_identity_address', Address.parse_address_preceded_by_type),
+ ('privacy_mode', {'size': 1, 'mapper': lambda x: HCI_LE_Set_Privacy_Mode_Command.privacy_mode_name(x)})
+])
+class HCI_LE_Set_Privacy_Mode_Command(HCI_Command):
+ '''
+ See Bluetooth spec @ 7.8.77 LE Set Privacy Mode Command
+ '''
+
+ NETWORK_PRIVACY_MODE = 0x00
+ DEVICE_PRIVACY_MODE = 0x01
+
+ PRIVACY_MODE_NAMES = {
+ NETWORK_PRIVACY_MODE: 'NETWORK_PRIVACY_MODE',
+ DEVICE_PRIVACY_MODE: 'DEVICE_PRIVACY_MODE'
+ }
+
+ @classmethod
+ def privacy_mode_name(cls, privacy_mode):
+ return name_or_number(cls.PRIVACY_MODE_NAMES, privacy_mode)
+
+
+# -----------------------------------------------------------------------------
+@HCI_Command.command([
+ ('bit_number', 1),
+ ('bit_value', 1)
+])
+class HCI_LE_Set_Host_Feature_Command(HCI_Command):
+ '''
+ See Bluetooth spec @ 7.8.115 LE Set Host Feature Command
+ '''
+
+
+# -----------------------------------------------------------------------------
# HCI Events
# -----------------------------------------------------------------------------
class HCI_Event(HCI_Packet):
@@ -3353,15 +3940,15 @@
# -----------------------------------------------------------------------------
@HCI_LE_Meta_Event.event([
- ('status', STATUS_SPEC),
- ('connection_handle', 2),
- ('role', {'size': 1, 'mapper': lambda x: 'CENTRAL' if x == 0 else 'PERIPHERAL'}),
- ('peer_address_type', Address.ADDRESS_TYPE_SPEC),
- ('peer_address', Address.parse_address_preceded_by_type),
- ('conn_interval', 2),
- ('conn_latency', 2),
- ('supervision_timeout', 2),
- ('master_clock_accuracy', 1)
+ ('status', STATUS_SPEC),
+ ('connection_handle', 2),
+ ('role', {'size': 1, 'mapper': lambda x: 'CENTRAL' if x == 0 else 'PERIPHERAL'}),
+ ('peer_address_type', Address.ADDRESS_TYPE_SPEC),
+ ('peer_address', Address.parse_address_preceded_by_type),
+ ('connection_interval', 2),
+ ('peripheral_latency', 2),
+ ('supervision_timeout', 2),
+ ('central_clock_accuracy', 1)
])
class HCI_LE_Connection_Complete_Event(HCI_LE_Meta_Event):
'''
@@ -3391,29 +3978,44 @@
SCAN_RSP: 'SCAN_RSP' # Scan Response
}
- REPORT_FIELDS = [
- ('event_type', 1),
- ('address_type', Address.ADDRESS_TYPE_SPEC),
- ('address', Address.parse_address_preceded_by_type),
- ('data', {'parser': HCI_Object.parse_length_prefixed_bytes, 'serializer': HCI_Object.serialize_length_prefixed_bytes}),
- ('rssi', -1)
- ]
+ class Report(HCI_Object):
+ FIELDS = [
+ ('event_type', 1),
+ ('address_type', Address.ADDRESS_TYPE_SPEC),
+ ('address', Address.parse_address_preceded_by_type),
+ ('data', {'parser': HCI_Object.parse_length_prefixed_bytes, 'serializer': HCI_Object.serialize_length_prefixed_bytes}),
+ ('rssi', -1)
+ ]
+
+ @classmethod
+ def from_parameters(cls, parameters, offset):
+ return cls.from_bytes(parameters, offset, cls.FIELDS)
+
+ def event_type_string(self):
+ return HCI_LE_Advertising_Report_Event.event_type_name(self.event_type)
+
+ def to_string(self, prefix):
+ return super().to_string(prefix, {
+ 'event_type': HCI_LE_Advertising_Report_Event.event_type_name,
+ 'address_type': Address.address_type_name,
+ 'data': lambda x: str(AdvertisingData.from_bytes(x))
+ })
@classmethod
def event_type_name(cls, event_type):
return name_or_number(cls.EVENT_TYPE_NAMES, event_type)
- @staticmethod
- def from_parameters(parameters):
+ @classmethod
+ def from_parameters(cls, parameters):
num_reports = parameters[1]
reports = []
offset = 2
for _ in range(num_reports):
- report = HCI_Object.from_bytes(parameters, offset, HCI_LE_Advertising_Report_Event.REPORT_FIELDS)
+ report = cls.Report.from_parameters(parameters, offset)
offset += 10 + len(report.data)
reports.append(report)
- return HCI_LE_Advertising_Report_Event(reports)
+ return cls(reports)
def __init__(self, reports):
self.reports = reports[:]
@@ -3424,11 +4026,7 @@
super().__init__(self.subevent_code, parameters)
def __str__(self):
- reports = '\n'.join([report.to_string(' ', {
- 'event_type': self.event_type_name,
- 'address_type': Address.address_type_name,
- 'data': lambda x: str(AdvertisingData.from_bytes(x))
- }) for report in self.reports])
+ reports = '\n'.join([f'{i}:\n{report.to_string(" ")}' for i, report in enumerate(self.reports)])
return f'{color(self.subevent_name(self.subevent_code), "magenta")}:\n{reports}'
@@ -3439,8 +4037,8 @@
@HCI_LE_Meta_Event.event([
('status', STATUS_SPEC),
('connection_handle', 2),
- ('conn_interval', 2),
- ('conn_latency', 2),
+ ('connection_interval', 2),
+ ('peripheral_latency', 2),
('supervision_timeout', 2)
])
class HCI_LE_Connection_Update_Complete_Event(HCI_LE_Meta_Event):
@@ -3478,7 +4076,7 @@
('connection_handle', 2),
('interval_min', 2),
('interval_max', 2),
- ('latency', 2),
+ ('max_latency', 2),
('timeout', 2)
])
class HCI_LE_Remote_Connection_Parameter_Request_Event(HCI_LE_Meta_Event):
@@ -3510,10 +4108,10 @@
('peer_address', Address.parse_address_preceded_by_type),
('local_resolvable_private_address', Address.parse_address),
('peer_resolvable_private_address', Address.parse_address),
- ('conn_interval', 2),
- ('conn_latency', 2),
+ ('connection_interval', 2),
+ ('peripheral_latency', 2),
('supervision_timeout', 2),
- ('master_clock_accuracy', 1)
+ ('central_clock_accuracy', 1)
])
class HCI_LE_Enhanced_Connection_Complete_Event(HCI_LE_Meta_Event):
'''
@@ -3535,6 +4133,124 @@
# -----------------------------------------------------------------------------
+class HCI_LE_Extended_Advertising_Report_Event(HCI_LE_Meta_Event):
+ '''
+ See Bluetooth spec @ 7.7.65.13 LE Extended Advertising Report Event
+ '''
+ subevent_code = HCI_LE_EXTENDED_ADVERTISING_REPORT_EVENT
+
+ # Event types flags
+ CONNECTABLE_ADVERTISING = 0
+ SCANNABLE_ADVERTISING = 1
+ DIRECTED_ADVERTISING = 2
+ SCAN_RESPONSE = 3
+ LEGACY_ADVERTISING_PDU_USED = 4
+
+ DATA_COMPLETE = 0x00
+ DATA_INCOMPLETE_MORE_TO_COME = 0x01
+ DATA_INCOMPLETE_TRUNCATED_NO_MORE_TO_COME = 0x02
+
+ EVENT_TYPE_FLAG_NAMES = (
+ 'CONNECTABLE_ADVERTISING',
+ 'SCANNABLE_ADVERTISING',
+ 'DIRECTED_ADVERTISING',
+ 'SCAN_RESPONSE',
+ 'LEGACY_ADVERTISING_PDU_USED'
+ )
+
+ LEGACY_PDU_TYPE_MAP = {
+ 0b0011: HCI_LE_Advertising_Report_Event.ADV_IND,
+ 0b0101: HCI_LE_Advertising_Report_Event.ADV_DIRECT_IND,
+ 0b0010: HCI_LE_Advertising_Report_Event.ADV_SCAN_IND,
+ 0b0000: HCI_LE_Advertising_Report_Event.ADV_NONCONN_IND,
+ 0b1011: HCI_LE_Advertising_Report_Event.SCAN_RSP,
+ 0b1010: HCI_LE_Advertising_Report_Event.SCAN_RSP
+ }
+
+ NO_ADI_FIELD_PROVIDED = 0xFF
+ TX_POWER_INFORMATION_NOT_AVAILABLE = 0x7F
+ RSSI_NOT_AVAILABLE = 0x7F
+ ANONYMOUS_ADDRESS_TYPE = 0xFF
+ UNRESOLVED_RESOLVABLE_ADDRESS_TYPE = 0xFE
+
+ class Report(HCI_Object):
+ FIELDS = [
+ ('event_type', 2),
+ ('address_type', Address.ADDRESS_TYPE_SPEC),
+ ('address', Address.parse_address_preceded_by_type),
+ ('primary_phy', {'size': 1, 'mapper': HCI_Constant.le_phy_name}),
+ ('secondary_phy', {'size': 1, 'mapper': HCI_Constant.le_phy_name}),
+ ('advertising_sid', 1),
+ ('tx_power', 1),
+ ('rssi', -1),
+ ('periodic_advertising_interval', 2),
+ ('direct_address_type', Address.ADDRESS_TYPE_SPEC),
+ ('direct_address', Address.parse_address_preceded_by_type),
+ ('data', {'parser': HCI_Object.parse_length_prefixed_bytes, 'serializer': HCI_Object.serialize_length_prefixed_bytes}),
+ ]
+
+ @classmethod
+ def from_parameters(cls, parameters, offset):
+ return cls.from_bytes(parameters, offset, cls.FIELDS)
+
+ def event_type_string(self):
+ return HCI_LE_Extended_Advertising_Report_Event.event_type_string(self.event_type)
+
+ def to_string(self, prefix):
+ return super().to_string(prefix, {
+ 'event_type': HCI_LE_Extended_Advertising_Report_Event.event_type_string,
+ 'address_type': Address.address_type_name,
+ 'data': lambda x: str(AdvertisingData.from_bytes(x))
+ })
+
+ @staticmethod
+ def event_type_string(event_type):
+ event_type_flags = bit_flags_to_strings(
+ event_type & 0x1F,
+ HCI_LE_Extended_Advertising_Report_Event.EVENT_TYPE_FLAG_NAMES,
+ )
+ event_type_flags.append(('COMPLETE', 'INCOMPLETE+', 'INCOMPLETE#', '?')[(event_type >> 5) & 3])
+
+ if event_type & (1 << HCI_LE_Extended_Advertising_Report_Event.LEGACY_ADVERTISING_PDU_USED):
+ legacy_pdu_type = HCI_LE_Extended_Advertising_Report_Event.LEGACY_PDU_TYPE_MAP.get(event_type & 0x0F)
+ if legacy_pdu_type is not None:
+ legacy_info_string = f'({HCI_LE_Advertising_Report_Event.event_type_name(legacy_pdu_type)})'
+ else:
+ legacy_info_string = ''
+ else:
+ legacy_info_string = ''
+
+ return f'0x{event_type:04X} [{",".join(event_type_flags)}]{legacy_info_string}'
+
+ @classmethod
+ def from_parameters(cls, parameters):
+ num_reports = parameters[1]
+ reports = []
+ offset = 2
+ for _ in range(num_reports):
+ report = cls.Report.from_parameters(parameters, offset)
+ offset += 24 + len(report.data)
+ reports.append(report)
+
+ return cls(reports)
+
+ def __init__(self, reports):
+ self.reports = reports[:]
+
+ # Serialize the fields
+ parameters = bytes([HCI_LE_EXTENDED_ADVERTISING_REPORT_EVENT, len(reports)]) + b''.join([bytes(report) for report in reports])
+
+ super().__init__(self.subevent_code, parameters)
+
+ def __str__(self):
+ reports = '\n'.join([f'{i}:\n{report.to_string(" ")}' for i, report in enumerate(self.reports)])
+ return f'{color(self.subevent_name(self.subevent_code), "magenta")}:\n{reports}'
+
+
+HCI_Event.meta_event_classes[HCI_LE_EXTENDED_ADVERTISING_REPORT_EVENT] = HCI_LE_Extended_Advertising_Report_Event
+
+
+# -----------------------------------------------------------------------------
@HCI_LE_Meta_Event.event([
('connection_handle', 2),
('channel_selection_algorithm', 1)
@@ -3686,7 +4402,7 @@
E0_OR_AES_CCM = 0x01
AES_CCM = 0x02
- ENCYRPTION_ENABLED_NAMES = {
+ ENCRYPTION_ENABLED_NAMES = {
OFF: 'OFF',
E0_OR_AES_CCM: 'E0_OR_AES_CCM',
AES_CCM: 'AES_CCM'
@@ -3694,7 +4410,7 @@
@staticmethod
def encryption_enabled_name(encryption_enabled):
- return name_or_number(HCI_Encryption_Change_Event.ENCYRPTION_ENABLED_NAMES, encryption_enabled)
+ return name_or_number(HCI_Encryption_Change_Event.ENCRYPTION_ENABLED_NAMES, encryption_enabled)
# -----------------------------------------------------------------------------
@@ -3954,7 +4670,7 @@
# -----------------------------------------------------------------------------
@HCI_Event.registered
-class HCI_Inquiry_Result_With_Rssi_Event(HCI_Event):
+class HCI_Inquiry_Result_With_RSSI_Event(HCI_Event):
'''
See Bluetooth spec @ 7.7.33 Inquiry Result with RSSI Event
'''
@@ -3974,11 +4690,11 @@
responses = []
offset = 1
for _ in range(num_responses):
- response = HCI_Object.from_bytes(parameters, offset, HCI_Inquiry_Result_With_Rssi_Event.RESPONSE_FIELDS)
+ response = HCI_Object.from_bytes(parameters, offset, HCI_Inquiry_Result_With_RSSI_Event.RESPONSE_FIELDS)
offset += 14
responses.append(response)
- return HCI_Inquiry_Result_With_Rssi_Event(responses)
+ return HCI_Inquiry_Result_With_RSSI_Event(responses)
def __init__(self, responses):
self.responses = responses[:]
@@ -4041,7 +4757,7 @@
U_LAW_LOG_AIR_MODE: 'u-law log',
A_LAW_LOG_AIR_MORE: 'A-law log',
CVSD_AIR_MODE: 'CVSD',
- TRANSPARENT_DATA_AIR_MODE: 'Transparend Data'
+ TRANSPARENT_DATA_AIR_MODE: 'Transparent Data'
}
@staticmethod
@@ -4164,6 +4880,17 @@
# -----------------------------------------------------------------------------
@HCI_Event.event([
+ ('bd_addr', Address.parse_address),
+ ('passkey', 4)
+])
+class HCI_User_Passkey_Notification_Event(HCI_Event):
+ '''
+ See Bluetooth spec @ 7.7.48 User Passkey Notification Event
+ '''
+
+
+# -----------------------------------------------------------------------------
+@HCI_Event.event([
('bd_addr', Address.parse_address),
('host_supported_features', 8)
])
diff --git a/bumble/host.py b/bumble/host.py
index cc692d0..8e43c50 100644
--- a/bumble/host.py
+++ b/bumble/host.py
@@ -76,9 +76,11 @@
self.hc_total_num_acl_data_packets = HOST_HC_TOTAL_NUM_ACL_DATA_PACKETS
self.acl_packet_queue = collections.deque()
self.acl_packets_in_flight = 0
- self.local_version = HCI_VERSION_BLUETOOTH_CORE_4_0
+ self.local_version = None
self.local_supported_commands = bytes(64)
self.local_le_features = 0
+ self.suggested_max_tx_octets = 251 # Max allowed
+ self.suggested_max_tx_time = 2120 # Max allowed
self.command_semaphore = asyncio.Semaphore(1)
self.long_term_key_provider = None
self.link_key_provider = None
@@ -91,32 +93,23 @@
self.set_packet_sink(controller_sink)
async def reset(self):
- await self.send_command(HCI_Reset_Command())
+ await self.send_command(HCI_Reset_Command(), check_result=True)
self.ready = True
- response = await self.send_command(HCI_Read_Local_Supported_Commands_Command())
- if response.return_parameters.status == HCI_SUCCESS:
- self.local_supported_commands = response.return_parameters.supported_commands
- else:
- logger.warn(f'HCI_Read_Local_Supported_Commands_Command failed: {response.return_parameters.status}')
+ response = await self.send_command(HCI_Read_Local_Supported_Commands_Command(), check_result=True)
+ self.local_supported_commands = response.return_parameters.supported_commands
if self.supports_command(HCI_LE_READ_LOCAL_SUPPORTED_FEATURES_COMMAND):
- response = await self.send_command(HCI_LE_Read_Local_Supported_Features_Command())
- if response.return_parameters.status == HCI_SUCCESS:
- self.local_le_features = struct.unpack('<Q', response.return_parameters.le_features)[0]
- else:
- logger.warn(f'HCI_LE_Read_Supported_Features_Command failed: {response.return_parameters.status}')
+ response = await self.send_command(HCI_LE_Read_Local_Supported_Features_Command(), check_result=True)
+ self.local_le_features = struct.unpack('<Q', response.return_parameters.le_features)[0]
if self.supports_command(HCI_READ_LOCAL_VERSION_INFORMATION_COMMAND):
- response = await self.send_command(HCI_Read_Local_Version_Information_Command())
- if response.return_parameters.status == HCI_SUCCESS:
- self.local_version = response.return_parameters
- else:
- logger.warn(f'HCI_Read_Local_Version_Information_Command failed: {response.return_parameters.status}')
+ response = await self.send_command(HCI_Read_Local_Version_Information_Command(), check_result=True)
+ self.local_version = response.return_parameters
await self.send_command(HCI_Set_Event_Mask_Command(event_mask = bytes.fromhex('FFFFFFFFFFFFFF3F')))
- if self.local_version.hci_version <= HCI_VERSION_BLUETOOTH_CORE_4_0:
+ if self.local_version is not None and self.local_version.hci_version <= HCI_VERSION_BLUETOOTH_CORE_4_0:
# Some older controllers don't like event masks with bits they don't understand
le_event_mask = bytes.fromhex('1F00000000000000')
else:
@@ -124,34 +117,48 @@
await self.send_command(HCI_LE_Set_Event_Mask_Command(le_event_mask = le_event_mask))
if self.supports_command(HCI_READ_BUFFER_SIZE_COMMAND):
- response = await self.send_command(HCI_Read_Buffer_Size_Command())
- if response.return_parameters.status == HCI_SUCCESS:
- self.hc_acl_data_packet_length = response.return_parameters.hc_acl_data_packet_length
- self.hc_total_num_acl_data_packets = response.return_parameters.hc_total_num_acl_data_packets
- else:
- logger.warn(f'HCI_Read_Buffer_Size_Command failed: {response.return_parameters.status}')
+ response = await self.send_command(HCI_Read_Buffer_Size_Command(), check_result=True)
+ self.hc_acl_data_packet_length = response.return_parameters.hc_acl_data_packet_length
+ self.hc_total_num_acl_data_packets = response.return_parameters.hc_total_num_acl_data_packets
+
+ logger.debug(
+ f'HCI ACL flow control: hc_acl_data_packet_length={self.hc_acl_data_packet_length},'
+ f'hc_total_num_acl_data_packets={self.hc_total_num_acl_data_packets}'
+ )
if self.supports_command(HCI_LE_READ_BUFFER_SIZE_COMMAND):
- response = await self.send_command(HCI_LE_Read_Buffer_Size_Command())
- if response.return_parameters.status == HCI_SUCCESS:
- self.hc_le_acl_data_packet_length = response.return_parameters.hc_le_acl_data_packet_length
- self.hc_total_num_le_acl_data_packets = response.return_parameters.hc_total_num_le_acl_data_packets
- else:
- logger.warn(f'HCI_LE_Read_Buffer_Size_Command failed: {response.return_parameters.status}')
+ response = await self.send_command(HCI_LE_Read_Buffer_Size_Command(), check_result=True)
+ self.hc_le_acl_data_packet_length = response.return_parameters.hc_le_acl_data_packet_length
+ self.hc_total_num_le_acl_data_packets = response.return_parameters.hc_total_num_le_acl_data_packets
- if response.return_parameters.hc_le_acl_data_packet_length == 0 or response.return_parameters.hc_total_num_le_acl_data_packets == 0:
- # LE and Classic share the same values
- self.hc_le_acl_data_packet_length = self.hc_acl_data_packet_length
- self.hc_total_num_le_acl_data_packets = self.hc_total_num_acl_data_packets
+ logger.debug(
+ f'HCI LE ACL flow control: hc_le_acl_data_packet_length={self.hc_le_acl_data_packet_length},'
+ f'hc_total_num_le_acl_data_packets={self.hc_total_num_le_acl_data_packets}'
+ )
- logger.debug(
- f'HCI ACL flow control: hc_acl_data_packet_length={self.hc_acl_data_packet_length},'
- f'hc_total_num_acl_data_packets={self.hc_total_num_acl_data_packets}'
- )
- logger.debug(
- f'HCI LE ACL flow control: hc_le_acl_data_packet_length={self.hc_le_acl_data_packet_length},'
- f'hc_total_num_le_acl_data_packets={self.hc_total_num_le_acl_data_packets}'
- )
+ if (
+ response.return_parameters.hc_le_acl_data_packet_length == 0 or
+ response.return_parameters.hc_total_num_le_acl_data_packets == 0
+ ):
+ # LE and Classic share the same values
+ self.hc_le_acl_data_packet_length = self.hc_acl_data_packet_length
+ self.hc_total_num_le_acl_data_packets = self.hc_total_num_acl_data_packets
+
+ if (
+ self.supports_command(HCI_LE_READ_SUGGESTED_DEFAULT_DATA_LENGTH_COMMAND) and
+ self.supports_command(HCI_LE_WRITE_SUGGESTED_DEFAULT_DATA_LENGTH_COMMAND)
+ ):
+ response = await self.send_command(HCI_LE_Read_Suggested_Default_Data_Length_Command())
+ suggested_max_tx_octets = response.return_parameters.suggested_max_tx_octets
+ suggested_max_tx_time = response.return_parameters.suggested_max_tx_time
+ if (
+ suggested_max_tx_octets != self.suggested_max_tx_octets or
+ suggested_max_tx_time != self.suggested_max_tx_time
+ ):
+ await self.send_command(HCI_LE_Write_Suggested_Default_Data_Length_Command(
+ suggested_max_tx_octets = self.suggested_max_tx_octets,
+ suggested_max_tx_time = self.suggested_max_tx_time
+ ))
self.reset_done = True
@@ -171,7 +178,7 @@
def send_hci_packet(self, packet):
self.hci_sink.on_packet(packet.to_bytes())
- async def send_command(self, command):
+ async def send_command(self, command, check_result=False):
logger.debug(f'{color("### HOST -> CONTROLLER", "blue")}: {command}')
# Wait until we can send (only one pending command at a time)
@@ -186,11 +193,25 @@
try:
self.send_hci_packet(command)
response = await self.pending_response
- # TODO: check error values
+
+ # Check the return parameters if required
+ if check_result:
+ if type(response.return_parameters) is int:
+ status = response.return_parameters
+ elif type(response.return_parameters) is bytes:
+ # return parameters first field is a one byte status code
+ status = response.return_parameters[0]
+ else:
+ status = response.return_parameters.status
+
+ if status != HCI_SUCCESS:
+ logger.warning(f'{command.name} failed ({HCI_Constant.error_name(status)})')
+ raise HCI_Error(status)
+
return response
except Exception as error:
logger.warning(f'{color("!!! Exception while sending HCI packet:", "red")} {error}')
- # raise error
+ raise error
finally:
self.pending_command = None
self.pending_response = None
@@ -348,13 +369,12 @@
# Classic only
def on_hci_connection_request_event(self, event):
- # For now, just accept everything
- # TODO: delegate the decision
- self.send_command_sync(
- HCI_Accept_Connection_Request_Command(
- bd_addr = event.bd_addr,
- role = 0x01 # Remain the peripheral
- )
+ # Notify the listeners
+ self.emit(
+ 'connection_request',
+ event.bd_addr,
+ event.class_of_device,
+ event.link_type,
)
def on_hci_le_connection_complete_event(self, event):
@@ -370,8 +390,8 @@
# Notify the client
connection_parameters = ConnectionParameters(
- event.conn_interval,
- event.conn_latency,
+ event.connection_interval,
+ event.peripheral_latency,
event.supervision_timeout
)
self.emit(
@@ -387,7 +407,7 @@
logger.debug(f'### CONNECTION FAILED: {event.status}')
# Notify the listeners
- self.emit('connection_failure', event.status)
+ self.emit('connection_failure', BT_LE_TRANSPORT, event.peer_address, event.status)
def on_hci_le_enhanced_connection_complete_event(self, event):
# Just use the same implementation as for the non-enhanced event for now
@@ -417,7 +437,7 @@
logger.debug(f'### BR/EDR CONNECTION FAILED: {event.status}')
# Notify the client
- self.emit('connection_failure', event.connection_handle, event.status)
+ self.emit('connection_failure', BT_BR_EDR_TRANSPORT, event.bd_addr, event.status)
def on_hci_disconnection_complete_event(self, event):
# Find the connection
@@ -435,7 +455,7 @@
logger.debug(f'### DISCONNECTION FAILED: {event.status}')
# Notify the listeners
- self.emit('disconnection_failure', event.status)
+ self.emit('disconnection_failure', event.connection_handle, event.status)
def on_hci_le_connection_update_complete_event(self, event):
if (connection := self.connections.get(event.connection_handle)) is None:
@@ -445,8 +465,8 @@
# Notify the client
if event.status == HCI_SUCCESS:
connection_parameters = ConnectionParameters(
- event.conn_interval,
- event.conn_latency,
+ event.connection_interval,
+ event.peripheral_latency,
event.supervision_timeout
)
self.emit('connection_parameters_update', connection.handle, connection_parameters)
@@ -467,13 +487,10 @@
def on_hci_le_advertising_report_event(self, event):
for report in event.reports:
- self.emit(
- 'advertising_report',
- report.address,
- report.data,
- report.rssi,
- report.event_type
- )
+ self.emit('advertising_report', report)
+
+ def on_hci_le_extended_advertising_report_event(self, event):
+ self.on_hci_le_advertising_report_event(event)
def on_hci_le_remote_connection_parameter_request_event(self, event):
if event.connection_handle not in self.connections:
@@ -489,8 +506,8 @@
interval_max = event.interval_max,
latency = event.latency,
timeout = event.timeout,
- minimum_ce_length = 0,
- maximum_ce_length = 0
+ min_ce_length = 0,
+ max_ce_length = 0
)
)
@@ -625,6 +642,9 @@
def on_hci_user_passkey_request_event(self, event):
self.emit('authentication_user_passkey_request', event.bd_addr)
+ def on_hci_user_passkey_notification_event(self, event):
+ self.emit('authentication_user_passkey_notification', event.bd_addr, event.passkey)
+
def on_hci_inquiry_complete_event(self, event):
self.emit('inquiry_complete')
@@ -652,3 +672,6 @@
self.emit('remote_name_failure', event.bd_addr, event.status)
else:
self.emit('remote_name', event.bd_addr, event.remote_name)
+
+ def on_hci_remote_host_supported_features_notification_event(self, event):
+ self.emit('remote_host_supported_features', event.bd_addr, event.host_supported_features)
diff --git a/bumble/l2cap.py b/bumble/l2cap.py
index e39dff1..c61a45f 100644
--- a/bumble/l2cap.py
+++ b/bumble/l2cap.py
@@ -19,6 +19,7 @@
import logging
import struct
+from collections import deque
from colors import color
from pyee import EventEmitter
@@ -43,13 +44,23 @@
L2CAP_DEFAULT_MTU = 2048 # Default value for the MTU we are willing to accept
+L2CAP_DEFAULT_CONNECTIONLESS_MTU = 1024
+
# See Bluetooth spec @ Vol 3, Part A - Table 2.1: CID name space on ACL-U, ASB-U, and AMP-U logical links
L2CAP_ACL_U_DYNAMIC_CID_RANGE_START = 0x0040
L2CAP_ACL_U_DYNAMIC_CID_RANGE_END = 0xFFFF
# See Bluetooth spec @ Vol 3, Part A - Table 2.2: CID name space on LE-U logical link
L2CAP_LE_U_DYNAMIC_CID_RANGE_START = 0x0040
-L2CAP_LE_U_DYNAMIC_CID_RANGE_START = 0x007F
+L2CAP_LE_U_DYNAMIC_CID_RANGE_END = 0x007F
+
+# PSM Range - See Bluetooth spec @ Vol 3, Part A / Table 4.5: PSM ranges and usage
+L2CAP_PSM_DYNAMIC_RANGE_START = 0x1001
+L2CAP_PSM_DYNAMIC_RANGE_END = 0xFFFF
+
+# LE PSM Ranges - See Bluetooth spec @ Vol 3, Part A / Table 4.19: LE Credit Based Connection Request LE_PSM ranges
+L2CAP_LE_PSM_DYNAMIC_RANGE_START = 0x0080
+L2CAP_LE_PSM_DYNAMIC_RANGE_END = 0x00FF
# Frame types
L2CAP_COMMAND_REJECT = 0x01
@@ -107,8 +118,13 @@
L2CAP_SIGNALING_MTU_EXCEEDED_REASON = 0x0001
L2CAP_INVALID_CID_IN_REQUEST_REASON = 0x0002
-L2CAP_LE_CREDIT_BASED_CONNECTION_DEFAULT_MTU = 2048
-L2CAP_LE_CREDIT_BASED_CONNECTION_DEFAULT_MPS = 2048
+L2CAP_LE_CREDIT_BASED_CONNECTION_MAX_CREDITS = 65535
+L2CAP_LE_CREDIT_BASED_CONNECTION_MIN_MTU = 23
+L2CAP_LE_CREDIT_BASED_CONNECTION_MIN_MPS = 23
+L2CAP_LE_CREDIT_BASED_CONNECTION_MAX_MPS = 65533
+L2CAP_LE_CREDIT_BASED_CONNECTION_DEFAULT_MTU = 2046
+L2CAP_LE_CREDIT_BASED_CONNECTION_DEFAULT_MPS = 2048
+L2CAP_LE_CREDIT_BASED_CONNECTION_DEFAULT_INITIAL_CREDITS = 256
L2CAP_MAXIMUM_TRANSMISSION_UNIT_CONFIGURATION_OPTION_TYPE = 0x01
@@ -172,7 +188,7 @@
self.identifier = pdu[1]
length = struct.unpack_from('<H', pdu, 2)[0]
if length + 4 != len(pdu):
- logger.warn(color(f'!!! length mismatch: expected {len(pdu) - 4} but got {length}', 'red'))
+ logger.warning(color(f'!!! length mismatch: expected {len(pdu) - 4} but got {length}', 'red'))
if hasattr(self, 'fields'):
self.init_from_bytes(pdu, 4)
return self
@@ -185,10 +201,10 @@
def decode_configuration_options(data):
options = []
while len(data) >= 2:
- type = data[0]
+ type = data[0]
length = data[1]
- value = data[2:2 + length]
- data = data[2 + length:]
+ value = data[2:2 + length]
+ data = data[2 + length:]
options.append((type, value))
return options
@@ -268,7 +284,10 @@
# -----------------------------------------------------------------------------
@L2CAP_Control_Frame.subclass([
- ('psm', 2),
+ ('psm', {
+ 'parser': lambda data, offset: L2CAP_Connection_Request.parse_psm(data, offset),
+ 'serializer': lambda value: L2CAP_Connection_Request.serialize_psm(value)
+ }),
('source_cid', 2)
])
class L2CAP_Connection_Request(L2CAP_Control_Frame):
@@ -276,6 +295,28 @@
See Bluetooth spec @ Vol 3, Part A - 4.2 CONNECTION REQUEST
'''
+ @staticmethod
+ def parse_psm(data, offset=0):
+ psm_length = 2
+ psm = data[offset] | data[offset + 1] << 8
+
+ # The PSM field extends until the first even octet (inclusive)
+ while data[offset + psm_length - 1] % 2 == 1:
+ psm |= data[offset + psm_length] << (8 * psm_length)
+ psm_length += 1
+
+ return offset + psm_length, psm
+
+ @staticmethod
+ def serialize_psm(psm):
+ serialized = struct.pack('<H', psm & 0xFFFF)
+ psm >>= 16
+ while psm:
+ serialized += bytes([psm & 0xFF])
+ psm >>= 8
+
+ return serialized
+
# -----------------------------------------------------------------------------
@L2CAP_Control_Frame.subclass([
@@ -289,16 +330,16 @@
See Bluetooth spec @ Vol 3, Part A - 4.3 CONNECTION RESPONSE
'''
- CONNECTION_SUCCESSFUL = 0x0000
- CONNECTION_PENDING = 0x0001
- CONNECTION_REFUSED_LE_PSM_NOT_SUPPORTED = 0x0002
- CONNECTION_REFUSED_SECURITY_BLOCK = 0x0003
- CONNECTION_REFUSED_NO_RESOURCES_AVAILABLE = 0x0004
- CONNECTION_REFUSED_INVALID_SOURCE_CID = 0x0006
- CONNECTION_REFUSED_SOURCE_CID_ALREADY_ALLOCATED = 0x0007
- CONNECTION_REFUSED_UNACCEPTABLE_PARAMETERS = 0x000B
+ CONNECTION_SUCCESSFUL = 0x0000
+ CONNECTION_PENDING = 0x0001
+ CONNECTION_REFUSED_LE_PSM_NOT_SUPPORTED = 0x0002
+ CONNECTION_REFUSED_SECURITY_BLOCK = 0x0003
+ CONNECTION_REFUSED_NO_RESOURCES_AVAILABLE = 0x0004
+ CONNECTION_REFUSED_INVALID_SOURCE_CID = 0x0006
+ CONNECTION_REFUSED_SOURCE_CID_ALREADY_ALLOCATED = 0x0007
+ CONNECTION_REFUSED_UNACCEPTABLE_PARAMETERS = 0x000B
- CONNECTION_RESULT_NAMES = {
+ RESULT_NAMES = {
CONNECTION_SUCCESSFUL: 'CONNECTION_SUCCESSFUL',
CONNECTION_PENDING: 'CONNECTION_PENDING',
CONNECTION_REFUSED_LE_PSM_NOT_SUPPORTED: 'CONNECTION_REFUSED_LE_PSM_NOT_SUPPORTED',
@@ -311,7 +352,7 @@
@staticmethod
def result_name(result):
- return name_or_number(L2CAP_Connection_Response.CONNECTION_RESULT_NAMES, result)
+ return name_or_number(L2CAP_Connection_Response.RESULT_NAMES, result)
# -----------------------------------------------------------------------------
@@ -462,10 +503,10 @@
# -----------------------------------------------------------------------------
@L2CAP_Control_Frame.subclass([
- ('interval_min', 2),
- ('interval_max', 2),
- ('slave_latency', 2),
- ('timeout_multiplier', 2)
+ ('interval_min', 2),
+ ('interval_max', 2),
+ ('latency', 2),
+ ('timeout', 2)
])
class L2CAP_Connection_Parameter_Update_Request(L2CAP_Control_Frame):
'''
@@ -485,10 +526,10 @@
# -----------------------------------------------------------------------------
@L2CAP_Control_Frame.subclass([
- ('le_psm', 2),
- ('source_cid', 2),
- ('mtu', 2),
- ('mps', 2),
+ ('le_psm', 2),
+ ('source_cid', 2),
+ ('mtu', 2),
+ ('mps', 2),
('initial_credits', 2)
])
class L2CAP_LE_Credit_Based_Connection_Request(L2CAP_Control_Frame):
@@ -521,7 +562,7 @@
CONNECTION_REFUSED_SOURCE_CID_ALREADY_ALLOCATED = 0x000A
CONNECTION_REFUSED_UNACCEPTABLE_PARAMETERS = 0x000B
- CONNECTION_RESULT_NAMES = {
+ RESULT_NAMES = {
CONNECTION_SUCCESSFUL: 'CONNECTION_SUCCESSFUL',
CONNECTION_REFUSED_LE_PSM_NOT_SUPPORTED: 'CONNECTION_REFUSED_LE_PSM_NOT_SUPPORTED',
CONNECTION_REFUSED_NO_RESOURCES_AVAILABLE: 'CONNECTION_REFUSED_NO_RESOURCES_AVAILABLE',
@@ -536,12 +577,12 @@
@staticmethod
def result_name(result):
- return name_or_number(L2CAP_LE_Credit_Based_Connection_Response.CONNECTION_RESULT_NAMES, result)
+ return name_or_number(L2CAP_LE_Credit_Based_Connection_Response.RESULT_NAMES, result)
# -----------------------------------------------------------------------------
@L2CAP_Control_Frame.subclass([
- ('cid', 2),
+ ('cid', 2),
('credits', 2)
])
class L2CAP_LE_Flow_Control_Credit(L2CAP_Control_Frame):
@@ -619,6 +660,9 @@
def send_pdu(self, pdu):
self.manager.send_pdu(self.connection, self.destination_cid, pdu)
+ def send_control_frame(self, frame):
+ self.manager.send_control_frame(self.connection, self.signaling_cid, frame)
+
async def send_request(self, request):
# Check that there isn't already a request pending
if self.response:
@@ -637,15 +681,16 @@
elif self.sink:
self.sink(pdu)
else:
- logger.warn(color('received pdu without a pending request or sink', 'red'))
-
- def send_control_frame(self, frame):
- self.manager.send_control_frame(self.connection, self.signaling_cid, frame)
+ logger.warning(color('received pdu without a pending request or sink', 'red'))
async def connect(self):
if self.state != Channel.CLOSED:
raise InvalidStateError('invalid state')
+ # Check that we can start a new connection
+ if self.connection_result:
+ raise RuntimeError('connection already pending')
+
self.change_state(Channel.WAIT_CONNECT_RSP)
self.send_control_frame(
L2CAP_Connection_Request(
@@ -657,7 +702,12 @@
# Create a future to wait for the state machine to get to a success or error state
self.connection_result = asyncio.get_running_loop().create_future()
- return await self.connection_result
+
+ # Wait for the connection to succeed or fail
+ try:
+ return await self.connection_result
+ finally:
+ self.connection_result = None
async def disconnect(self):
if self.state != Channel.OPEN:
@@ -708,7 +758,7 @@
def on_connection_response(self, response):
if self.state != Channel.WAIT_CONNECT_RSP:
- logger.warn(color('invalid state', 'red'))
+ logger.warning(color('invalid state', 'red'))
return
if response.result == L2CAP_Connection_Response.CONNECTION_SUCCESSFUL:
@@ -734,7 +784,7 @@
self.state != Channel.WAIT_CONFIG_REQ and
self.state != Channel.WAIT_CONFIG_REQ_RSP
):
- logger.warn(color('invalid state', 'red'))
+ logger.warning(color('invalid state', 'red'))
return
# Decode the options
@@ -750,7 +800,7 @@
source_cid = self.destination_cid,
flags = 0x0000,
result = L2CAP_Configure_Response.SUCCESS,
- options = request.options # TODO: don't accept everthing blindly
+ options = request.options # TODO: don't accept everything blindly
)
)
if self.state == Channel.WAIT_CONFIG:
@@ -777,7 +827,7 @@
self.connection_result = None
self.emit('open')
else:
- logger.warn(color('invalid state', 'red'))
+ logger.warning(color('invalid state', 'red'))
elif response.result == L2CAP_Configure_Response.FAILURE_UNACCEPTABLE_PARAMETERS:
# Re-configure with what's suggested in the response
self.send_control_frame(
@@ -789,7 +839,7 @@
)
)
else:
- logger.warn(color(f'!!! configuration rejected: {L2CAP_Configure_Response.result_name(response.result)}', 'red'))
+ logger.warning(color(f'!!! configuration rejected: {L2CAP_Configure_Response.result_name(response.result)}', 'red'))
# TODO: decide how to fail gracefully
def on_disconnection_request(self, request):
@@ -805,15 +855,15 @@
self.emit('close')
self.manager.on_channel_closed(self)
else:
- logger.warn(color('invalid state', 'red'))
+ logger.warning(color('invalid state', 'red'))
def on_disconnection_response(self, response):
if self.state != Channel.WAIT_DISCONNECT:
- logger.warn(color('invalid state', 'red'))
+ logger.warning(color('invalid state', 'red'))
return
if response.destination_cid != self.destination_cid or response.source_cid != self.source_cid:
- logger.warn('unexpected source or destination CID')
+ logger.warning('unexpected source or destination CID')
return
self.change_state(Channel.CLOSED)
@@ -828,22 +878,362 @@
# -----------------------------------------------------------------------------
+class LeConnectionOrientedChannel(EventEmitter):
+ """
+ LE Credit-based Connection Oriented Channel
+ """
+
+ INIT = 0
+ CONNECTED = 1
+ CONNECTING = 2
+ DISCONNECTING = 3
+ DISCONNECTED = 4
+ CONNECTION_ERROR = 5
+
+ STATE_NAMES = {
+ INIT: 'INIT',
+ CONNECTED: 'CONNECTED',
+ CONNECTING: 'CONNECTING',
+ DISCONNECTING: 'DISCONNECTING',
+ DISCONNECTED: 'DISCONNECTED',
+ CONNECTION_ERROR: 'CONNECTION_ERROR'
+ }
+
+ @staticmethod
+ def state_name(state):
+ return name_or_number(LeConnectionOrientedChannel.STATE_NAMES, state)
+
+ def __init__(
+ self,
+ manager,
+ connection,
+ le_psm,
+ source_cid,
+ destination_cid,
+ mtu,
+ mps,
+ credits,
+ peer_mtu,
+ peer_mps,
+ peer_credits,
+ connected
+ ):
+ super().__init__()
+ self.manager = manager
+ self.connection = connection
+ self.le_psm = le_psm
+ self.source_cid = source_cid
+ self.destination_cid = destination_cid
+ self.mtu = mtu
+ self.mps = mps
+ self.credits = credits
+ self.peer_mtu = peer_mtu
+ self.peer_mps = peer_mps
+ self.peer_credits = peer_credits
+ self.peer_max_credits = self.peer_credits
+ self.peer_credits_threshold = self.peer_max_credits // 2
+ self.in_sdu = None
+ self.in_sdu_length = 0
+ self.out_queue = deque()
+ self.out_sdu = None
+ self.sink = None
+ self.connection_result = None
+ self.disconnection_result = None
+ self.drained = asyncio.Event()
+
+ self.drained.set()
+
+ if connected:
+ self.state = LeConnectionOrientedChannel.CONNECTED
+ else:
+ self.state = LeConnectionOrientedChannel.INIT
+
+ def change_state(self, new_state):
+ logger.debug(f'{self} state change -> {color(self.state_name(new_state), "cyan")}')
+ self.state = new_state
+
+ if new_state == self.CONNECTED:
+ self.emit('open')
+ elif new_state == self.DISCONNECTED:
+ self.emit('close')
+
+ def send_pdu(self, pdu):
+ self.manager.send_pdu(self.connection, self.destination_cid, pdu)
+
+ def send_control_frame(self, frame):
+ self.manager.send_control_frame(self.connection, L2CAP_LE_SIGNALING_CID, frame)
+
+ async def connect(self):
+ # Check that we're in the right state
+ if self.state != self.INIT:
+ raise InvalidStateError('not in a connectable state')
+
+ # Check that we can start a new connection
+ identifier = self.manager.next_identifier(self.connection)
+ if identifier in self.manager.le_coc_requests:
+ raise RuntimeError('too many concurrent connection requests')
+
+ self.change_state(self.CONNECTING)
+ request = L2CAP_LE_Credit_Based_Connection_Request(
+ identifier = identifier,
+ le_psm = self.le_psm,
+ source_cid = self.source_cid,
+ mtu = self.mtu,
+ mps = self.mps,
+ initial_credits = self.peer_credits
+ )
+ self.manager.le_coc_requests[identifier] = request
+ self.send_control_frame(request)
+
+ # Create a future to wait for the response
+ self.connection_result = asyncio.get_running_loop().create_future()
+
+ # Wait for the connection to succeed or fail
+ return await self.connection_result
+
+ async def disconnect(self):
+ # Check that we're connected
+ if self.state != self.CONNECTED:
+ raise InvalidStateError('not connected')
+
+ self.change_state(self.DISCONNECTING)
+ self.flush_output()
+ self.send_control_frame(
+ L2CAP_Disconnection_Request(
+ identifier = self.manager.next_identifier(self.connection),
+ destination_cid = self.destination_cid,
+ source_cid = self.source_cid
+ )
+ )
+
+ # Create a future to wait for the state machine to get to a success or error state
+ self.disconnection_result = asyncio.get_running_loop().create_future()
+ return await self.disconnection_result
+
+ def on_pdu(self, pdu):
+ if self.sink is None:
+ logger.warning('received pdu without a sink')
+ return
+
+ if self.state != self.CONNECTED:
+ logger.warning('received PDU while not connected, dropping')
+
+ # Manage the peer credits
+ if self.peer_credits == 0:
+ logger.warning('received LE frame when peer out of credits')
+ else:
+ self.peer_credits -= 1
+ if self.peer_credits <= self.peer_credits_threshold:
+ # The credits fell below the threshold, replenish them to the max
+ self.send_control_frame(
+ L2CAP_LE_Flow_Control_Credit(
+ identifier = self.manager.next_identifier(self.connection),
+ cid = self.source_cid,
+ credits = self.peer_max_credits - self.peer_credits
+ )
+ )
+ self.peer_credits = self.peer_max_credits
+
+ # Check if this starts a new SDU
+ if self.in_sdu is None:
+ # Start a new SDU
+ self.in_sdu = pdu
+ else:
+ # Continue an SDU
+ self.in_sdu += pdu
+
+ # Check if the SDU is complete
+ if self.in_sdu_length == 0:
+ # We don't know the size yet, check if we have received the header to compute it
+ if len(self.in_sdu) >= 2:
+ self.in_sdu_length = struct.unpack_from('<H', self.in_sdu, 0)[0]
+ if self.in_sdu_length == 0:
+ # We'll compute it later
+ return
+ if len(self.in_sdu) < 2 + self.in_sdu_length:
+ # Not complete yet
+ logger.debug(f'SDU: {len(self.in_sdu) - 2} of {self.in_sdu_length} bytes received')
+ return
+ if len(self.in_sdu) != 2 + self.in_sdu_length:
+ # Overflow
+ logger.warning(f'SDU overflow: sdu_length={self.in_sdu_length}, received {len(self.in_sdu) - 2}')
+ # TODO: we should disconnect
+ self.in_sdu = None
+ self.in_sdu_length = 0
+ return
+
+ # Send the SDU to the sink
+ logger.debug(f'SDU complete: 2+{len(self.in_sdu) - 2} bytes')
+ self.sink(self.in_sdu[2:])
+
+ # Prepare for a new SDU
+ self.in_sdu = None
+ self.in_sdu_length = 0
+
+ def on_connection_response(self, response):
+ # Look for a matching pending response result
+ if self.connection_result is None:
+ logger.warning(f'received unexpected connection response (id={response.identifier})')
+ return
+
+ if response.result == L2CAP_LE_Credit_Based_Connection_Response.CONNECTION_SUCCESSFUL:
+ self.destination_cid = response.destination_cid
+ self.peer_mtu = response.mtu
+ self.peer_mps = response.mps
+ self.credits = response.initial_credits
+ self.connected = True
+ self.connection_result.set_result(self)
+ self.change_state(self.CONNECTED)
+ else:
+ self.connection_result.set_exception(
+ ProtocolError(
+ response.result,
+ 'l2cap',
+ L2CAP_LE_Credit_Based_Connection_Response.result_name(response.result))
+ )
+ self.change_state(self.CONNECTION_ERROR)
+
+ # Cleanup
+ self.connection_result = None
+
+ def on_credits(self, credits):
+ self.credits += credits
+ logger.debug(f'received {credits} credits, total = {self.credits}')
+
+ # Try to send more data if we have any queued up
+ self.process_output()
+
+ def on_disconnection_request(self, request):
+ self.send_control_frame(
+ L2CAP_Disconnection_Response(
+ identifier = request.identifier,
+ destination_cid = request.destination_cid,
+ source_cid = request.source_cid
+ )
+ )
+ self.change_state(self.DISCONNECTED)
+ self.flush_output()
+
+ def on_disconnection_response(self, response):
+ if self.state != self.DISCONNECTING:
+ logger.warning(color('invalid state', 'red'))
+ return
+
+ if response.destination_cid != self.destination_cid or response.source_cid != self.source_cid:
+ logger.warning('unexpected source or destination CID')
+ return
+
+ self.change_state(self.DISCONNECTED)
+ if self.disconnection_result:
+ self.disconnection_result.set_result(None)
+ self.disconnection_result = None
+
+ def flush_output(self):
+ self.out_queue.clear()
+ self.out_sdu = None
+
+ def process_output(self):
+ while self.credits > 0:
+ if self.out_sdu is not None:
+ # Finish the current SDU
+ packet = self.out_sdu[:self.peer_mps]
+ self.send_pdu(packet)
+ self.credits -= 1
+ logger.debug(f'sent {len(packet)} bytes, {self.credits} credits left')
+ if len(packet) == len(self.out_sdu):
+ # We sent everything
+ self.out_sdu = None
+ else:
+ # Keep what's still left to send
+ self.out_sdu = self.out_sdu[len(packet):]
+ continue
+ elif self.out_queue:
+ # Create the next SDU (2 bytes header plus up to MTU bytes payload)
+ logger.debug(f'assembling SDU from {len(self.out_queue)} packets in output queue')
+ payload = b''
+ while self.out_queue and len(payload) < self.peer_mtu:
+ # We can add more data to the payload
+ chunk = self.out_queue[0][:self.peer_mtu - len(payload)]
+ payload += chunk
+ self.out_queue[0] = self.out_queue[0][len(chunk):]
+ if len(self.out_queue[0]) == 0:
+ # We consumed the entire buffer, remove it
+ self.out_queue.popleft()
+ logger.debug(f'packet completed, {len(self.out_queue)} left in queue')
+
+ # Construct the SDU with its header
+ assert len(payload) != 0
+ logger.debug(f'SDU complete: {len(payload)} payload bytes')
+ self.out_sdu = struct.pack('<H', len(payload)) + payload
+ else:
+ # Nothing left to send for now
+ self.drained.set()
+ return
+
+ def write(self, data):
+ if self.state != self.CONNECTED:
+ logger.warning('not connected, dropping data')
+ return
+
+ # Queue the data
+ self.out_queue.append(data)
+ self.drained.clear()
+ logger.debug(f'{len(data)} bytes packet queued, {len(self.out_queue)} packets in queue')
+
+ # Send what we can
+ self.process_output()
+
+ async def drain(self):
+ await self.drained.wait()
+
+ def pause_reading(self):
+ # TODO: not implemented yet
+ pass
+
+ def resume_reading(self):
+ # TODO: not implemented yet
+ pass
+
+ def __str__(self):
+ return f'CoC({self.source_cid}->{self.destination_cid}, State={self.state_name(self.state)}, PSM={self.le_psm}, MTU={self.mtu}/{self.peer_mtu}, MPS={self.mps}/{self.peer_mps}, credits={self.credits}/{self.peer_credits})'
+
+
+# -----------------------------------------------------------------------------
class ChannelManager:
- def __init__(self, extended_features=None, connectionless_mtu=1024):
- self.host = None
- self.channels = {} # Channels, mapped by connection and cid
- # Fixed channel handlers, mapped by cid
- self.fixed_channels = {
- L2CAP_SIGNALING_CID: None, L2CAP_LE_SIGNALING_CID: None}
+ def __init__(self, extended_features=[], connectionless_mtu=L2CAP_DEFAULT_CONNECTIONLESS_MTU):
+ self._host = None
self.identifiers = {} # Incrementing identifier values by connection
+ self.channels = {} # All channels, mapped by connection and source cid
+ self.fixed_channels = { # Fixed channel handlers, mapped by cid
+ L2CAP_SIGNALING_CID: None, L2CAP_LE_SIGNALING_CID: None
+ }
self.servers = {} # Servers accepting connections, by PSM
- self.extended_features = [] if extended_features is None else extended_features
+ self.le_coc_channels = {} # LE CoC channels, mapped by connection and destination cid
+ self.le_coc_servers = {} # LE CoC - Servers accepting connections, by PSM
+ self.le_coc_requests = {} # LE CoC connection requests, by identifier
+ self.extended_features = extended_features
self.connectionless_mtu = connectionless_mtu
+ @property
+ def host(self):
+ return self._host
+
+ @host.setter
+ def host(self, host):
+ if self._host is not None:
+ self._host.remove_listener('disconnection', self.on_disconnection)
+ self._host = host
+ if host is not None:
+ host.on('disconnection', self.on_disconnection)
+
def find_channel(self, connection_handle, cid):
if connection_channels := self.channels.get(connection_handle):
return connection_channels.get(cid)
+ def find_le_coc_channel(self, connection_handle, cid):
+ if connection_channels := self.le_coc_channels.get(connection_handle):
+ return connection_channels.get(cid)
+
@staticmethod
def find_free_br_edr_cid(channels):
# Pick the smallest valid CID that's not already in the list
@@ -853,21 +1243,108 @@
if cid not in channels:
return cid
+ @staticmethod
+ def find_free_le_cid(channels):
+ # Pick the smallest valid CID that's not already in the list
+ # (not necessarily the most efficient algorithm, but the list of CID is
+ # very small in practice)
+ for cid in range(L2CAP_LE_U_DYNAMIC_CID_RANGE_START, L2CAP_LE_U_DYNAMIC_CID_RANGE_END + 1):
+ if cid not in channels:
+ return cid
+
+ @staticmethod
+ def check_le_coc_parameters(max_credits, mtu, mps):
+ if max_credits < 1 or max_credits > L2CAP_LE_CREDIT_BASED_CONNECTION_MAX_CREDITS:
+ raise ValueError('max credits out of range')
+ if mtu < L2CAP_LE_CREDIT_BASED_CONNECTION_MIN_MTU:
+ raise ValueError('MTU too small')
+ if mps < L2CAP_LE_CREDIT_BASED_CONNECTION_MIN_MPS or mps > L2CAP_LE_CREDIT_BASED_CONNECTION_MAX_MPS:
+ raise ValueError('MPS out of range')
+
def next_identifier(self, connection):
identifier = (self.identifiers.setdefault(connection.handle, 0) + 1) % 256
self.identifiers[connection.handle] = identifier
return identifier
-
+
def register_fixed_channel(self, cid, handler):
self.fixed_channels[cid] = handler
-
+
def deregister_fixed_channel(self, cid):
if cid in self.fixed_channels:
del self.fixed_channels[cid]
def register_server(self, psm, server):
+ if psm == 0:
+ # Find a free PSM
+ for candidate in range(L2CAP_PSM_DYNAMIC_RANGE_START, L2CAP_PSM_DYNAMIC_RANGE_END + 1, 2):
+ if (candidate >> 8) % 2 == 1:
+ continue
+ if candidate in self.servers:
+ continue
+ psm = candidate
+ break
+ else:
+ raise InvalidStateError('no free PSM')
+ else:
+ # Check that the PSM isn't already in use
+ if psm in self.servers:
+ raise ValueError('PSM already in use')
+
+ # Check that the PSM is valid
+ if psm % 2 == 0:
+ raise ValueError('invalid PSM (not odd)')
+ check = psm >> 8
+ while check:
+ if check % 2 != 0:
+ raise ValueError('invalid PSM')
+ check >>= 8
+
self.servers[psm] = server
+ return psm
+
+ def register_le_coc_server(
+ self,
+ psm,
+ server,
+ max_credits=L2CAP_LE_CREDIT_BASED_CONNECTION_DEFAULT_INITIAL_CREDITS,
+ mtu=L2CAP_LE_CREDIT_BASED_CONNECTION_DEFAULT_MTU,
+ mps=L2CAP_LE_CREDIT_BASED_CONNECTION_DEFAULT_MPS
+ ):
+ self.check_le_coc_parameters(max_credits, mtu, mps)
+
+ if psm == 0:
+ # Find a free PSM
+ for candidate in range(L2CAP_LE_PSM_DYNAMIC_RANGE_START, L2CAP_LE_PSM_DYNAMIC_RANGE_END + 1):
+ if candidate in self.le_coc_servers:
+ continue
+ psm = candidate
+ break
+ else:
+ raise InvalidStateError('no free PSM')
+ else:
+ # Check that the PSM isn't already in use
+ if psm in self.le_coc_servers:
+ raise ValueError('PSM already in use')
+
+ self.le_coc_servers[psm] = (
+ server,
+ max_credits or L2CAP_LE_CREDIT_BASED_CONNECTION_DEFAULT_INITIAL_CREDITS,
+ mtu or L2CAP_LE_CREDIT_BASED_CONNECTION_DEFAULT_MTU,
+ mps or L2CAP_LE_CREDIT_BASED_CONNECTION_DEFAULT_MPS
+ )
+
+ return psm
+
+ def on_disconnection(self, connection_handle, reason):
+ logger.debug(f'disconnection from {connection_handle}, cleaning up channels')
+ if connection_handle in self.channels:
+ del self.channels[connection_handle]
+ if connection_handle in self.le_coc_channels:
+ del self.le_coc_channels[connection_handle]
+ if connection_handle in self.identifiers:
+ del self.identifiers[connection_handle]
+
def send_pdu(self, connection, cid, pdu):
pdu_str = pdu.hex() if type(pdu) is bytes else str(pdu)
logger.debug(f'{color(">>> Sending L2CAP PDU", "blue")} on connection [0x{connection.handle:04X}] (CID={cid}) {connection.peer_address}: {pdu_str}')
@@ -883,7 +1360,7 @@
self.fixed_channels[cid](connection.handle, pdu)
else:
if (channel := self.find_channel(connection.handle, cid)) is None:
- logger.warn(color(f'channel not found for 0x{connection.handle:04X}:{cid}', 'red'))
+ logger.warning(color(f'channel not found for 0x{connection.handle:04X}:{cid}', 'red'))
return
channel.on_pdu(pdu)
@@ -927,7 +1404,6 @@
def on_l2cap_command_reject(self, connection, cid, packet):
logger.warning(f'{color("!!! Command rejected:", "red")} {packet.reason}')
- pass
def on_l2cap_connection_request(self, connection, cid, request):
# Check if there's a server for this PSM
@@ -959,7 +1435,7 @@
server(channel)
channel.on_connection_request(request)
else:
- logger.warn(f'No server for connection 0x{connection.handle:04X} on PSM {request.psm}')
+ logger.warning(f'No server for connection 0x{connection.handle:04X} on PSM {request.psm}')
self.send_control_frame(
connection,
cid,
@@ -974,35 +1450,35 @@
def on_l2cap_connection_response(self, connection, cid, response):
if (channel := self.find_channel(connection.handle, response.source_cid)) is None:
- logger.warn(color(f'channel {response.source_cid} not found for 0x{connection.handle:04X}:{cid}', 'red'))
+ logger.warning(color(f'channel {response.source_cid} not found for 0x{connection.handle:04X}:{cid}', 'red'))
return
channel.on_connection_response(response)
def on_l2cap_configure_request(self, connection, cid, request):
if (channel := self.find_channel(connection.handle, request.destination_cid)) is None:
- logger.warn(color(f'channel {request.destination_cid} not found for 0x{connection.handle:04X}:{cid}', 'red'))
+ logger.warning(color(f'channel {request.destination_cid} not found for 0x{connection.handle:04X}:{cid}', 'red'))
return
channel.on_configure_request(request)
def on_l2cap_configure_response(self, connection, cid, response):
if (channel := self.find_channel(connection.handle, response.source_cid)) is None:
- logger.warn(color(f'channel {response.source_cid} not found for 0x{connection.handle:04X}:{cid}', 'red'))
+ logger.warning(color(f'channel {response.source_cid} not found for 0x{connection.handle:04X}:{cid}', 'red'))
return
channel.on_configure_response(response)
def on_l2cap_disconnection_request(self, connection, cid, request):
if (channel := self.find_channel(connection.handle, request.destination_cid)) is None:
- logger.warn(color(f'channel {request.destination_cid} not found for 0x{connection.handle:04X}:{cid}', 'red'))
+ logger.warning(color(f'channel {request.destination_cid} not found for 0x{connection.handle:04X}:{cid}', 'red'))
return
channel.on_disconnection_request(request)
def on_l2cap_disconnection_response(self, connection, cid, response):
if (channel := self.find_channel(connection.handle, response.source_cid)) is None:
- logger.warn(color(f'channel {response.source_cid} not found for 0x{connection.handle:04X}:{cid}', 'red'))
+ logger.warning(color(f'channel {response.source_cid} not found for 0x{connection.handle:04X}:{cid}', 'red'))
return
channel.on_disconnection_response(response)
@@ -1057,13 +1533,13 @@
)
)
self.host.send_command_sync(HCI_LE_Connection_Update_Command(
- connection_handle = connection.handle,
- conn_interval_min = request.interval_min,
- conn_interval_max = request.interval_max,
- conn_latency = request.slave_latency,
- supervision_timeout = request.timeout_multiplier,
- minimum_ce_length = 0,
- maximum_ce_length = 0
+ connection_handle = connection.handle,
+ connection_interval_min = request.interval_min,
+ connection_interval_max = request.interval_max,
+ max_latency = request.latency,
+ supervision_timeout = request.timeout,
+ min_ce_length = 0,
+ max_ce_length = 0
))
else:
self.send_control_frame(
@@ -1076,25 +1552,123 @@
)
def on_l2cap_connection_parameter_update_response(self, connection, cid, response):
+ # TODO: check response
pass
def on_l2cap_le_credit_based_connection_request(self, connection, cid, request):
- # FIXME: temp fixed values
- self.send_control_frame(
- connection,
- cid,
- L2CAP_LE_Credit_Based_Connection_Response(
- identifier = request.identifier,
- destination_cid = 194, # FIXME: for testing only
- mtu = L2CAP_LE_CREDIT_BASED_CONNECTION_DEFAULT_MTU,
- mps = L2CAP_LE_CREDIT_BASED_CONNECTION_DEFAULT_MPS,
- initial_credits = 3, # FIXME: for testing only
- result = L2CAP_LE_Credit_Based_Connection_Response.CONNECTION_SUCCESSFUL
- )
- )
+ if request.le_psm in self.le_coc_servers:
+ (server, max_credits, mtu, mps) = self.le_coc_servers[request.le_psm]
- def on_l2cap_le_flow_control_credit(self, connection, cid, packet):
- pass
+ # Check that the CID isn't already used
+ le_connection_channels = self.le_coc_channels.setdefault(connection.handle, {})
+ if request.source_cid in le_connection_channels:
+ logger.warning(f'source CID {request.source_cid} already in use')
+ self.send_control_frame(
+ connection,
+ cid,
+ L2CAP_LE_Credit_Based_Connection_Response(
+ identifier = request.identifier,
+ destination_cid = 0,
+ mtu = mtu,
+ mps = mps,
+ initial_credits = 0,
+ result = L2CAP_LE_Credit_Based_Connection_Response.CONNECTION_REFUSED_SOURCE_CID_ALREADY_ALLOCATED
+ )
+ )
+ return
+
+ # Find a free CID for this new channel
+ connection_channels = self.channels.setdefault(connection.handle, {})
+ source_cid = self.find_free_le_cid(connection_channels)
+ if source_cid is None: # Should never happen!
+ self.send_control_frame(
+ connection,
+ cid,
+ L2CAP_LE_Credit_Based_Connection_Response(
+ identifier = request.identifier,
+ destination_cid = 0,
+ mtu = mtu,
+ mps = mps,
+ initial_credits = 0,
+ result = L2CAP_LE_Credit_Based_Connection_Response.CONNECTION_REFUSED_NO_RESOURCES_AVAILABLE,
+ )
+ )
+ return
+
+ # Create a new channel
+ logger.debug(f'creating LE CoC server channel with cid={source_cid} for psm {request.le_psm}')
+ channel = LeConnectionOrientedChannel(
+ self,
+ connection,
+ request.le_psm,
+ source_cid,
+ request.source_cid,
+ mtu,
+ mps,
+ request.initial_credits,
+ request.mtu,
+ request.mps,
+ max_credits,
+ True
+ )
+ connection_channels[source_cid] = channel
+ le_connection_channels[request.source_cid] = channel
+
+ # Respond
+ self.send_control_frame(
+ connection,
+ cid,
+ L2CAP_LE_Credit_Based_Connection_Response(
+ identifier = request.identifier,
+ destination_cid = source_cid,
+ mtu = mtu,
+ mps = mps,
+ initial_credits = max_credits,
+ result = L2CAP_LE_Credit_Based_Connection_Response.CONNECTION_SUCCESSFUL
+ )
+ )
+
+ # Notify
+ server(channel)
+ else:
+ logger.info(f'No LE server for connection 0x{connection.handle:04X} on PSM {request.le_psm}')
+ self.send_control_frame(
+ connection,
+ cid,
+ L2CAP_LE_Credit_Based_Connection_Response(
+ identifier = request.identifier,
+ destination_cid = 0,
+ mtu = L2CAP_LE_CREDIT_BASED_CONNECTION_DEFAULT_MTU,
+ mps = L2CAP_LE_CREDIT_BASED_CONNECTION_DEFAULT_MPS,
+ initial_credits = 0,
+ result = L2CAP_LE_Credit_Based_Connection_Response.CONNECTION_REFUSED_LE_PSM_NOT_SUPPORTED,
+ )
+ )
+
+ def on_l2cap_le_credit_based_connection_response(self, connection, cid, response):
+ # Find the pending request by identifier
+ request = self.le_coc_requests.get(response.identifier)
+ if request is None:
+ logger.warning(color('!!! received response for unknown request', 'red'))
+ return
+ del self.le_coc_requests[response.identifier]
+
+ # Find the channel for this request
+ channel = self.find_channel(connection.handle, request.source_cid)
+ if channel is None:
+ logger.warning(color(f'received connection response for an unknown channel (cid={request.source_cid})', 'red'))
+ return
+
+ # Process the response
+ channel.on_connection_response(response)
+
+ def on_l2cap_le_flow_control_credit(self, connection, cid, credit):
+ channel = self.find_le_coc_channel(connection.handle, credit.cid)
+ if channel is None:
+ logger.warning(f'received credits for an unknown channel (cid={credit.cid}')
+ return
+
+ channel.on_credits(credit.credits)
def on_channel_closed(self, channel):
connection_channels = self.channels.get(channel.connection.handle)
@@ -1102,22 +1676,65 @@
if channel.source_cid in connection_channels:
del connection_channels[channel.source_cid]
- async def connect(self, connection, psm):
- # NOTE: this implementation hard-codes BR/EDR more
- # TODO: LE mode (maybe?)
+ async def open_le_coc(self, connection, psm, max_credits, mtu, mps):
+ self.check_le_coc_parameters(max_credits, mtu, mps)
- # Find a free CID for a new channel
+ # Find a free CID for the new channel
connection_channels = self.channels.setdefault(connection.handle, {})
- cid = self.find_free_br_edr_cid(connection_channels)
- if cid is None: # Should never happen!
+ source_cid = self.find_free_le_cid(connection_channels)
+ if source_cid is None: # Should never happen!
raise RuntimeError('all CIDs already in use')
# Create the channel
- logger.debug(f'creating client channel with cid={cid} for psm {psm}')
- channel = Channel(self, connection, L2CAP_SIGNALING_CID, psm, cid, L2CAP_MIN_BR_EDR_MTU)
- connection_channels[cid] = channel
+ logger.debug(f'creating coc channel with cid={source_cid} for psm {psm}')
+ channel = LeConnectionOrientedChannel(
+ manager = self,
+ connection = connection,
+ le_psm = psm,
+ source_cid = source_cid,
+ destination_cid = 0,
+ mtu = mtu,
+ mps = mps,
+ credits = 0,
+ peer_mtu = 0,
+ peer_mps = 0,
+ peer_credits = max_credits,
+ connected = False
+ )
+ connection_channels[source_cid] = channel
# Connect
- await channel.connect()
+ try:
+ await channel.connect()
+ except Exception as error:
+ logger.warning(f'connection failed: {error}')
+ del connection_channels[source_cid]
+ raise
+
+ # Remember the channel by source CID and destination CID
+ le_connection_channels = self.le_coc_channels.setdefault(connection.handle, {})
+ le_connection_channels[channel.destination_cid] = channel
+
+ return channel
+
+ async def connect(self, connection, psm):
+ # NOTE: this implementation hard-codes BR/EDR
+
+ # Find a free CID for a new channel
+ connection_channels = self.channels.setdefault(connection.handle, {})
+ source_cid = self.find_free_br_edr_cid(connection_channels)
+ if source_cid is None: # Should never happen!
+ raise RuntimeError('all CIDs already in use')
+
+ # Create the channel
+ logger.debug(f'creating client channel with cid={source_cid} for psm {psm}')
+ channel = Channel(self, connection, L2CAP_SIGNALING_CID, psm, source_cid, L2CAP_MIN_BR_EDR_MTU)
+ connection_channels[source_cid] = channel
+
+ # Connect
+ try:
+ await channel.connect()
+ except Exception:
+ del connection_channels[source_cid]
return channel
diff --git a/bumble/rfcomm.py b/bumble/rfcomm.py
index be4d406..a87b8d8 100644
--- a/bumble/rfcomm.py
+++ b/bumble/rfcomm.py
@@ -21,7 +21,7 @@
from colors import color
from pyee import EventEmitter
-from .core import InvalidStateError, ProtocolError, ConnectionError
+from .core import BT_BR_EDR_TRANSPORT, InvalidStateError, ProtocolError, ConnectionError
# -----------------------------------------------------------------------------
# Logging
@@ -634,7 +634,12 @@
if self.state == Multiplexer.OPENING:
self.change_state(Multiplexer.CONNECTED)
if self.open_result:
- self.open_result.set_exception(ConnectionError(ConnectionError.CONNECTION_REFUSED))
+ self.open_result.set_exception(ConnectionError(
+ ConnectionError.CONNECTION_REFUSED,
+ BT_BR_EDR_TRANSPORT,
+ self.l2cap_channel.connection.peer_address,
+ 'rfcomm'
+ ))
else:
logger.warn(f'unexpected state for DM: {self}')
diff --git a/bumble/smp.py b/bumble/smp.py
index c544a82..8f0ea0b 100644
--- a/bumble/smp.py
+++ b/bumble/smp.py
@@ -477,6 +477,9 @@
async def accept(self):
return True
+ async def confirm(self):
+ return True
+
async def compare_numbers(self, number, digits=6):
return True
@@ -637,15 +640,16 @@
self.oob = False
# Set up addresses
+ self_address = connection.self_address
peer_address = connection.peer_resolvable_address or connection.peer_address
if self.is_initiator:
- self.ia = bytes(manager.address)
- self.iat = 1 if manager.address.is_random else 0
+ self.ia = bytes(self_address)
+ self.iat = 1 if self_address.is_random else 0
self.ra = bytes(peer_address)
self.rat = 1 if peer_address.is_random else 0
else:
- self.ra = bytes(manager.address)
- self.rat = 1 if manager.address.is_random else 0
+ self.ra = bytes(self_address)
+ self.rat = 1 if self_address.is_random else 0
self.ia = bytes(peer_address)
self.iat = 1 if peer_address.is_random else 0
@@ -715,6 +719,21 @@
return False
return True
+ def prompt_user_for_confirmation(self, next_steps):
+ async def prompt():
+ logger.debug('ask for confirmation')
+ try:
+ response = await self.pairing_config.delegate.confirm()
+ if response:
+ next_steps()
+ return
+ except Exception as error:
+ logger.warn(f'exception while confirm: {error}')
+
+ self.send_pairing_failed(SMP_CONFIRM_VALUE_FAILED_ERROR)
+
+ asyncio.create_task(prompt())
+
def prompt_user_for_numeric_comparison(self, code, next_steps):
async def prompt():
logger.debug(f'verification code: {code}')
@@ -907,8 +926,8 @@
SMP_Identity_Information_Command(identity_resolving_key=self.manager.device.irk)
)
self.send_command(SMP_Identity_Address_Information_Command(
- addr_type = self.manager.address.address_type,
- bd_addr = self.manager.address
+ addr_type = self.connection.self_address.address_type,
+ bd_addr = self.connection.self_address
))
# Distribute CSRK
@@ -939,8 +958,8 @@
SMP_Identity_Information_Command(identity_resolving_key=self.manager.device.irk)
)
self.send_command(SMP_Identity_Address_Information_Command(
- addr_type = self.manager.address.address_type,
- bd_addr = self.manager.address
+ addr_type = self.connection.self_address.address_type,
+ bd_addr = self.connection.self_address
))
# Distribute CSRK
@@ -1091,7 +1110,7 @@
self.manager.on_pairing(self, peer_address, keys)
def on_pairing_failure(self, reason):
- logger.warn(f'pairing failure ({error_name(reason)})')
+ logger.warning(f'pairing failure ({error_name(reason)})')
if self.completed:
return
@@ -1387,12 +1406,12 @@
# Compute the 6-digit code
code = crypto.g2(self.pka, self.pkb, self.na, self.nb) % 1000000
- if self.pairing_method == self.NUMERIC_COMPARISON:
- # Ask for user confirmation
- self.wait_before_continuing = asyncio.get_running_loop().create_future()
- self.prompt_user_for_numeric_comparison(code, next_steps)
+ # Ask for user confirmation
+ self.wait_before_continuing = asyncio.get_running_loop().create_future()
+ if self.pairing_method == self.JUST_WORKS:
+ self.prompt_user_for_confirmation(next_steps)
else:
- next_steps()
+ self.prompt_user_for_numeric_comparison(code, next_steps)
else:
next_steps()
@@ -1486,10 +1505,9 @@
Implements the Initiator and Responder roles of the Security Manager Protocol
'''
- def __init__(self, device, address):
+ def __init__(self, device):
super().__init__()
self.device = device
- self.address = address
self.sessions = {}
self._ecc_key = None
self.pairing_config_factory = lambda connection: PairingConfig()
diff --git a/bumble/transport/common.py b/bumble/transport/common.py
index d5c1ae9..0f5d27f 100644
--- a/bumble/transport/common.py
+++ b/bumble/transport/common.py
@@ -274,7 +274,7 @@
self.terminated.set_result(error)
break
- self.pump_task = asyncio.get_running_loop().create_task(pump_packets())
+ self.pump_task = asyncio.create_task(pump_packets())
def close(self):
if self.pump_task:
@@ -304,7 +304,7 @@
logger.warn(f'exception while sending packet: {error}')
break
- self.pump_task = asyncio.get_running_loop().create_task(pump_packets())
+ self.pump_task = asyncio.create_task(pump_packets())
def close(self):
if self.pump_task:
diff --git a/bumble/transport/usb.py b/bumble/transport/usb.py
index 80a8871..1133a5e 100644
--- a/bumble/transport/usb.py
+++ b/bumble/transport/usb.py
@@ -36,7 +36,7 @@
async def open_usb_transport(spec):
'''
Open a USB transport.
- The parameter string has this syntax:
+ The moniker string has this syntax:
either <index> or
<vendor>:<product> or
<vendor>:<product>/<serial-number>] or
@@ -47,15 +47,21 @@
/<serial-number> suffix or #<index> suffix max be specified when more than one device with
the same vendor and product identifiers are present.
+ In addition, if the moniker ends with the symbol "!", the device will be used in "forced" mode:
+ the first USB interface of the device will be used, regardless of the interface class/subclass.
+ This may be useful for some devices that use a custom class/subclass but may nonetheless work as-is.
+
Examples:
0 --> the first BT USB dongle
04b4:f901 --> the BT USB dongle with vendor=04b4 and product=f901
04b4:f901#2 --> the third USB device with vendor=04b4 and product=f901
04b4:f901/00E04C239987 --> the BT USB dongle with vendor=04b4 and product=f901 and serial number 00E04C239987
+ usb:0B05:17CB! --> the BT USB dongle vendor=0B05 and product=17CB, in "forced" mode.
'''
USB_RECIPIENT_DEVICE = 0x00
USB_REQUEST_TYPE_CLASS = 0x01 << 5
+ USB_DEVICE_CLASS_DEVICE = 0x00
USB_DEVICE_CLASS_WIRELESS_CONTROLLER = 0xE0
USB_DEVICE_SUBCLASS_RF_CONTROLLER = 0x01
USB_DEVICE_PROTOCOL_BLUETOOTH_PRIMARY_CONTROLLER = 0x01
@@ -63,6 +69,12 @@
USB_ENDPOINT_TRANSFER_TYPE_INTERRUPT = 0x03
USB_ENDPOINT_IN = 0x80
+ USB_BT_HCI_CLASS_TUPLE = (
+ USB_DEVICE_CLASS_WIRELESS_CONTROLLER,
+ USB_DEVICE_SUBCLASS_RF_CONTROLLER,
+ USB_DEVICE_PROTOCOL_BLUETOOTH_PRIMARY_CONTROLLER
+ )
+
READ_SIZE = 1024
class UsbPacketSink:
@@ -280,6 +292,13 @@
context.open()
try:
found = None
+
+ if spec.endswith('!'):
+ spec = spec[:-1]
+ forced_mode = True
+ else:
+ forced_mode = False
+
if ':' in spec:
vendor_id, product_id = spec.split(':')
serial_number = None
@@ -291,10 +310,14 @@
device_index = int(device_index_str)
for device in context.getDeviceIterator(skip_on_error=True):
+ try:
+ device_serial_number = device.getSerialNumber()
+ except usb1.USBError:
+ device_serial_number = None
if (
device.getVendorID() == int(vendor_id, 16) and
device.getProductID() == int(product_id, 16) and
- (serial_number is None or device.getSerialNumber() == serial_number)
+ (serial_number is None or serial_number == device_serial_number)
):
if device_index == 0:
found = device
@@ -302,13 +325,27 @@
device_index -= 1
device.close()
else:
+ # Look for a compatible device by index
+ def device_is_bluetooth_hci(device):
+ # Check if the device class indicates a match
+ if (device.getDeviceClass(), device.getDeviceSubClass(), device.getDeviceProtocol()) == \
+ USB_BT_HCI_CLASS_TUPLE:
+ return True
+
+ # If the device class is 'Device', look for a matching interface
+ if device.getDeviceClass() == USB_DEVICE_CLASS_DEVICE:
+ for configuration in device:
+ for interface in configuration:
+ for setting in interface:
+ if (setting.getClass(), setting.getSubClass(), setting.getProtocol()) == \
+ USB_BT_HCI_CLASS_TUPLE:
+ return True
+
+ return False
+
device_index = int(spec)
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_is_bluetooth_hci(device):
if device_index == 0:
found = device
break
@@ -329,9 +366,8 @@
setting = None
for setting in interface:
if (
- setting.getClass() != USB_DEVICE_CLASS_WIRELESS_CONTROLLER or
- setting.getSubClass() != USB_DEVICE_SUBCLASS_RF_CONTROLLER or
- setting.getProtocol() != USB_DEVICE_PROTOCOL_BLUETOOTH_PRIMARY_CONTROLLER
+ not forced_mode and
+ (setting.getClass(), setting.getSubClass(), setting.getProtocol()) != USB_BT_HCI_CLASS_TUPLE
):
continue
@@ -378,14 +414,13 @@
device = found.open()
- # Detach the kernel driver if supported and needed
+ # Auto-detach the kernel driver if supported
if usb1.hasCapability(usb1.CAP_SUPPORTS_DETACH_KERNEL_DRIVER):
try:
- if device.kernelDriverActive(interface):
- logger.debug("detaching kernel driver")
- device.detachKernelDriver(interface)
- except usb1.USBError:
- pass
+ logger.debug('auto-detaching kernel driver')
+ device.setAutoDetachKernelDriver(True)
+ except usb1.USBError as error:
+ logger.warning(f'unable to auto-detach kernel driver: {error}')
# Set the configuration if needed
try:
diff --git a/bumble/utils.py b/bumble/utils.py
index 1ab3fd7..5d8ab95 100644
--- a/bumble/utils.py
+++ b/bumble/utils.py
@@ -18,6 +18,7 @@
import asyncio
import logging
import traceback
+import collections
from functools import wraps
from colors import color
from pyee import EventEmitter
@@ -140,3 +141,95 @@
return wrapper
return decorator
+
+
+# -----------------------------------------------------------------------------
+class FlowControlAsyncPipe:
+ """
+ Asyncio pipe with flow control. When writing to the pipe, the source is
+ paused (by calling a function passed in when the pipe is created) if the
+ amount of queued data exceeds a specified threshold.
+ """
+ def __init__(self, pause_source, resume_source, write_to_sink=None, drain_sink=None, threshold=0):
+ self.pause_source = pause_source
+ self.resume_source = resume_source
+ self.write_to_sink = write_to_sink
+ self.drain_sink = drain_sink
+ self.threshold = threshold
+ self.queue = collections.deque() # Queue of packets
+ self.queued_bytes = 0 # Number of bytes in the queue
+ self.ready_to_pump = asyncio.Event()
+ self.paused = False
+ self.source_paused = False
+ self.pump_task = None
+
+ def start(self):
+ if self.pump_task is None:
+ self.pump_task = asyncio.create_task(self.pump())
+
+ self.check_pump()
+
+ def stop(self):
+ if self.pump_task is not None:
+ self.pump_task.cancel()
+ self.pump_task = None
+
+ def write(self, packet):
+ self.queued_bytes += len(packet)
+ self.queue.append(packet)
+
+ # Pause the source if we're over the threshold
+ if self.queued_bytes > self.threshold and not self.source_paused:
+ logger.debug(f'pausing source (queued={self.queued_bytes})')
+ self.pause_source()
+ self.source_paused = True
+
+ self.check_pump()
+
+ def pause(self):
+ if not self.paused:
+ self.paused = True
+ if not self.source_paused:
+ self.pause_source()
+ self.source_paused = True
+ self.check_pump()
+
+ def resume(self):
+ if self.paused:
+ self.paused = False
+ if self.source_paused:
+ self.resume_source()
+ self.source_paused = False
+ self.check_pump()
+
+ def can_pump(self):
+ return self.queue and not self.paused and self.write_to_sink is not None
+
+ def check_pump(self):
+ if self.can_pump():
+ self.ready_to_pump.set()
+ else:
+ self.ready_to_pump.clear()
+
+ async def pump(self):
+ while True:
+ # Wait until we can try to pump packets
+ await self.ready_to_pump.wait()
+
+ # Try to pump a packet
+ if self.can_pump():
+ packet = self.queue.pop()
+ self.write_to_sink(packet)
+ self.queued_bytes -= len(packet)
+
+ # Drain the sink if we can
+ if self.drain_sink:
+ await self.drain_sink()
+
+ # Check if we can accept more
+ if self.queued_bytes <= self.threshold and self.source_paused:
+ logger.debug(f'resuming source (queued={self.queued_bytes})')
+ self.source_paused = False
+ self.resume_source()
+
+ self.check_pump()
diff --git a/docs/mkdocs/mkdocs.yml b/docs/mkdocs/mkdocs.yml
index 3bade7b..87d791c 100644
--- a/docs/mkdocs/mkdocs.yml
+++ b/docs/mkdocs/mkdocs.yml
@@ -45,6 +45,10 @@
- HCI Bridge: apps_and_tools/hci_bridge.md
- Golden Gate Bridge: apps_and_tools/gg_bridge.md
- Show: apps_and_tools/show.md
+ - GATT Dump: apps_and_tools/gatt_dump.md
+ - Pair: apps_and_tools/pair.md
+ - Unbond: apps_and_tools/unbond.md
+ - USB Probe: apps_and_tools/usb_probe.md
- Hardware:
- Overview: hardware/index.md
- Platforms:
diff --git a/docs/mkdocs/requirements.txt b/docs/mkdocs/requirements.txt
index a9b8452..a12d40d 100644
--- a/docs/mkdocs/requirements.txt
+++ b/docs/mkdocs/requirements.txt
@@ -1,6 +1,6 @@
# This requirements file is for python3
-mkdocs == 1.2.3
-mkdocs-material == 7.1.7
-mkdocs-material-extensions == 1.0.1
-pymdown-extensions == 8.2
-mkdocstrings == 0.15.1
\ No newline at end of file
+mkdocs == 1.4.0
+mkdocs-material == 8.5.6
+mkdocs-material-extensions == 1.0.3
+pymdown-extensions == 9.6
+mkdocstrings-python == 0.7.1
\ No newline at end of file
diff --git a/docs/mkdocs/src/apps_and_tools/usb_probe.md b/docs/mkdocs/src/apps_and_tools/usb_probe.md
index 80e2f41..95751a3 100644
--- a/docs/mkdocs/src/apps_and_tools/usb_probe.md
+++ b/docs/mkdocs/src/apps_and_tools/usb_probe.md
@@ -13,17 +13,29 @@
## Usage
-This command line tool takes no arguments.
+This command line tool may be invoked with no arguments, or with `--verbose`
+for extra details.
When installed from PyPI, run as
```
$ bumble-usb-probe
```
+or, for extra details, with the `--verbose` argument
+```
+$ bumble-usb-probe --v
+```
+
When running from the source distribution:
```
$ python3 apps/usb-probe.py
```
+or
+
+```
+$ python3 apps/usb-probe.py --verbose
+```
+
!!! example
```
$ python3 apps/usb_probe.py
diff --git a/docs/mkdocs/src/platforms/linux.md b/docs/mkdocs/src/platforms/linux.md
index 6468cd9..338908c 100644
--- a/docs/mkdocs/src/platforms/linux.md
+++ b/docs/mkdocs/src/platforms/linux.md
@@ -1,48 +1,86 @@
:material-linux: LINUX PLATFORM
===============================
-In addition to all the standard functionality available from the project by running the python tools and/or writing your own apps by leveraging the API, it is also possible on Linux hosts to interface the Bumble stack with the native BlueZ stack, and with Bluetooth controllers.
+Using Bumble With Physical Bluetooth Controllers
+------------------------------------------------
-Using Bumble With BlueZ
------------------------
+A Bumble application can interface with a local Bluetooth controller on a Linux host.
+The 3 main types of physical Bluetooth controllers are:
-A Bumble virtual controller can be attached to the BlueZ stack.
-Attaching a controller to BlueZ can be done by either simulating a UART HCI interface, or by using the VHCI driver interface if available.
-In both cases, the controller can run locally on the Linux host, or remotely on a different host, with a bridge between the remote controller and the local BlueZ host, which may be useful when the BlueZ stack is running on an embedded system, or a host on which running the Bumble controller is not convenient.
+ * Bluetooth USB Dongle
+ * HCI over UART (via a serial port)
+ * Kernel-managed Bluetooth HCI (HCI Sockets)
-### Using VHCI
+!!! tip "Conflicts with the kernel and BlueZ"
+ If your use a USB dongle that is recognized by your kernel as a supported Bluetooth device, it is
+ likely that the kernel driver will claim that USB device and attach it to the BlueZ stack.
+ If you want to claim ownership of it to use with Bumble, you will need to set the state of the corresponding HCI interface as `DOWN`.
+ HCI interfaces are numbered, starting from 0 (i.e `hci0`, `hci1`, ...).
-With the [VHCI transport](../transports/vhci.md) you can attach a Bumble virtual controller to the BlueZ stack. Once attached, the controller will appear just like any other controller, and thus can be used with the standard BlueZ tools.
-
-!!! example "Attaching a virtual controller"
- With the example app `run_controller.py`:
+ For example, to bring `hci0` down:
```
- PYTHONPATH=. python3 examples/run_controller.py F6:F7:F8:F9:FA:FB examples/device1.json vhci
- ```
-
- You should see a 'Virtual Bus' controller. For example:
- ```
- $ hciconfig
- hci0: Type: Primary Bus: Virtual
- BD Address: F6:F7:F8:F9:FA:FB ACL MTU: 27:64 SCO MTU: 0:0
- UP RUNNING
- RX bytes:0 acl:0 sco:0 events:43 errors:0
- TX bytes:274 acl:0 sco:0 commands:43 errors:0
+ $ sudo hciconfig hci0 down
```
- And scanning for devices should show the virtual 'Bumble' device that's running as part of the `run_controller.py` example app:
+ You can use the `hciconfig` command with no arguments to get a list of HCI interfaces seen by
+ the kernel.
+
+ Also, if `bluetoothd` is running on your system, it will likely re-claim the interface after you
+ close it, so you may need to bring the interface back `UP` before using it again, or to disable
+ `bluetoothd` altogether (see the section further below about BlueZ and `bluetoothd`).
+
+### Using a USB Dongle
+
+See the [USB Transport page](../transports/usb.md) for general information on how to use HCI USB controllers.
+
+!!! tip "USB Permissions"
+ By default, when running as a regular user, you won't have the permission to use
+ arbitrary USB devices.
+ You can change the permissions for a specific USB device based on its bus number and
+ device number (you can use `lsusb` to find the Bus and Device numbers for your Bluetooth
+ dongle).
+
+ Example:
```
- pi@raspberrypi:~ $ sudo hcitool -i hci2 lescan
- LE Scan ...
- F0:F1:F2:F3:F4:F5 Bumble
+ $ sudo chmod o+w /dev/bus/usb/001/004
```
+ This will change the permissions for Device 4 on Bus 1.
+
+ Note that the USB Bus number and Device number may change depending on where you plug the USB
+ dongle and what other USB devices and hubs are also plugged in.
+
+ If you need to make the permission changes permanent across reboots, you can create a `udev`
+ rule for your specific Bluetooth dongle. Visit [this Arch Linux Wiki page](https://wiki.archlinux.org/title/udev) for a
+ good overview of how you may do that.
+
+### Using HCI over UART
+
+See the [Serial Transport page](../transports/serial.md) for general information on how to use HCI over a UART (serial port).
### Using HCI Sockets
HCI sockets provide a way to send/receive HCI packets to/from a Bluetooth controller managed by the kernel.
-The HCI device referenced by an `hci-socket` transport (`hciX`, where `X` is an integer, with `hci0` being the first controller device, and so on) must be in the `DOWN` state before it can be opened as a transport.
-You can bring a HCI controller `UP` or `DOWN` with `hciconfig`.
+See the [HCI Socket Transport page](../transports/hci_socket.md) for details on the `hci-socket` tansport syntax.
+The HCI device referenced by an `hci-socket` transport (`hci<X>`, where `<X>` is an integer, with `hci0` being the first controller device, and so on) must be in the `DOWN` state before it can be opened as a transport.
+You can bring a HCI controller `UP` or `DOWN` with `hciconfig hci<X> up` and `hciconfig hci<X> up`.
+
+!!! tip "HCI Socket Permissions"
+ By default, when running as a regular user, you won't have the permission to use
+ an HCI socket to a Bluetooth controller (you may see an exception like `PermissionError: [Errno 1] Operation not permitted`).
+
+ If you want to run without using `sudo`, you need to manage the capabilities by adding the appropriate entries in `/etc/security/capability.conf` to grant a user or group the `cap_net_admin` capability.
+ See [this manpage](https://manpages.ubuntu.com/manpages/bionic/man5/capability.conf.5.html) for details.
+
+ Alternatively, if you are just experimenting temporarily, the `capsh` command may be useful in order
+ to execute a single command with enhanced permissions, as in this example:
+
+
+ ```
+ $ sudo capsh --caps="cap_net_admin+eip cap_setpcap,cap_setuid,cap_setgid+ep" --keep=1 --user=$USER --addamb=cap_net_admin -- -c "<path/to/executable> <executable-args>"
+ ```
+ Where `<path/to/executable>` is the path to your `python3` executable or to one of the Bumble bundled command-line applications.
+
!!! tip "List all available controllers"
The command
```
@@ -72,29 +110,16 @@
```
$ hciconfig hci0 down
```
- (or `hciX` with `X` being the index of the controller device you want to use), but a simpler solution is to just stop the `bluetoothd` daemon, with a command like:
+ (or `hci<X>` with `<X>` being the index of the controller device you want to use), but a simpler solution is to just stop the `bluetoothd` daemon, with a command like:
```
$ sudo systemctl stop bluetooth.service
```
You can always re-start the daemon with
```
$ sudo systemctl start bluetooth.service
- ```
-### Using a Simulated UART HCI
-
-### Bridge to a Remote Controller
-
-
-Using Bumble With Bluetooth Controllers
----------------------------------------
-
-A Bumble application can interface with a local Bluetooth controller.
-If your Bluetooth controller is a standard HCI USB controller, see the [USB Transport page](../transports/usb.md) for details on how to use HCI USB controllers.
-If your Bluetooth controller is a standard HCI UART controller, see the [Serial Transport page](../transports/serial.md).
-Alternatively, a Bumble Host object can communicate with one of the platform's controllers via an HCI Socket.
-
-`<details to be filled in>`
+Bumble on the Raspberry Pi
+--------------------------
### Raspberry Pi 4 :fontawesome-brands-raspberry-pi:
@@ -102,9 +127,10 @@
#### Via The Kernel
-Use an HCI Socket transport
+Use an HCI Socket transport (see section above)
#### Directly
+
In order to use the Bluetooth controller directly on a Raspberry Pi 4 board, you need to ensure that it isn't being used by the BlueZ stack (which it probably is by default).
```
@@ -136,3 +162,47 @@
python3 run_scanner.py serial:/dev/serial1,3000000
```
+
+Using Bumble With BlueZ
+-----------------------
+
+In addition to all the standard functionality available from the project by running the python tools and/or writing your own apps by leveraging the API, it is also possible on Linux hosts to interface the Bumble stack with the native BlueZ stack, and with Bluetooth controllers.
+
+A Bumble virtual controller can be attached to the BlueZ stack.
+Attaching a controller to BlueZ can be done by either simulating a UART HCI interface, or by using the VHCI driver interface if available.
+In both cases, the controller can run locally on the Linux host, or remotely on a different host, with a bridge between the remote controller and the local BlueZ host, which may be useful when the BlueZ stack is running on an embedded system, or a host on which running the Bumble controller is not convenient.
+
+### Using VHCI
+
+With the [VHCI transport](../transports/vhci.md) you can attach a Bumble virtual controller to the BlueZ stack. Once attached, the controller will appear just like any other controller, and thus can be used with the standard BlueZ tools.
+
+!!! example "Attaching a virtual controller"
+ With the example app `run_controller.py`:
+ ```
+ python3 examples/run_controller.py F6:F7:F8:F9:FA:FB examples/device1.json vhci
+ ```
+
+ You should see a 'Virtual Bus' controller. For example:
+ ```
+ $ hciconfig
+ hci0: Type: Primary Bus: Virtual
+ BD Address: F6:F7:F8:F9:FA:FB ACL MTU: 27:64 SCO MTU: 0:0
+ UP RUNNING
+ RX bytes:0 acl:0 sco:0 events:43 errors:0
+ TX bytes:274 acl:0 sco:0 commands:43 errors:0
+ ```
+
+ And scanning for devices should show the virtual 'Bumble' device that's running as part of the `run_controller.py` example app:
+ ```
+ pi@raspberrypi:~ $ sudo hcitool -i hci2 lescan
+ LE Scan ...
+ F0:F1:F2:F3:F4:F5 Bumble
+ ```
+
+ ```
+
+### Using a Simulated UART HCI
+
+### Bridge to a Remote Controller
+
+
diff --git a/docs/mkdocs/src/transports/android_emulator.md b/docs/mkdocs/src/transports/android_emulator.md
index d903394..e43c82c 100644
--- a/docs/mkdocs/src/transports/android_emulator.md
+++ b/docs/mkdocs/src/transports/android_emulator.md
@@ -5,8 +5,9 @@
("host" mode), or attaches a virtual controller to the Android Bluetooth host stack ("controller" mode).
## Moniker
-The moniker syntax for an Android Emulator transport is: `android-emulator:[mode=<host|controller>][mode=<host|controller>]`.
-Both the `mode=<host|controller>` and `mode=<host|controller>` parameters are optional (so the moniker `android-emulator` by itself is a valid moniker, which will create a transport in `host` mode, connected to `localhost` on the default gRPC port for the emulator)
+The moniker syntax for an Android Emulator transport is: `android-emulator:[mode=<host|controller>][<hostname>:<port>]`, where
+the `mode` parameter can specify running as a host or a controller, and `<hostname>:<port>` can specify a host name (or IP address) and TCP port number on which to reach the gRPC server for the emulator.
+Both the `mode=<host|controller>` and `<hostname>:<port>` parameters are optional (so the moniker `android-emulator` by itself is a valid moniker, which will create a transport in `host` mode, connected to `localhost` on the default gRPC port for the emulator).
!!! example Example
`android-emulator`
diff --git a/docs/mkdocs/src/transports/usb.md b/docs/mkdocs/src/transports/usb.md
index 6fe5e2a..d794e33 100644
--- a/docs/mkdocs/src/transports/usb.md
+++ b/docs/mkdocs/src/transports/usb.md
@@ -5,6 +5,7 @@
## Moniker
The moniker for a USB transport is either:
+
* `usb:<index>`
* `usb:<vendor>:<product>`
* `usb:<vendor>:<product>/<serial-number>`
@@ -16,6 +17,10 @@
`<vendor>` and `<product>` are a vendor ID and product ID in hexadecimal.
+In addition, if the moniker ends with the symbol "!", the device will be used in "forced" mode:
+the first USB interface of the device will be used, regardless of the interface class/subclass.
+This may be useful for some devices that use a custom class/subclass but may nonetheless work as-is.
+
!!! examples
`usb:04b4:f901`
The USB dongle with `<vendor>` equal to `04b4` and `<product>` equal to `f901`
@@ -29,6 +34,10 @@
`usb:04b4:f901/#1`
The second USB dongle with `<vendor>` equal to `04b4` and `<product>` equal to `f901`
+ `usb:0B05:17CB!`
+ The BT USB dongle vendor=0B05 and product=17CB, in "forced" mode.
+
+
## Alternative
The library includes two different implementations of the USB transport, implemented using different python bindings for `libusb`.
Using the transport prefix `pyusb:` instead of `usb:` selects the implementation based on [PyUSB](https://pypi.org/project/pyusb/), using the synchronous API of `libusb`, whereas the default implementation is based on [libusb1](https://pypi.org/project/libusb1/), using the asynchronous API of `libusb`. In order to use the alternative PyUSB-based implementation, you need to ensure that you have installed that python module, as it isn't installed by default as a dependency of Bumble.
diff --git a/examples/asha_sink1.json b/examples/asha_sink1.json
new file mode 100644
index 0000000..badef8b
--- /dev/null
+++ b/examples/asha_sink1.json
@@ -0,0 +1,5 @@
+{
+ "name": "Bumble Aid Left",
+ "address": "F1:F2:F3:F4:F5:F6",
+ "keystore": "JsonKeyStore"
+}
diff --git a/examples/asha_sink2.json b/examples/asha_sink2.json
new file mode 100644
index 0000000..785d406
--- /dev/null
+++ b/examples/asha_sink2.json
@@ -0,0 +1,5 @@
+{
+ "name": "Bumble Aid Right",
+ "address": "F7:F8:F9:FA:FB:FC",
+ "keystore": "JsonKeyStore"
+}
diff --git a/examples/keyboard.py b/examples/keyboard.py
index ddb9f0f..068cbc0 100644
--- a/examples/keyboard.py
+++ b/examples/keyboard.py
@@ -34,8 +34,8 @@
Characteristic,
CharacteristicValue,
GATT_DEVICE_INFORMATION_SERVICE,
- GATT_DEVICE_HUMAN_INTERFACE_DEVICE_SERVICE,
- GATT_DEVICE_BATTERY_SERVICE,
+ GATT_HUMAN_INTERFACE_DEVICE_SERVICE,
+ GATT_BATTERY_SERVICE,
GATT_BATTERY_LEVEL_CHARACTERISTIC,
GATT_MANUFACTURER_NAME_STRING_CHARACTERISTIC,
GATT_REPORT_CHARACTERISTIC,
@@ -126,8 +126,8 @@
connection = await device.connect(peer_address)
await connection.pair()
peer = Peer(connection)
- await peer.discover_service(GATT_DEVICE_HUMAN_INTERFACE_DEVICE_SERVICE)
- hid_services = peer.get_services_by_uuid(GATT_DEVICE_HUMAN_INTERFACE_DEVICE_SERVICE)
+ await peer.discover_service(GATT_HUMAN_INTERFACE_DEVICE_SERVICE)
+ hid_services = peer.get_services_by_uuid(GATT_HUMAN_INTERFACE_DEVICE_SERVICE)
if not hid_services:
print(color('!!! No HID service', 'red'))
return
@@ -221,7 +221,7 @@
]
),
Service(
- GATT_DEVICE_HUMAN_INTERFACE_DEVICE_SERVICE,
+ GATT_HUMAN_INTERFACE_DEVICE_SERVICE,
[
Characteristic(
GATT_PROTOCOL_MODE_CHARACTERISTIC,
@@ -252,7 +252,7 @@
]
),
Service(
- GATT_DEVICE_BATTERY_SERVICE,
+ GATT_BATTERY_SERVICE,
[
Characteristic(
GATT_BATTERY_LEVEL_CHARACTERISTIC,
@@ -273,7 +273,7 @@
AdvertisingData([
(AdvertisingData.COMPLETE_LOCAL_NAME, bytes('Bumble Keyboard', 'utf-8')),
(AdvertisingData.INCOMPLETE_LIST_OF_16_BIT_SERVICE_CLASS_UUIDS,
- bytes(GATT_DEVICE_HUMAN_INTERFACE_DEVICE_SERVICE)),
+ bytes(GATT_HUMAN_INTERFACE_DEVICE_SERVICE)),
(AdvertisingData.APPEARANCE, struct.pack('<H', 0x03C1)),
(AdvertisingData.FLAGS, bytes([0x05]))
])
diff --git a/examples/run_advertiser.py b/examples/run_advertiser.py
index 5201356..e54bc37 100644
--- a/examples/run_advertiser.py
+++ b/examples/run_advertiser.py
@@ -29,18 +29,31 @@
# -----------------------------------------------------------------------------
async def main():
- if len(sys.argv) != 3:
- print('Usage: run_advertiser.py <config-file> <transport-spec>')
- print('example: run_advertiser.py device1.json link-relay:ws://localhost:8888/test')
+ if len(sys.argv) < 3:
+ print('Usage: run_advertiser.py <config-file> <transport-spec> [type] [address]')
+ print('example: run_advertiser.py device1.json usb:0')
return
+ if len(sys.argv) >= 4:
+ advertising_type = AdvertisingType(int(sys.argv[3]))
+ else:
+ advertising_type = AdvertisingType.UNDIRECTED_CONNECTABLE_SCANNABLE
+
+ if advertising_type.is_directed:
+ if len(sys.argv) < 5:
+ print('<address> required for directed advertising')
+ return
+ target = Address(sys.argv[4])
+ else:
+ target = None
+
print('<<< connecting to HCI...')
async with await open_transport_or_link(sys.argv[2]) as (hci_source, hci_sink):
print('<<< connected')
device = Device.from_config_file_with_hci(sys.argv[1], hci_source, hci_sink)
await device.power_on()
- await device.start_advertising()
+ await device.start_advertising(advertising_type=advertising_type, target=target)
await hci_source.wait_for_termination()
# -----------------------------------------------------------------------------
diff --git a/examples/run_asha_sink.py b/examples/run_asha_sink.py
new file mode 100644
index 0000000..bebb5de
--- /dev/null
+++ b/examples/run_asha_sink.py
@@ -0,0 +1,161 @@
+# Copyright 2021-2022 Google LLC
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# https://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+# -----------------------------------------------------------------------------
+# Imports
+# -----------------------------------------------------------------------------
+import asyncio
+import struct
+import sys
+import os
+import logging
+
+from bumble.core import AdvertisingData
+from bumble.device import Device
+from bumble.transport import open_transport_or_link
+from bumble.hci import UUID
+from bumble.gatt import (
+ Service,
+ Characteristic,
+ CharacteristicValue
+)
+
+
+# -----------------------------------------------------------------------------
+# Constants
+# -----------------------------------------------------------------------------
+ASHA_SERVICE = UUID.from_16_bits(0xFDF0, 'Audio Streaming for Hearing Aid')
+ASHA_READ_ONLY_PROPERTIES_CHARACTERISTIC = UUID('6333651e-c481-4a3e-9169-7c902aad37bb', 'ReadOnlyProperties')
+ASHA_AUDIO_CONTROL_POINT_CHARACTERISTIC = UUID('f0d4de7e-4a88-476c-9d9f-1937b0996cc0', 'AudioControlPoint')
+ASHA_AUDIO_STATUS_CHARACTERISTIC = UUID('38663f1a-e711-4cac-b641-326b56404837', 'AudioStatus')
+ASHA_VOLUME_CHARACTERISTIC = UUID('00e4ca9e-ab14-41e4-8823-f9e70c7e91df', 'Volume')
+ASHA_LE_PSM_OUT_CHARACTERISTIC = UUID('2d410339-82b6-42aa-b34e-e2e01df8cc1a', 'LE_PSM_OUT')
+
+
+# -----------------------------------------------------------------------------
+async def main():
+ if len(sys.argv) != 4:
+ print('Usage: python run_asha_sink.py <device-config> <transport-spec> <audio-file>')
+ print('example: python run_asha_sink.py device1.json usb:0 audio_out.g722')
+ return
+
+ audio_out = open(sys.argv[3], 'wb')
+
+ async with await open_transport_or_link(sys.argv[2]) as (hci_source, hci_sink):
+ device = Device.from_config_file_with_hci(sys.argv[1], hci_source, hci_sink)
+
+ # Handler for audio control commands
+ def on_audio_control_point_write(connection, value):
+ print('--- AUDIO CONTROL POINT Write:', value.hex())
+ opcode = value[0]
+ if opcode == 1:
+ # Start
+ audio_type = ('Unknown', 'Ringtone', 'Phone Call', 'Media')[value[2]]
+ print(f'### START: codec={value[1]}, audio_type={audio_type}, volume={value[3]}, otherstate={value[4]}')
+ elif opcode == 2:
+ print('### STOP')
+ elif opcode == 3:
+ print(f'### STATUS: connected={value[1]}')
+
+ # Respond with a status
+ asyncio.create_task(device.notify_subscribers(audio_status_characteristic, force=True))
+
+ # Handler for volume control
+ def on_volume_write(connection, value):
+ print('--- VOLUME Write:', value[0])
+
+ # Register an L2CAP CoC server
+ def on_coc(channel):
+ def on_data(data):
+ print('<<< Voice data received:', data.hex())
+ audio_out.write(data)
+
+ channel.sink = on_data
+
+ psm = device.register_l2cap_channel_server(0, on_coc, 8)
+ print(f'### LE_PSM_OUT = {psm}')
+
+ # Add the ASHA service to the GATT server
+ read_only_properties_characteristic = Characteristic(
+ ASHA_READ_ONLY_PROPERTIES_CHARACTERISTIC,
+ Characteristic.READ,
+ Characteristic.READABLE,
+ bytes([
+ 0x01, # Version
+ 0x00, # Device Capabilities [Left, Monaural]
+ 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, # HiSyncId
+ 0x01, # Feature Map [LE CoC audio output streaming supported]
+ 0x00, 0x00, # Render Delay
+ 0x00, 0x00, # RFU
+ 0x02, 0x00 # Codec IDs [G.722 at 16 kHz]
+ ])
+ )
+ audio_control_point_characteristic = Characteristic(
+ ASHA_AUDIO_CONTROL_POINT_CHARACTERISTIC,
+ Characteristic.WRITE | Characteristic.WRITE_WITHOUT_RESPONSE,
+ Characteristic.WRITEABLE,
+ CharacteristicValue(write=on_audio_control_point_write)
+ )
+ audio_status_characteristic = Characteristic(
+ ASHA_AUDIO_STATUS_CHARACTERISTIC,
+ Characteristic.READ | Characteristic.NOTIFY,
+ Characteristic.READABLE,
+ bytes([0])
+ )
+ volume_characteristic = Characteristic(
+ ASHA_VOLUME_CHARACTERISTIC,
+ Characteristic.WRITE_WITHOUT_RESPONSE,
+ Characteristic.WRITEABLE,
+ CharacteristicValue(write=on_volume_write)
+ )
+ le_psm_out_characteristic = Characteristic(
+ ASHA_LE_PSM_OUT_CHARACTERISTIC,
+ Characteristic.READ,
+ Characteristic.READABLE,
+ struct.pack('<H', psm)
+ )
+ device.add_service(Service(
+ ASHA_SERVICE,
+ [
+ read_only_properties_characteristic,
+ audio_control_point_characteristic,
+ audio_status_characteristic,
+ volume_characteristic,
+ le_psm_out_characteristic
+ ]
+ ))
+
+ # Set the advertising data
+ device.advertising_data = bytes(
+ AdvertisingData([
+ (AdvertisingData.COMPLETE_LOCAL_NAME, bytes(device.name, 'utf-8')),
+ (AdvertisingData.FLAGS, bytes([0x06])),
+ (AdvertisingData.INCOMPLETE_LIST_OF_16_BIT_SERVICE_CLASS_UUIDS, bytes(ASHA_SERVICE)),
+ (AdvertisingData.SERVICE_DATA_16_BIT_UUID, bytes(ASHA_SERVICE) + bytes([
+ 0x01, # Protocol Version
+ 0x00, # Capability
+ 0x01, 0x02, 0x03, 0x04 # Truncated HiSyncID
+ ]))
+ ])
+ )
+
+ # Go!
+ await device.power_on()
+ await device.start_advertising(auto_restart=True)
+
+ await hci_source.wait_for_termination()
+
+# -----------------------------------------------------------------------------
+logging.basicConfig(level = os.environ.get('BUMBLE_LOGLEVEL', 'DEBUG').upper())
+asyncio.run(main())
diff --git a/examples/run_classic_connect.py b/examples/run_classic_connect.py
index d6842fe..8395a23 100644
--- a/examples/run_classic_connect.py
+++ b/examples/run_classic_connect.py
@@ -30,7 +30,7 @@
# -----------------------------------------------------------------------------
async def main():
if len(sys.argv) < 3:
- print('Usage: run_classic_connect.py <device-config> <transport-spec> <bluetooth-address>')
+ print('Usage: run_classic_connect.py <device-config> <transport-spec> <bluetooth-addresses..>')
print('example: run_classic_connect.py classic1.json usb:04b4:f901 E1:CA:72:48:C4:E8')
return
@@ -43,8 +43,7 @@
device.classic_enabled = True
await device.power_on()
- # Connect to a peer
- target_address = sys.argv[3]
+ async def connect(target_address):
print(f'=== Connecting to {target_address}...')
connection = await device.connect(target_address, transport=BT_BR_EDR_TRANSPORT)
print(f'=== Connected to {connection.peer_address}!')
@@ -76,6 +75,10 @@
await sdp_client.disconnect()
await hci_source.wait_for_termination()
+ # Connect to a peer
+ target_addresses = sys.argv[3:]
+ await asyncio.wait([asyncio.create_task(connect(target_address)) for target_address in target_addresses])
+
# -----------------------------------------------------------------------------
logging.basicConfig(level = os.environ.get('BUMBLE_LOGLEVEL', 'DEBUG').upper())
asyncio.run(main())
diff --git a/examples/run_controller_with_scanner.py b/examples/run_controller_with_scanner.py
index 88bc1f8..18ba274 100644
--- a/examples/run_controller_with_scanner.py
+++ b/examples/run_controller_with_scanner.py
@@ -29,15 +29,15 @@
# -----------------------------------------------------------------------------
class ScannerListener(Device.Listener):
- def on_advertisement(self, address, ad_data, rssi, connectable):
- address_type_string = ('P', 'R', 'PI', 'RI')[address.address_type]
- address_color = 'yellow' if connectable else 'red'
+ def on_advertisement(self, advertisement):
+ address_type_string = ('P', 'R', 'PI', 'RI')[advertisement.address.address_type]
+ address_color = 'yellow' if advertisement.is_connectable else 'red'
if address_type_string.startswith('P'):
type_color = 'green'
else:
type_color = 'cyan'
- print(f'>>> {color(address, address_color)} [{color(address_type_string, type_color)}]: RSSI={rssi}, {ad_data}')
+ print(f'>>> {color(advertisement.address, address_color)} [{color(address_type_string, type_color)}]: RSSI={advertisement.rssi}, {advertisement.data}')
# -----------------------------------------------------------------------------
diff --git a/examples/run_notifier.py b/examples/run_notifier.py
index f52b56c..15173cb 100644
--- a/examples/run_notifier.py
+++ b/examples/run_notifier.py
@@ -50,10 +50,16 @@
# -----------------------------------------------------------------------------
+# Alternative way to listen for subscriptions
+# -----------------------------------------------------------------------------
+def on_my_characteristic_subscription(peer, enabled):
+ print(f'### My characteristic from {peer}: {"enabled" if enabled else "disabled"}')
+
+# -----------------------------------------------------------------------------
async def main():
if len(sys.argv) < 3:
- print('Usage: run_gatt_server.py <device-config> <transport-spec>')
- print('example: run_gatt_server.py device1.json usb:0')
+ print('Usage: run_notifier.py <device-config> <transport-spec>')
+ print('example: run_notifier.py device1.json usb:0')
return
print('<<< connecting to HCI...')
@@ -83,6 +89,7 @@
Characteristic.READABLE,
bytes([0x42])
)
+ characteristic3.on('subscription', on_my_characteristic_subscription)
custom_service = Service(
'50DB505C-8AC4-4738-8448-3B1D9CC09CC5',
[characteristic1, characteristic2, characteristic3]
diff --git a/examples/run_rfcomm_client.py b/examples/run_rfcomm_client.py
index 83ef848..76586c3 100644
--- a/examples/run_rfcomm_client.py
+++ b/examples/run_rfcomm_client.py
@@ -98,6 +98,7 @@
await sdp_client.disconnect()
+
# -----------------------------------------------------------------------------
class TcpServerProtocol(asyncio.Protocol):
def __init__(self, rfcomm_session):
@@ -173,7 +174,7 @@
print('*** Encryption on')
# Create a client and start it
- print('@@@ Starting to RFCOMM client...')
+ print('@@@ Starting RFCOMM client...')
rfcomm_client = Client(device, connection)
rfcomm_mux = await rfcomm_client.start()
print('@@@ Started')
@@ -192,7 +193,7 @@
if len(sys.argv) == 6:
# A TCP port was specified, start listening
tcp_port = int(sys.argv[5])
- asyncio.get_running_loop().create_task(tcp_server(tcp_port, session))
+ asyncio.create_task(tcp_server(tcp_port, session))
await hci_source.wait_for_termination()
diff --git a/examples/run_scanner.py b/examples/run_scanner.py
index feed88f..719e58e 100644
--- a/examples/run_scanner.py
+++ b/examples/run_scanner.py
@@ -40,24 +40,24 @@
device = Device.with_hci('Bumble', 'F0:F1:F2:F3:F4:F5', hci_source, hci_sink)
@device.on('advertisement')
- def _(address, ad_data, rssi, connectable):
- address_type_string = ('PUBLIC', 'RANDOM', 'PUBLIC_ID', 'RANDOM_ID')[address.address_type]
- address_color = 'yellow' if connectable else 'red'
+ def _(advertisement):
+ address_type_string = ('PUBLIC', 'RANDOM', 'PUBLIC_ID', 'RANDOM_ID')[advertisement.address.address_type]
+ address_color = 'yellow' if advertisement.is_connectable else 'red'
address_qualifier = ''
if address_type_string.startswith('P'):
type_color = 'cyan'
else:
- if address.is_static:
+ if advertisement.address.is_static:
type_color = 'green'
address_qualifier = '(static)'
- elif address.is_resolvable:
+ elif advertisement.address.is_resolvable:
type_color = 'magenta'
address_qualifier = '(resolvable)'
else:
type_color = 'white'
separator = '\n '
- print(f'>>> {color(address, address_color)} [{color(address_type_string, type_color)}]{address_qualifier}:{separator}RSSI:{rssi}{separator}{ad_data.to_string(separator)}')
+ print(f'>>> {color(advertisement.address, address_color)} [{color(address_type_string, type_color)}]{address_qualifier}:{separator}RSSI:{advertisement.rssi}{separator}{advertisement.data.to_string(separator)}')
await device.power_on()
await device.start_scanning(filter_duplicates=filter_duplicates)
diff --git a/setup.cfg b/setup.cfg
index 05d07c9..441fbaa 100644
--- a/setup.cfg
+++ b/setup.cfg
@@ -48,8 +48,10 @@
[options.entry_points]
console_scripts =
bumble-console = bumble.apps.console:main
+ bumble-controller-info = bumble.apps.controller_info:main
bumble-gatt-dump = bumble.apps.gatt_dump:main
bumble-hci-bridge = bumble.apps.hci_bridge:main
+ bumble-l2cap-bridge = bumble.apps.l2cap_bridge:main
bumble-pair = bumble.apps.pair:main
bumble-scan = bumble.apps.scan:main
bumble-show = bumble.apps.show:main
@@ -63,10 +65,12 @@
test =
pytest >= 6.2
pytest-asyncio >= 0.17
+ pytest-html >= 3.2.0
+ coverage >= 6.4
development =
invoke >= 1.4
nox >= 2022
documentation =
- mkdocs >= 1.2.3
- mkdocs-material >= 8.1.9
+ mkdocs >= 1.4.0
+ mkdocs-material >= 8.5.6
mkdocstrings[python] >= 0.19.0
diff --git a/tasks.py b/tasks.py
index 61f3dd6..ddba8cd 100644
--- a/tasks.py
+++ b/tasks.py
@@ -53,7 +53,7 @@
ns.add_collection(test_tasks, name="test")
@task
-def test(ctx, filter=None, junit=False, install=False):
+def test(ctx, filter=None, junit=False, install=False, html=False):
# Install the package before running the tests
if install:
ctx.run("python -m pip install .[test]")
@@ -63,6 +63,8 @@
args += "--junit-xml test-results.xml"
if filter is not None:
args += " -k '{}'".format(filter)
+ if html:
+ args += "--html results.html"
ctx.run("python -m pytest {} {}".format(os.path.join(ROOT_DIR, "tests"), args))
test_tasks.add_task(test, default=True)
diff --git a/tests/core_test.py b/tests/core_test.py
index f4bdd83..fa397db 100644
--- a/tests/core_test.py
+++ b/tests/core_test.py
@@ -24,19 +24,19 @@
ad = AdvertisingData.from_bytes(data)
ad_bytes = bytes(ad)
assert(data == ad_bytes)
- assert(ad.get(AdvertisingData.COMPLETE_LOCAL_NAME) is None)
- assert(ad.get(AdvertisingData.TX_POWER_LEVEL) == bytes([123]))
- assert(ad.get(AdvertisingData.COMPLETE_LOCAL_NAME, return_all=True) == [])
- assert(ad.get(AdvertisingData.TX_POWER_LEVEL, return_all=True) == [bytes([123])])
+ assert(ad.get(AdvertisingData.COMPLETE_LOCAL_NAME, raw=True) is None)
+ assert(ad.get(AdvertisingData.TX_POWER_LEVEL, raw=True) == bytes([123]))
+ assert(ad.get(AdvertisingData.COMPLETE_LOCAL_NAME, return_all=True, raw=True) == [])
+ assert(ad.get(AdvertisingData.TX_POWER_LEVEL, return_all=True, raw=True) == [bytes([123])])
data2 = bytes([2, AdvertisingData.TX_POWER_LEVEL, 234])
ad.append(data2)
ad_bytes = bytes(ad)
assert(ad_bytes == data + data2)
- assert(ad.get(AdvertisingData.COMPLETE_LOCAL_NAME) is None)
- assert(ad.get(AdvertisingData.TX_POWER_LEVEL) == bytes([123]))
- assert(ad.get(AdvertisingData.COMPLETE_LOCAL_NAME, return_all=True) == [])
- assert(ad.get(AdvertisingData.TX_POWER_LEVEL, return_all=True) == [bytes([123]), bytes([234])])
+ assert(ad.get(AdvertisingData.COMPLETE_LOCAL_NAME, raw=True) is None)
+ assert(ad.get(AdvertisingData.TX_POWER_LEVEL, raw=True) == bytes([123]))
+ assert(ad.get(AdvertisingData.COMPLETE_LOCAL_NAME, return_all=True, raw=True) == [])
+ assert(ad.get(AdvertisingData.TX_POWER_LEVEL, return_all=True, raw=True) == [bytes([123]), bytes([234])])
# -----------------------------------------------------------------------------
diff --git a/tests/device_test.py b/tests/device_test.py
new file mode 100644
index 0000000..acf4446
--- /dev/null
+++ b/tests/device_test.py
@@ -0,0 +1,188 @@
+# Copyright 2021-2022 Google LLC
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# https://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+# -----------------------------------------------------------------------------
+# Imports
+# -----------------------------------------------------------------------------
+import asyncio
+import logging
+import os
+from types import LambdaType
+import pytest
+
+from bumble.core import BT_BR_EDR_TRANSPORT
+from bumble.device import Connection, Device
+from bumble.host import Host
+from bumble.hci import (
+ HCI_ACCEPT_CONNECTION_REQUEST_COMMAND, HCI_COMMAND_STATUS_PENDING, HCI_CREATE_CONNECTION_COMMAND, HCI_SUCCESS,
+ Address, HCI_Command_Complete_Event, HCI_Command_Status_Event, HCI_Connection_Complete_Event, HCI_Connection_Request_Event, HCI_Packet
+)
+
+
+# -----------------------------------------------------------------------------
+# Logging
+# -----------------------------------------------------------------------------
+logger = logging.getLogger(__name__)
+
+
+# -----------------------------------------------------------------------------
+class Sink:
+ def __init__(self, flow):
+ self.flow = flow
+ next(self.flow)
+
+ def on_packet(self, packet):
+ self.flow.send(packet)
+
+
+# -----------------------------------------------------------------------------
[email protected]
+async def test_device_connect_parallel():
+ d0 = Device(host=Host(None, None))
+ d1 = Device(host=Host(None, None))
+ d2 = Device(host=Host(None, None))
+
+ # enable classic
+ d0.classic_enabled = True
+ d1.classic_enabled = True
+ d2.classic_enabled = True
+
+ # set public addresses
+ d0.public_address = Address('F0:F1:F2:F3:F4:F5', address_type=Address.PUBLIC_DEVICE_ADDRESS)
+ d1.public_address = Address('F5:F4:F3:F2:F1:F0', address_type=Address.PUBLIC_DEVICE_ADDRESS)
+ d2.public_address = Address('F5:F4:F3:F3:F4:F5', address_type=Address.PUBLIC_DEVICE_ADDRESS)
+
+ def d0_flow():
+ packet = HCI_Packet.from_bytes((yield))
+ assert packet.name == 'HCI_CREATE_CONNECTION_COMMAND'
+ assert packet.bd_addr == d1.public_address
+
+ d0.host.on_hci_packet(HCI_Command_Status_Event(
+ status = HCI_COMMAND_STATUS_PENDING,
+ num_hci_command_packets = 1,
+ command_opcode = HCI_CREATE_CONNECTION_COMMAND
+ ))
+
+ d1.host.on_hci_packet(HCI_Connection_Request_Event(
+ bd_addr = d0.public_address,
+ class_of_device = 0,
+ link_type = HCI_Connection_Complete_Event.ACL_LINK_TYPE
+ ))
+
+ packet = HCI_Packet.from_bytes((yield))
+ assert packet.name == 'HCI_CREATE_CONNECTION_COMMAND'
+ assert packet.bd_addr == d2.public_address
+
+ d0.host.on_hci_packet(HCI_Command_Status_Event(
+ status = HCI_COMMAND_STATUS_PENDING,
+ num_hci_command_packets = 1,
+ command_opcode = HCI_CREATE_CONNECTION_COMMAND
+ ))
+
+ d2.host.on_hci_packet(HCI_Connection_Request_Event(
+ bd_addr = d0.public_address,
+ class_of_device = 0,
+ link_type = HCI_Connection_Complete_Event.ACL_LINK_TYPE
+ ))
+
+ assert (yield) == None
+
+ def d1_flow():
+ packet = HCI_Packet.from_bytes((yield))
+ assert packet.name == 'HCI_ACCEPT_CONNECTION_REQUEST_COMMAND'
+
+ d1.host.on_hci_packet(HCI_Command_Complete_Event(
+ num_hci_command_packets = 1,
+ command_opcode = HCI_ACCEPT_CONNECTION_REQUEST_COMMAND,
+ return_parameters = b"\x00"
+ ))
+
+ d1.host.on_hci_packet(HCI_Connection_Complete_Event(
+ status = HCI_SUCCESS,
+ connection_handle = 0x100,
+ bd_addr = d0.public_address,
+ link_type = HCI_Connection_Complete_Event.ACL_LINK_TYPE,
+ encryption_enabled = True,
+ ))
+
+ d0.host.on_hci_packet(HCI_Connection_Complete_Event(
+ status = HCI_SUCCESS,
+ connection_handle = 0x100,
+ bd_addr = d1.public_address,
+ link_type = HCI_Connection_Complete_Event.ACL_LINK_TYPE,
+ encryption_enabled = True,
+ ))
+
+ assert (yield) == None
+
+ def d2_flow():
+ packet = HCI_Packet.from_bytes((yield))
+ assert packet.name == 'HCI_ACCEPT_CONNECTION_REQUEST_COMMAND'
+
+ d2.host.on_hci_packet(HCI_Command_Complete_Event(
+ num_hci_command_packets = 1,
+ command_opcode = HCI_ACCEPT_CONNECTION_REQUEST_COMMAND,
+ return_parameters = b"\x00"
+ ))
+
+ d2.host.on_hci_packet(HCI_Connection_Complete_Event(
+ status = HCI_SUCCESS,
+ connection_handle = 0x101,
+ bd_addr = d0.public_address,
+ link_type = HCI_Connection_Complete_Event.ACL_LINK_TYPE,
+ encryption_enabled = True,
+ ))
+
+ d0.host.on_hci_packet(HCI_Connection_Complete_Event(
+ status = HCI_SUCCESS,
+ connection_handle = 0x101,
+ bd_addr = d2.public_address,
+ link_type = HCI_Connection_Complete_Event.ACL_LINK_TYPE,
+ encryption_enabled = True,
+ ))
+
+ assert (yield) == None
+
+ d0.host.set_packet_sink(Sink(d0_flow()))
+ d1.host.set_packet_sink(Sink(d1_flow()))
+ d2.host.set_packet_sink(Sink(d2_flow()))
+
+ [c01, c02, a10, a20, a01] = await asyncio.gather(*[
+ asyncio.create_task(d0.connect(d1.public_address, transport=BT_BR_EDR_TRANSPORT)),
+ asyncio.create_task(d0.connect(d2.public_address, transport=BT_BR_EDR_TRANSPORT)),
+ asyncio.create_task(d1.accept(peer_address=d0.public_address)),
+ asyncio.create_task(d2.accept()),
+ asyncio.create_task(d0.accept(peer_address=d1.public_address)),
+ ])
+
+ assert type(c01) == Connection
+ assert type(c02) == Connection
+ assert type(a10) == Connection
+ assert type(a20) == Connection
+ assert type(a01) == Connection
+
+ assert c01.handle == a10.handle and c01.handle == 0x100
+ assert c02.handle == a20.handle and c02.handle == 0x101
+ assert a01 == c01
+
+
+# -----------------------------------------------------------------------------
+async def run_test_device():
+ await test_device_connect_parallel()
+
+
+# -----------------------------------------------------------------------------
+if __name__ == '__main__':
+ logging.basicConfig(level = os.environ.get('BUMBLE_LOGLEVEL', 'INFO').upper())
+ asyncio.run(run_test_device())
diff --git a/tests/gatt_test.py b/tests/gatt_test.py
index 4bfcce8..927aeee 100644
--- a/tests/gatt_test.py
+++ b/tests/gatt_test.py
@@ -22,6 +22,7 @@
import pytest
from bumble.controller import Controller
+from bumble.gatt_client import CharacteristicProxy
from bumble.link import LocalLink
from bumble.device import Device, Peer
from bumble.host import Host
@@ -53,29 +54,29 @@
parsed = ATT_PDU.from_bytes(pdu)
x_str = str(x)
parsed_str = str(parsed)
- assert(x_str == parsed_str)
+ assert x_str == parsed_str
# -----------------------------------------------------------------------------
def test_UUID():
u = UUID.from_16_bits(0x7788)
- assert(str(u) == 'UUID-16:7788')
+ assert str(u) == 'UUID-16:7788'
u = UUID.from_32_bits(0x11223344)
- assert(str(u) == 'UUID-32:11223344')
+ assert str(u) == 'UUID-32:11223344'
u = UUID('61A3512C-09BE-4DDC-A6A6-0B03667AAFC6')
- assert(str(u) == '61A3512C-09BE-4DDC-A6A6-0B03667AAFC6')
+ assert str(u) == '61A3512C-09BE-4DDC-A6A6-0B03667AAFC6'
v = UUID(str(u))
- assert(str(v) == '61A3512C-09BE-4DDC-A6A6-0B03667AAFC6')
+ assert str(v) == '61A3512C-09BE-4DDC-A6A6-0B03667AAFC6'
w = UUID.from_bytes(v.to_bytes())
- assert(str(w) == '61A3512C-09BE-4DDC-A6A6-0B03667AAFC6')
+ assert str(w) == '61A3512C-09BE-4DDC-A6A6-0B03667AAFC6'
u1 = UUID.from_16_bits(0x1234)
b1 = u1.to_bytes(force_128 = True)
u2 = UUID.from_bytes(b1)
- assert(u1 == u2)
+ assert u1 == u2
u3 = UUID.from_16_bits(0x180a)
- assert(str(u3) == 'UUID-16:180A (Device Information)')
+ assert str(u3) == 'UUID-16:180A (Device Information)'
# -----------------------------------------------------------------------------
@@ -99,6 +100,133 @@
# -----------------------------------------------------------------------------
[email protected]
+async def test_characteristic_encoding():
+ class Foo(Characteristic):
+ def encode_value(self, value):
+ return bytes([value])
+
+ def decode_value(self, value_bytes):
+ return value_bytes[0]
+
+ c = Foo(GATT_BATTERY_LEVEL_CHARACTERISTIC, Characteristic.READ, Characteristic.READABLE, 123)
+ x = c.read_value(None)
+ assert x == bytes([123])
+ c.write_value(None, bytes([122]))
+ assert c.value == 122
+
+ class FooProxy(CharacteristicProxy):
+ def __init__(self, characteristic):
+ super().__init__(
+ characteristic.client,
+ characteristic.handle,
+ characteristic.end_group_handle,
+ characteristic.uuid,
+ characteristic.properties
+ )
+
+ def encode_value(self, value):
+ return bytes([value])
+
+ def decode_value(self, value_bytes):
+ return value_bytes[0]
+
+ [client, server] = LinkedDevices().devices[:2]
+
+ characteristic = Characteristic(
+ 'FDB159DB-036C-49E3-B3DB-6325AC750806',
+ Characteristic.READ | Characteristic.WRITE | Characteristic.NOTIFY,
+ Characteristic.READABLE | Characteristic.WRITEABLE,
+ bytes([123])
+ )
+
+ service = Service(
+ '3A657F47-D34F-46B3-B1EC-698E29B6B829',
+ [characteristic]
+ )
+ server.add_service(service)
+
+ await client.power_on()
+ await server.power_on()
+ connection = await client.connect(server.random_address)
+ peer = Peer(connection)
+
+ await peer.discover_services()
+ await peer.discover_characteristics()
+ c = peer.get_characteristics_by_uuid(characteristic.uuid)
+ assert len(c) == 1
+ c = c[0]
+ cp = FooProxy(c)
+
+ v = await cp.read_value()
+ assert v == 123
+ await cp.write_value(124)
+ await async_barrier()
+ assert characteristic.value == bytes([124])
+
+ v = await cp.read_value()
+ assert v == 124
+ await cp.write_value(125, with_response=True)
+ await async_barrier()
+ assert characteristic.value == bytes([125])
+
+ cd = DelegatedCharacteristicAdapter(c, encode=lambda x: bytes([x // 2]))
+ await cd.write_value(100, with_response=True)
+ await async_barrier()
+ assert characteristic.value == bytes([50])
+
+ last_change = None
+
+ def on_change(value):
+ nonlocal last_change
+ last_change = value
+
+ await c.subscribe(on_change)
+ await server.notify_subscribers(characteristic)
+ await async_barrier()
+ assert last_change == characteristic.value
+ last_change = None
+
+ await server.notify_subscribers(characteristic, value=bytes([125]))
+ await async_barrier()
+ assert last_change == bytes([125])
+ last_change = None
+
+ await c.unsubscribe(on_change)
+ await server.notify_subscribers(characteristic)
+ await async_barrier()
+ assert last_change is None
+
+ await cp.subscribe(on_change)
+ await server.notify_subscribers(characteristic)
+ await async_barrier()
+ assert last_change == characteristic.value[0]
+ last_change = None
+
+ await server.notify_subscribers(characteristic, value=bytes([126]))
+ await async_barrier()
+ assert last_change == 126
+ last_change = None
+
+ await cp.unsubscribe(on_change)
+ await server.notify_subscribers(characteristic)
+ await async_barrier()
+ assert last_change is None
+
+ cd = DelegatedCharacteristicAdapter(c, decode=lambda x: x[0])
+ await cd.subscribe(on_change)
+ await server.notify_subscribers(characteristic)
+ await async_barrier()
+ assert last_change == characteristic.value[0]
+ last_change = None
+
+ await cd.unsubscribe(on_change)
+ await server.notify_subscribers(characteristic)
+ await async_barrier()
+ assert last_change is None
+
+
+# -----------------------------------------------------------------------------
def test_CharacteristicAdapter():
# Check that the CharacteristicAdapter base class is transparent
v = bytes([1, 2, 3])
@@ -106,21 +234,21 @@
a = CharacteristicAdapter(c)
value = a.read_value(None)
- assert(value == v)
+ assert value == v
v = bytes([3, 4, 5])
a.write_value(None, v)
- assert(c.value == v)
+ assert c.value == v
# Simple delegated adapter
a = DelegatedCharacteristicAdapter(c, lambda x: bytes(reversed(x)), lambda x: bytes(reversed(x)))
value = a.read_value(None)
- assert(value == bytes(reversed(v)))
+ assert value == bytes(reversed(v))
v = bytes([3, 4, 5])
a.write_value(None, v)
- assert(a.value == bytes(reversed(v)))
+ assert a.value == bytes(reversed(v))
# Packed adapter with single element format
v = 1234
@@ -129,10 +257,10 @@
a = PackedCharacteristicAdapter(c, '>H')
value = a.read_value(None)
- assert(value == pv)
+ assert value == pv
c.value = None
a.write_value(None, pv)
- assert(a.value == v)
+ assert a.value == v
# Packed adapter with multi-element format
v1 = 1234
@@ -142,10 +270,10 @@
a = PackedCharacteristicAdapter(c, '>HH')
value = a.read_value(None)
- assert(value == pv)
+ assert value == pv
c.value = None
a.write_value(None, pv)
- assert(a.value == (v1, v2))
+ assert a.value == (v1, v2)
# Mapped adapter
v1 = 1234
@@ -156,10 +284,10 @@
a = MappedCharacteristicAdapter(c, '>HH', ('v1', 'v2'))
value = a.read_value(None)
- assert(value == pv)
+ assert value == pv
c.value = None
a.write_value(None, pv)
- assert(a.value == mapped)
+ assert a.value == mapped
# UTF-8 adapter
v = 'Hello π'
@@ -168,10 +296,10 @@
a = UTF8CharacteristicAdapter(c)
value = a.read_value(None)
- assert(value == ev)
+ assert value == ev
c.value = None
a.write_value(None, ev)
- assert(a.value == v)
+ assert a.value == v
# -----------------------------------------------------------------------------
@@ -179,24 +307,25 @@
b = bytes([1, 2, 3])
c = CharacteristicValue(read=lambda _: b)
x = c.read(None)
- assert(x == b)
+ assert x == b
result = []
c = CharacteristicValue(write=lambda connection, value: result.append((connection, value)))
z = object()
c.write(z, b)
- assert(result == [(z, b)])
+ assert result == [(z, b)]
# -----------------------------------------------------------------------------
-class TwoDevices:
+class LinkedDevices:
def __init__(self):
- self.connections = [None, None]
+ self.connections = [None, None, None]
self.link = LocalLink()
self.controllers = [
Controller('C1', link = self.link),
- Controller('C2', link = self.link)
+ Controller('C2', link = self.link),
+ Controller('C3', link = self.link)
]
self.devices = [
Device(
@@ -204,12 +333,16 @@
host = Host(self.controllers[0], AsyncPipeSink(self.controllers[0]))
),
Device(
- address = 'F5:F4:F3:F2:F1:F0',
+ address = 'F1:F2:F3:F4:F5:F6',
host = Host(self.controllers[1], AsyncPipeSink(self.controllers[1]))
+ ),
+ Device(
+ address = 'F2:F3:F4:F5:F6:F7',
+ host = Host(self.controllers[2], AsyncPipeSink(self.controllers[2]))
)
]
- self.paired = [None, None]
+ self.paired = [None, None, None]
# -----------------------------------------------------------------------------
@@ -222,7 +355,7 @@
# -----------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_read_write():
- [client, server] = TwoDevices().devices
+ [client, server] = LinkedDevices().devices[:2]
characteristic1 = Characteristic(
'FDB159DB-036C-49E3-B3DB-6325AC750806',
@@ -265,41 +398,41 @@
await peer.discover_services()
await peer.discover_characteristics()
c = peer.get_characteristics_by_uuid(characteristic1.uuid)
- assert(len(c) == 1)
+ assert len(c) == 1
c1 = c[0]
c = peer.get_characteristics_by_uuid(characteristic2.uuid)
- assert(len(c) == 1)
+ assert len(c) == 1
c2 = c[0]
v1 = await peer.read_value(c1)
- assert(v1 == b'')
+ assert v1 == b''
b = bytes([1, 2, 3])
await peer.write_value(c1, b)
await async_barrier()
- assert(characteristic1.value == b)
+ assert characteristic1.value == b
v1 = await peer.read_value(c1)
- assert(v1 == b)
- assert(type(characteristic1._last_value) is tuple)
- assert(len(characteristic1._last_value) == 2)
- assert(str(characteristic1._last_value[0].peer_address) == str(client.random_address))
- assert(characteristic1._last_value[1] == b)
+ assert v1 == b
+ assert type(characteristic1._last_value is tuple)
+ assert len(characteristic1._last_value) == 2
+ assert str(characteristic1._last_value[0].peer_address) == str(client.random_address)
+ assert characteristic1._last_value[1] == b
bb = bytes([3, 4, 5, 6])
characteristic1.value = bb
v1 = await peer.read_value(c1)
- assert(v1 == bb)
+ assert v1 == bb
await peer.write_value(c2, b)
await async_barrier()
- assert(type(characteristic2._last_value) is tuple)
- assert(len(characteristic2._last_value) == 2)
- assert(str(characteristic2._last_value[0].peer_address) == str(client.random_address))
- assert(characteristic2._last_value[1] == b)
+ assert type(characteristic2._last_value is tuple)
+ assert len(characteristic2._last_value) == 2
+ assert str(characteristic2._last_value[0].peer_address) == str(client.random_address)
+ assert characteristic2._last_value[1] == b
# -----------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_read_write2():
- [client, server] = TwoDevices().devices
+ [client, server] = LinkedDevices().devices[:2]
v = bytes([0x11, 0x22, 0x33, 0x44])
characteristic1 = Characteristic(
@@ -324,32 +457,32 @@
await peer.discover_services()
c = peer.get_services_by_uuid(service1.uuid)
- assert(len(c) == 1)
+ assert len(c) == 1
s = c[0]
await s.discover_characteristics()
c = s.get_characteristics_by_uuid(characteristic1.uuid)
- assert(len(c) == 1)
+ assert len(c) == 1
c1 = c[0]
v1 = await c1.read_value()
- assert(v1 == v)
+ assert v1 == v
a1 = PackedCharacteristicAdapter(c1, '>I')
v1 = await a1.read_value()
- assert(v1 == struct.unpack('>I', v)[0])
+ assert v1 == struct.unpack('>I', v)[0]
b = bytes([0x55, 0x66, 0x77, 0x88])
await a1.write_value(struct.unpack('>I', b)[0])
await async_barrier()
- assert(characteristic1.value == b)
+ assert characteristic1.value == b
v1 = await a1.read_value()
- assert(v1 == struct.unpack('>I', b)[0])
+ assert v1 == struct.unpack('>I', b)[0]
# -----------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_subscribe_notify():
- [client, server] = TwoDevices().devices
+ [client, server] = LinkedDevices().devices[:2]
characteristic1 = Characteristic(
'FDB159DB-036C-49E3-B3DB-6325AC750806',
@@ -410,13 +543,13 @@
await peer.discover_services()
await peer.discover_characteristics()
c = peer.get_characteristics_by_uuid(characteristic1.uuid)
- assert(len(c) == 1)
+ assert len(c) == 1
c1 = c[0]
c = peer.get_characteristics_by_uuid(characteristic2.uuid)
- assert(len(c) == 1)
+ assert len(c) == 1
c2 = c[0]
c = peer.get_characteristics_by_uuid(characteristic3.uuid)
- assert(len(c) == 1)
+ assert len(c) == 1
c3 = c[0]
c1._called = False
@@ -429,23 +562,32 @@
c1.on('update', on_c1_update)
await peer.subscribe(c1)
await async_barrier()
- assert(server._last_subscription[1] == characteristic1)
- assert(server._last_subscription[2])
- assert(not server._last_subscription[3])
- assert(characteristic1._last_subscription[1])
- assert(not characteristic1._last_subscription[2])
+ assert server._last_subscription[1] == characteristic1
+ assert server._last_subscription[2]
+ assert not server._last_subscription[3]
+ assert characteristic1._last_subscription[1]
+ assert not characteristic1._last_subscription[2]
await server.indicate_subscribers(characteristic1)
await async_barrier()
- assert(not c1._called)
+ assert not c1._called
await server.notify_subscribers(characteristic1)
await async_barrier()
- assert(c1._called)
- assert(c1._last_update == characteristic1.value)
+ assert c1._called
+ assert c1._last_update == characteristic1.value
+
+ c1._called = False
+ c1._last_update = None
+ c1_value = characteristic1.value
+ await server.notify_subscribers(characteristic1, bytes([0, 1, 2]))
+ await async_barrier()
+ assert c1._called
+ assert c1._last_update == bytes([0, 1, 2])
+ assert characteristic1.value == c1_value
c1._called = False
await peer.unsubscribe(c1)
await server.notify_subscribers(characteristic1)
- assert(not c1._called)
+ assert not c1._called
c2._called = False
c2._last_update = None
@@ -458,51 +600,109 @@
await async_barrier()
await server.notify_subscriber(characteristic2._last_subscription[0], characteristic2)
await async_barrier()
- assert(not c2._called)
+ assert not c2._called
await server.indicate_subscriber(characteristic2._last_subscription[0], characteristic2)
await async_barrier()
- assert(c2._called)
- assert(c2._last_update == characteristic2.value)
+ assert c2._called
+ assert c2._last_update == characteristic2.value
c2._called = False
await peer.unsubscribe(c2, on_c2_update)
await server.indicate_subscriber(characteristic2._last_subscription[0], characteristic2)
await async_barrier()
- assert(not c2._called)
+ assert not c2._called
+
+ c3._called = False
+ c3._called_2 = False
+ c3._called_3 = False
+ c3._last_update = None
+ c3._last_update_2 = None
+ c3._last_update_3 = None
def on_c3_update(value):
c3._called = True
c3._last_update = value
- def on_c3_update_2(value):
+ def on_c3_update_2(value): # for notify
c3._called_2 = True
c3._last_update_2 = value
+ def on_c3_update_3(value): # for indicate
+ c3._called_3 = True
+ c3._last_update_3 = value
+
c3.on('update', on_c3_update)
await peer.subscribe(c3, on_c3_update_2)
await async_barrier()
await server.notify_subscriber(characteristic3._last_subscription[0], characteristic3)
await async_barrier()
- assert(c3._called)
- assert(c3._last_update == characteristic3.value)
- assert(c3._called_2)
- assert(c3._last_update_2 == characteristic3.value)
- characteristic3.value = bytes([1, 2, 3])
- await server.indicate_subscriber(characteristic3._last_subscription[0], characteristic3)
- await async_barrier()
- assert(c3._called)
- assert(c3._last_update == characteristic3.value)
- assert(c3._called_2)
- assert(c3._last_update_2 == characteristic3.value)
+ assert c3._called
+ assert c3._last_update == characteristic3.value
+ assert c3._called_2
+ assert c3._last_update_2 == characteristic3.value
+ assert not c3._called_3
c3._called = False
c3._called_2 = False
+ c3._called_3 = False
+ await peer.unsubscribe(c3)
+ await peer.subscribe(c3, on_c3_update_3, prefer_notify=False)
+ await async_barrier()
+ characteristic3.value = bytes([1, 2, 3])
+ await server.indicate_subscriber(characteristic3._last_subscription[0], characteristic3)
+ await async_barrier()
+ assert c3._called
+ assert c3._last_update == characteristic3.value
+ assert not c3._called_2
+ assert c3._called_3
+ assert c3._last_update_3 == characteristic3.value
+
+ c3._called = False
+ c3._called_2 = False
+ c3._called_3 = False
await peer.unsubscribe(c3)
await server.notify_subscriber(characteristic3._last_subscription[0], characteristic3)
await server.indicate_subscriber(characteristic3._last_subscription[0], characteristic3)
await async_barrier()
- assert(not c3._called)
- assert(not c3._called_2)
+ assert not c3._called
+ assert not c3._called_2
+ assert not c3._called_3
+
+
+# -----------------------------------------------------------------------------
[email protected]
+async def test_mtu_exchange():
+ [d1, d2, d3] = LinkedDevices().devices[:3]
+
+ d3.gatt_server.max_mtu = 100
+
+ d3_connections = []
+ @d3.on('connection')
+ def on_d3_connection(connection):
+ d3_connections.append(connection)
+
+ await d1.power_on()
+ await d2.power_on()
+ await d3.power_on()
+
+ d1_connection = await d1.connect(d3.random_address)
+ assert len(d3_connections) == 1
+ assert d3_connections[0] is not None
+
+ d2_connection = await d2.connect(d3.random_address)
+ assert len(d3_connections) == 2
+ assert d3_connections[1] is not None
+
+ d1_peer = Peer(d1_connection)
+ d2_peer = Peer(d2_connection)
+
+ d1_client_mtu = await d1_peer.request_mtu(220)
+ assert d1_client_mtu == 100
+ assert d1_connection.att_mtu == 100
+
+ d2_client_mtu = await d2_peer.request_mtu(50)
+ assert d2_client_mtu == 50
+ assert d2_connection.att_mtu == 50
# -----------------------------------------------------------------------------
@@ -510,6 +710,9 @@
await test_read_write()
await test_read_write2()
await test_subscribe_notify()
+ await test_characteristic_encoding()
+ await test_mtu_exchange()
+
# -----------------------------------------------------------------------------
if __name__ == '__main__':
diff --git a/tests/hci_test.py b/tests/hci_test.py
index 6331847..a5e3a82 100644
--- a/tests/hci_test.py
+++ b/tests/hci_test.py
@@ -27,8 +27,8 @@
parsed_str = str(parsed)
print(x_str)
parsed_bytes = parsed.to_bytes()
- assert(x_str == parsed_str)
- assert(packet == parsed_bytes)
+ assert x_str == parsed_str
+ assert packet == parsed_bytes
# -----------------------------------------------------------------------------
@@ -49,19 +49,19 @@
role = 1,
peer_address_type = 1,
peer_address = address,
- conn_interval = 3,
- conn_latency = 4,
+ connection_interval = 3,
+ peripheral_latency = 4,
supervision_timeout = 5,
- master_clock_accuracy = 6
+ central_clock_accuracy = 6
)
basic_check(event)
# -----------------------------------------------------------------------------
def test_HCI_LE_Advertising_Report_Event():
- address = Address('00:11:22:33:44:55')
- report = HCI_Object(
- HCI_LE_Advertising_Report_Event.REPORT_FIELDS,
+ address = Address('00:11:22:33:44:55/P')
+ report = HCI_LE_Advertising_Report_Event.Report(
+ HCI_LE_Advertising_Report_Event.Report.FIELDS,
event_type = HCI_LE_Advertising_Report_Event.ADV_IND,
address_type = Address.PUBLIC_DEVICE_ADDRESS,
address = address,
@@ -87,8 +87,8 @@
event = HCI_LE_Connection_Update_Complete_Event(
status = HCI_SUCCESS,
connection_handle = 0x007,
- conn_interval = 10,
- conn_latency = 3,
+ connection_interval = 10,
+ peripheral_latency = 3,
supervision_timeout = 5
)
basic_check(event)
@@ -133,7 +133,7 @@
)
basic_check(event)
event = HCI_Packet.from_bytes(event.to_bytes())
- assert(event.return_parameters == 7)
+ assert event.return_parameters == 7
# With a simple status as an integer status
event = HCI_Command_Complete_Event(
@@ -142,7 +142,7 @@
return_parameters = 9
)
basic_check(event)
- assert(event.return_parameters == 9)
+ assert event.return_parameters == 9
# -----------------------------------------------------------------------------
@@ -283,12 +283,32 @@
peer_address_type = 1,
peer_address = Address('00:11:22:33:44:55'),
own_address_type = 2,
- conn_interval_min = 7,
- conn_interval_max = 8,
- conn_latency = 9,
+ connection_interval_min = 7,
+ connection_interval_max = 8,
+ max_latency = 9,
supervision_timeout = 10,
- minimum_ce_length = 11,
- maximum_ce_length = 12
+ min_ce_length = 11,
+ max_ce_length = 12
+ )
+ basic_check(command)
+
+
+# -----------------------------------------------------------------------------
+def test_HCI_LE_Extended_Create_Connection_Command():
+ command = HCI_LE_Extended_Create_Connection_Command(
+ initiator_filter_policy = 0,
+ own_address_type = 0,
+ peer_address_type = 1,
+ peer_address = Address('00:11:22:33:44:55'),
+ initiating_phys = 3,
+ scan_intervals = (10, 11),
+ scan_windows = (12, 13),
+ connection_interval_mins = (14, 15),
+ connection_interval_maxs = (16, 17),
+ max_latencies = (18, 19),
+ supervision_timeouts = (20, 21),
+ min_ce_lengths = (100, 101),
+ max_ce_lengths = (102, 103)
)
basic_check(command)
@@ -314,13 +334,13 @@
# -----------------------------------------------------------------------------
def test_HCI_LE_Connection_Update_Command():
command = HCI_LE_Connection_Update_Command(
- connection_handle = 0x0002,
- conn_interval_min = 10,
- conn_interval_max = 20,
- conn_latency = 7,
- supervision_timeout = 3,
- minimum_ce_length = 100,
- maximum_ce_length = 200
+ connection_handle = 0x0002,
+ connection_interval_min = 10,
+ connection_interval_max = 20,
+ max_latency = 7,
+ supervision_timeout = 3,
+ min_ce_length = 100,
+ max_ce_length = 200
)
basic_check(command)
@@ -348,7 +368,7 @@
command = HCI_LE_Set_Extended_Scan_Parameters_Command(
own_address_type=Address.RANDOM_DEVICE_ADDRESS,
scanning_filter_policy=HCI_LE_Set_Extended_Scan_Parameters_Command.BASIC_FILTERED_POLICY,
- scanning_phys=(1 << HCI_LE_Set_Extended_Scan_Parameters_Command.LE_1M_PHY | 1 << HCI_LE_Set_Extended_Scan_Parameters_Command.LE_CODED_PHY | 1 << 4),
+ scanning_phys=(1 << HCI_LE_1M_PHY_BIT | 1 << HCI_LE_CODED_PHY_BIT | 1 << 4),
scan_types=[
HCI_LE_Set_Extended_Scan_Parameters_Command.ACTIVE_SCANNING,
HCI_LE_Set_Extended_Scan_Parameters_Command.ACTIVE_SCANNING,
@@ -363,20 +383,20 @@
# -----------------------------------------------------------------------------
def test_address():
a = Address('C4:F2:17:1A:1D:BB')
- assert(not a.is_public)
- assert(a.is_random)
- assert(a.address_type == Address.RANDOM_DEVICE_ADDRESS)
- assert(not a.is_resolvable)
- assert(not a.is_resolved)
- assert(a.is_static)
+ assert not a.is_public
+ assert a.is_random
+ assert a.address_type == Address.RANDOM_DEVICE_ADDRESS
+ assert not a.is_resolvable
+ assert not a.is_resolved
+ assert a.is_static
# -----------------------------------------------------------------------------
def test_custom():
data = bytes([0x77, 0x02, 0x01, 0x03])
packet = HCI_CustomPacket(data)
- assert(packet.hci_packet_type == 0x77)
- assert(packet.payload == data)
+ assert packet.hci_packet_type == 0x77
+ assert packet.payload == data
# -----------------------------------------------------------------------------
@@ -408,6 +428,7 @@
test_HCI_LE_Set_Scan_Parameters_Command()
test_HCI_LE_Set_Scan_Enable_Command()
test_HCI_LE_Create_Connection_Command()
+ test_HCI_LE_Extended_Create_Connection_Command()
test_HCI_LE_Add_Device_To_Filter_Accept_List_Command()
test_HCI_LE_Remove_Device_From_Filter_Accept_List_Command()
test_HCI_LE_Connection_Update_Command()
diff --git a/tests/import_test.py b/tests/import_test.py
index d6eafbd..4a83cea 100644
--- a/tests/import_test.py
+++ b/tests/import_test.py
@@ -63,5 +63,56 @@
# -----------------------------------------------------------------------------
+def test_app_imports():
+ from apps.console import main
+ assert main
+
+ from apps.controller_info import main
+ assert main
+
+ from apps.controllers import main
+ assert main
+
+ from apps.gatt_dump import main
+ assert main
+
+ from apps.gg_bridge import main
+ assert main
+
+ from apps.hci_bridge import main
+ assert main
+
+ from apps.pair import main
+ assert main
+
+ from apps.scan import main
+ assert main
+
+ from apps.show import main
+ assert main
+
+ from apps.unbond import main
+ assert main
+
+ from apps.usb_probe import main
+ assert main
+
+
+# -----------------------------------------------------------------------------
+def test_profiles_imports():
+ from bumble.profiles import (
+ battery_service,
+ device_information_service,
+ heart_rate_service
+ )
+
+ assert battery_service
+ assert device_information_service
+ assert heart_rate_service
+
+
+# -----------------------------------------------------------------------------
if __name__ == '__main__':
test_import()
+ test_app_imports()
+ test_profiles_imports()
diff --git a/tests/l2cap_test.py b/tests/l2cap_test.py
new file mode 100644
index 0000000..82d5386
--- /dev/null
+++ b/tests/l2cap_test.py
@@ -0,0 +1,284 @@
+# Copyright 2021-2022 Google LLC
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# https://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+# -----------------------------------------------------------------------------
+# Imports
+# -----------------------------------------------------------------------------
+import asyncio
+import logging
+import os
+import random
+import pytest
+
+from bumble.controller import Controller
+from bumble.link import LocalLink
+from bumble.device import Device
+from bumble.host import Host
+from bumble.transport import AsyncPipeSink
+from bumble.core import ProtocolError
+from bumble.l2cap import (
+ L2CAP_Connection_Request
+)
+
+
+# -----------------------------------------------------------------------------
+# Logging
+# -----------------------------------------------------------------------------
+logger = logging.getLogger(__name__)
+
+
+# -----------------------------------------------------------------------------
+class TwoDevices:
+ def __init__(self):
+ self.connections = [None, None]
+
+ self.link = LocalLink()
+ self.controllers = [
+ Controller('C1', link = self.link),
+ Controller('C2', link = self.link)
+ ]
+ self.devices = [
+ Device(
+ address = 'F0:F1:F2:F3:F4:F5',
+ host = Host(self.controllers[0], AsyncPipeSink(self.controllers[0]))
+ ),
+ Device(
+ address = 'F5:F4:F3:F2:F1:F0',
+ host = Host(self.controllers[1], AsyncPipeSink(self.controllers[1]))
+ )
+ ]
+
+ self.paired = [None, None]
+
+ def on_connection(self, which, connection):
+ self.connections[which] = connection
+
+ def on_paired(self, which, keys):
+ self.paired[which] = keys
+
+
+# -----------------------------------------------------------------------------
+async def setup_connection():
+ # Create two devices, each with a controller, attached to the same link
+ two_devices = TwoDevices()
+
+ # Attach listeners
+ two_devices.devices[0].on('connection', lambda connection: two_devices.on_connection(0, connection))
+ two_devices.devices[1].on('connection', lambda connection: two_devices.on_connection(1, connection))
+
+ # Start
+ await two_devices.devices[0].power_on()
+ await two_devices.devices[1].power_on()
+
+ # Connect the two devices
+ await two_devices.devices[0].connect(two_devices.devices[1].random_address)
+
+ # Check the post conditions
+ assert two_devices.connections[0] is not None
+ assert two_devices.connections[1] is not None
+
+ return two_devices
+
+
+# -----------------------------------------------------------------------------
+def test_helpers():
+ psm = L2CAP_Connection_Request.serialize_psm(0x01)
+ assert psm == bytes([0x01, 0x00])
+
+ psm = L2CAP_Connection_Request.serialize_psm(0x1023)
+ assert psm == bytes([0x23, 0x10])
+
+ psm = L2CAP_Connection_Request.serialize_psm(0x242311)
+ assert psm == bytes([0x11, 0x23, 0x24])
+
+ (offset, psm) = L2CAP_Connection_Request.parse_psm(bytes([0x00, 0x01, 0x00, 0x44]), 1)
+ assert offset == 3
+ assert psm == 0x01
+
+ (offset, psm) = L2CAP_Connection_Request.parse_psm(bytes([0x00, 0x23, 0x10, 0x44]), 1)
+ assert offset == 3
+ assert psm == 0x1023
+
+ (offset, psm) = L2CAP_Connection_Request.parse_psm(bytes([0x00, 0x11, 0x23, 0x24, 0x44]), 1)
+ assert offset == 4
+ assert psm == 0x242311
+
+ rq = L2CAP_Connection_Request(psm = 0x01, source_cid = 0x44)
+ brq = bytes(rq)
+ srq = L2CAP_Connection_Request.from_bytes(brq)
+ assert srq.psm == rq.psm
+ assert srq.source_cid == rq.source_cid
+
+
+# -----------------------------------------------------------------------------
[email protected]
+async def test_basic_connection():
+ devices = await setup_connection()
+ psm = 1234
+
+ # Check that if there's no one listening, we can't connect
+ with pytest.raises(ProtocolError):
+ l2cap_channel = await devices.connections[0].open_l2cap_channel(psm)
+
+ # Now add a listener
+ incoming_channel = None
+ received = []
+
+ def on_coc(channel):
+ nonlocal incoming_channel
+ incoming_channel = channel
+
+ def on_data(data):
+ received.append(data)
+
+ channel.sink = on_data
+
+ devices.devices[1].register_l2cap_channel_server(psm, on_coc)
+ l2cap_channel = await devices.connections[0].open_l2cap_channel(psm)
+
+ messages = (
+ bytes([1, 2, 3]),
+ bytes([4, 5, 6]),
+ bytes(10000)
+ )
+ for message in messages:
+ l2cap_channel.write(message)
+ await asyncio.sleep(0)
+
+ await l2cap_channel.drain()
+
+ # Test closing
+ closed = [False, False]
+ closed_event = asyncio.Event()
+
+ def on_close(which, event):
+ closed[which] = True
+ if event:
+ event.set()
+
+ l2cap_channel.on('close', lambda: on_close(0, None))
+ incoming_channel.on('close', lambda: on_close(1, closed_event))
+ await l2cap_channel.disconnect()
+ assert closed == [True, True]
+ await closed_event.wait()
+
+ sent_bytes = b''.join(messages)
+ received_bytes = b''.join(received)
+ assert sent_bytes == received_bytes
+
+
+# -----------------------------------------------------------------------------
+async def transfer_payload(max_credits, mtu, mps):
+ devices = await setup_connection()
+
+ received = []
+
+ def on_coc(channel):
+ def on_data(data):
+ received.append(data)
+
+ channel.sink = on_data
+
+ psm = devices.devices[1].register_l2cap_channel_server(
+ psm = 0,
+ server = on_coc,
+ max_credits = max_credits,
+ mtu = mtu,
+ mps = mps
+ )
+ l2cap_channel = await devices.connections[0].open_l2cap_channel(psm)
+
+ messages = [
+ bytes([1, 2, 3, 4, 5, 6, 7]) * x
+ for x in (3, 10, 100, 789)
+ ]
+ for message in messages:
+ l2cap_channel.write(message)
+ await asyncio.sleep(0)
+ if random.randint(0, 5) == 1:
+ await l2cap_channel.drain()
+
+ await l2cap_channel.drain()
+ await l2cap_channel.disconnect()
+
+ sent_bytes = b''.join(messages)
+ received_bytes = b''.join(received)
+ assert sent_bytes == received_bytes
+
+
[email protected]
+async def test_transfer():
+ for max_credits in (1, 10, 100, 10000):
+ for mtu in (50, 255, 256, 1000):
+ for mps in (50, 255, 256, 1000):
+ # print(max_credits, mtu, mps)
+ await transfer_payload(max_credits, mtu, mps)
+
+
+# -----------------------------------------------------------------------------
[email protected]
+async def test_bidirectional_transfer():
+ devices = await setup_connection()
+
+ client_received = []
+ server_received = []
+ server_channel = None
+
+ def on_server_coc(channel):
+ nonlocal server_channel
+ server_channel = channel
+
+ def on_server_data(data):
+ server_received.append(data)
+
+ channel.sink = on_server_data
+
+ def on_client_data(data):
+ client_received.append(data)
+
+ psm = devices.devices[1].register_l2cap_channel_server(psm=0, server=on_server_coc)
+ client_channel = await devices.connections[0].open_l2cap_channel(psm)
+ client_channel.sink = on_client_data
+
+ messages = [
+ bytes([1, 2, 3, 4, 5, 6, 7]) * x
+ for x in (3, 10, 100)
+ ]
+ for message in messages:
+ client_channel.write(message)
+ await client_channel.drain()
+ await asyncio.sleep(0)
+ server_channel.write(message)
+ await server_channel.drain()
+
+ await client_channel.disconnect()
+
+ message_bytes = b''.join(messages)
+ client_received_bytes = b''.join(client_received)
+ server_received_bytes = b''.join(server_received)
+ assert client_received_bytes == message_bytes
+ assert server_received_bytes == message_bytes
+
+
+# -----------------------------------------------------------------------------
+async def run():
+ test_helpers()
+ await test_basic_connection()
+ await test_transfer()
+ await test_bidirectional_transfer()
+
+# -----------------------------------------------------------------------------
+if __name__ == '__main__':
+ logging.basicConfig(level = os.environ.get('BUMBLE_LOGLEVEL', 'INFO').upper())
+ asyncio.run(run())
diff --git a/web/scanner.py b/web/scanner.py
index 9ab9f47..e734dbf 100644
--- a/web/scanner.py
+++ b/web/scanner.py
@@ -21,9 +21,9 @@
# -----------------------------------------------------------------------------
class ScannerListener(Device.Listener):
- def on_advertisement(self, address, ad_data, rssi, connectable):
- address_type_string = ('P', 'R', 'PI', 'RI')[address.address_type]
- print(f'>>> {address} [{address_type_string}]: RSSI={rssi}, {ad_data}')
+ def on_advertisement(self, advertisement):
+ address_type_string = ('P', 'R', 'PI', 'RI')[advertisement.address.address_type]
+ print(f'>>> {advertisement.address} [{address_type_string}]: RSSI={advertisement.rssi}, {advertisement.ad_data}')
class HciSource: