Compare commits

...

25 Commits

Author SHA1 Message Date
Gilles Boccon-Gibod
a9e726545e wip 2022-09-20 14:58:20 -07:00
Gilles Boccon-Gibod
d188041694 Merge pull request #35 from zxzxwu/ctkd
Support CTKD over BR/EDR
2022-08-30 06:19:57 -07:00
Josh Wu
99cba19d7c Support CTKD over BR/EDR
Self test is not available Bumble BR/EDR local transport is not
implemented yet.

Test: Internal test - CTKD over BR/EDR
2022-08-30 11:19:22 +08:00
Gilles Boccon-Gibod
84d70ad4f3 add usb_probe tool and improve compatibility (#33)
* add usb_probe tool and improve compatibility with older/non-compliant devices

* fix logic test

* add doc
2022-08-26 12:41:55 -07:00
zxzxwu
996a9e28f4 Handle L2CAP info dynamically (#28)
* Add feature and MTU fields in L2CAP manager constructor
* Add register/unregister API for fixed channels
2022-08-18 08:25:59 -07:00
zxzxwu
27cb4c586b Delegate Classic connectable and discoverable (#27)
For remote-initiated test cases, we need the device to be
scan-configurable.
2022-08-17 14:20:32 -07:00
Gilles Boccon-Gibod
1f78243ea6 add test.release task to facilitate CI integration (#26) 2022-08-16 13:37:26 -07:00
Ray
216ce2abd0 Add release tasks (#6)
Added two tasks to tasks.py, release and release_tests.

Applied black formatter

authored-by: Raymundo Ramirez Mata <raymundora@google.com>
2022-08-16 11:50:30 -07:00
Gilles Boccon-Gibod
431445e6a2 fix imports (#25) 2022-08-16 11:29:56 -07:00
Michael Mogenson
d7cc546248 Update supported commands in console.py docs (#24)
Co-authored-by: Michael Mogenson <mogenson@google.com>
2022-08-12 14:23:21 -07:00
Gilles Boccon-Gibod
29fd19f40d gbg/fix subscribe lambda (#23)
* don't use a lambda as a subscriber

* update tests to look at side effects instead of internals
2022-08-12 14:22:31 -07:00
Michael Mogenson
14dfc1a501 Add subscribe and unsubscribe commands to console.py (#22)
Subscribe command will enable notify or indicate events from the
characteristic, depending on supported characteristic properties, and
print received values to the output window.

Unsubscribe will stop notify or indicate events.

Rename find_attribute() to find_characteristic() and return a
characteristic for a set of UUIDS, a characteristic for an attribute
handle, or None.

Print read and received values has a hex string.

Add an unsubscribe implementation to gatt_client.py. Reset the CCCD bits
to 0x0000. Remove a matching subsciber, if one is provided. Otherwise
remove all subscribers for a characteristic, since no more notify or
indicates events will be comming.

authored-by: Michael Mogenson <mogenson@google.com>
2022-08-12 11:49:01 -07:00
Gilles Boccon-Gibod
938282e961 Update python-publish.yml 2022-08-04 14:40:40 -07:00
Gilles Boccon-Gibod
900c15b151 Update python-publish.yml
trigger on published release
2022-08-04 14:30:25 -07:00
Gilles Boccon-Gibod
9ea93be723 add missing package entry (#21) 2022-08-04 14:27:21 -07:00
Gilles Boccon-Gibod
894ab023c7 Update python-publish.yml
don't run on PRs
2022-08-04 10:50:28 -07:00
Gilles Boccon-Gibod
7bbb37b2da Merge pull request #20 from google/gbg/test-gatt-long-read
add long read self test
2022-08-04 10:33:27 -07:00
Gilles Boccon-Gibod
3fa5d320de add long read self test 2022-08-03 16:19:04 -07:00
Gilles Boccon-Gibod
16d684c199 Merge pull request #19 from google/gbg/pypi-publish
add long description
2022-08-03 16:11:51 -07:00
Gilles Boccon-Gibod
c28aa2ebb6 add long description 2022-08-01 18:18:32 -07:00
Gilles Boccon-Gibod
28586382f4 don't publish to test PyPI
publishing to PyPI doesn't work with SCM versioning
2022-08-01 18:16:46 -07:00
Gilles Boccon-Gibod
76f08977c4 support SCM versioning 2022-08-01 17:30:00 -07:00
Gilles Boccon-Gibod
15cbf52da4 Update python-build-test.yml
Get history and tags for SCM versioning to work
2022-08-01 17:27:11 -07:00
Gilles Boccon-Gibod
f4f84dffef Update python-publish.yml
add action to fetch tags in order for SCM versioning to work
2022-08-01 17:21:19 -07:00
Gilles Boccon-Gibod
6dfb07d7b9 Create python-publish.yml 2022-08-01 16:35:32 -07:00
23 changed files with 833 additions and 199 deletions

View File

@@ -16,7 +16,12 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- name: Check out from Git
uses: actions/checkout@v3
- name: Get history and tags for SCM versioning to work
run: |
git fetch --prune --unshallow
git fetch --depth=1 origin +refs/tags/*:refs/tags/*
- name: Set up Python 3.10
uses: actions/setup-python@v3
with:
@@ -24,11 +29,11 @@ jobs:
- name: Install dependencies
run: |
python -m pip install --upgrade pip
python -m pip install ".[test,development,documentation]"
python -m pip install ".[build,test,development,documentation]"
- name: Test with pytest
run: |
pytest
- name: Build
run: |
inv build
inv mkdocs
inv build.mkdocs

37
.github/workflows/python-publish.yml vendored Normal file
View File

@@ -0,0 +1,37 @@
name: Upload Python Package
on:
release:
types: [published]
permissions:
contents: read
jobs:
deploy:
name: Build and publish Python 🐍 distributions 📦 to PyPI and TestPyPI
runs-on: ubuntu-latest
steps:
- name: Check out from Git
uses: actions/checkout@v3
- name: Get history and tags for SCM versioning to work
run: |
git fetch --prune --unshallow
git fetch --depth=1 origin +refs/tags/*:refs/tags/*
- name: Set up Python
uses: actions/setup-python@v3
with:
python-version: '3.10'
- name: Install dependencies
run: |
python -m pip install --upgrade pip
python -m pip install build
- name: Build package
run: python -m build
- name: Publish package to PyPI
if: github.event_name == 'release' && startsWith(github.ref, 'refs/tags')
uses: pypa/gh-action-pypi-publish@release/v1
with:
user: __token__
password: ${{ secrets.PYPI_API_TOKEN }}

View File

@@ -9,7 +9,7 @@
Bluetooth Stack for Apps, Emulation, Test and Experimentation
=============================================================
<img src="docs/mkdocs/src/images/logo_framed.png" alt="drawing" width="200" height="200"/>
<img src="docs/mkdocs/src/images/logo_framed.png" alt="Logo" width="200" height="200"/>
Bumble is a full-featured Bluetooth stack written entirely in Python. It supports most of the common Bluetooth Low Energy (BLE) and Bluetooth Classic (BR/EDR) protocols and profiles, including GAP, L2CAP, ATT, GATT, SMP, SDP, RFCOMM, HFP, HID and A2DP. The stack can be used with physical radios via HCI over USB, UART, or the Linux VHCI, as well as virtual radios, including the virtual Bluetooth support of the Android emulator.
@@ -38,12 +38,20 @@ python -m pip install ".[test,development,documentation]"
### Examples
Refer to the [Example Documentation](examples/README.md) for details on the included example scripts and how to run them.
Refer to the [Examples Documentation](examples/README.md) for details on the included example scripts and how to run them.
The complete [list of Examples](/docs/mkdocs/src/examples/index.md), and what they are designed to do is here.
There are also a set of [Apps and Tools](docs/mkdocs/src/apps_and_tools/index.md) that show the utility of Bumble.
### Using Bumble With a USB Dongle
Bumble is easiest to use with a dedicated USB dongle.
This is because internal Bluetooth interfaces tend to be locked down by the operating system.
You can use the [usb_probe](/docs/mkdocs/src/apps_and_tools/usb_probe.md) tool (all platforms) or `lsusb` (Linux or macOS) to list the available USB devices on your system.
See the [USB Transport](/docs/mkdocs/src/transports/usb.md) page for details on how to refer to USB devices.
## License
Licensed under the [Apache 2.0](LICENSE) License.

View File

@@ -122,6 +122,8 @@ class ConsoleApp:
},
'read': LiveCompleter(self.known_attributes),
'write': LiveCompleter(self.known_attributes),
'subscribe': LiveCompleter(self.known_attributes),
'unsubscribe': LiveCompleter(self.known_attributes),
'quit': None,
'exit': None
})
@@ -331,7 +333,7 @@ class ConsoleApp:
await self.show_attributes(attributes)
def find_attribute(self, param):
def find_characteristic(self, param):
parts = param.split('.')
if len(parts) == 2:
service_uuid = UUID(parts[0]) if parts[0] != '*' else None
@@ -344,7 +346,10 @@ class ConsoleApp:
elif len(parts) == 1:
if parts[0].startswith('#'):
attribute_handle = int(f'{parts[0][1:]}', 16)
return attribute_handle
for service in self.connected_peer.services:
for characteristic in service.characteristics:
if characteristic.handle == attribute_handle:
return characteristic
async def command(self, command):
try:
@@ -457,13 +462,13 @@ class ConsoleApp:
self.show_error('invalid syntax', 'expected read <attribute>')
return
attribute = self.find_attribute(params[0])
if attribute is None:
characteristic = self.find_characteristic(params[0])
if characteristic is None:
self.show_error('no such characteristic')
return
value = await self.connected_peer.read_value(attribute)
self.append_to_output(f'VALUE: {value}')
value = await characteristic.read_value()
self.append_to_output(f'VALUE: 0x{value.hex()}')
async def do_write(self, params):
if not self.connected_peer:
@@ -482,21 +487,48 @@ class ConsoleApp:
except ValueError:
value = str.encode(params[1]) # must be a string
attribute = self.find_attribute(params[0])
if attribute is None:
characteristic = self.find_characteristic(params[0])
if characteristic is None:
self.show_error('no such characteristic')
return
# use write with response if supported
with_response = (
(attribute.properties & Characteristic.WRITE)
if hasattr(attribute, "properties")
else False
with_response = characteristic.properties & Characteristic.WRITE
await characteristic.write_value(value, with_response=with_response)
async def do_subscribe(self, params):
if not self.connected_peer:
self.show_error('not connected')
return
if len(params) != 1:
self.show_error('invalid syntax', 'expected subscribe <attribute>')
return
characteristic = self.find_characteristic(params[0])
if characteristic is None:
self.show_error('no such characteristic')
return
await characteristic.subscribe(
lambda value: self.append_to_output(f"{characteristic} VALUE: 0x{value.hex()}"),
)
await self.connected_peer.write_value(
attribute, value, with_response=with_response
)
async def do_unsubscribe(self, params):
if not self.connected_peer:
self.show_error('not connected')
return
if len(params) != 1:
self.show_error('invalid syntax', 'expected subscribe <attribute>')
return
characteristic = self.find_characteristic(params[0])
if characteristic is None:
self.show_error('no such characteristic')
return
await characteristic.unsubscribe()
async def do_exit(self, params):
self.ui.exit()

157
apps/usb_probe.py Normal file
View File

@@ -0,0 +1,157 @@
# 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.
# -----------------------------------------------------------------------------
# This tool lists all the USB devices, with details about each device.
# For each device, the different possible Bumble transport strings that can
# refer to it are listed. If the device is known to be a Bluetooth HCI device,
# its identifier is printed in reverse colors, and the transport names in cyan color.
# For other devices, regardless of their type, the transport names are printed
# in red. Whether that device is actually a Bluetooth device or not depends on
# whether it is a Bluetooth device that uses a non-standard Class, or some other
# type of device (there's no way to tell).
# -----------------------------------------------------------------------------
# -----------------------------------------------------------------------------
# Imports
# -----------------------------------------------------------------------------
import os
import logging
import usb1
from colors import color
# -----------------------------------------------------------------------------
# Constants
# -----------------------------------------------------------------------------
USB_DEVICE_CLASS_WIRELESS_CONTROLLER = 0xE0
USB_DEVICE_SUBCLASS_RF_CONTROLLER = 0x01
USB_DEVICE_PROTOCOL_BLUETOOTH_PRIMARY_CONTROLLER = 0x01
USB_DEVICE_CLASSES = {
0x00: 'Device',
0x01: 'Audio',
0x02: 'Communications and CDC Control',
0x03: 'Human Interface Device',
0x05: 'Physical',
0x06: 'Still Imaging',
0x07: 'Printer',
0x08: 'Mass Storage',
0x09: 'Hub',
0x0A: 'CDC Data',
0x0B: 'Smart Card',
0x0D: 'Content Security',
0x0E: 'Video',
0x0F: 'Personal Healthcare',
0x10: 'Audio/Video',
0x11: 'Billboard',
0x12: 'USB Type-C Bridge',
0x3C: 'I3C',
0xDC: 'Diagnostic',
USB_DEVICE_CLASS_WIRELESS_CONTROLLER: (
'Wireless Controller',
{
0x01: {
0x01: 'Bluetooth',
0x02: 'UWB',
0x03: 'Remote NDIS',
0x04: 'Bluetooth AMP'
}
}
),
0xEF: 'Miscellaneous',
0xFE: 'Application Specific',
0xFF: 'Vendor Specific'
}
# -----------------------------------------------------------------------------
def main():
logging.basicConfig(level = os.environ.get('BUMBLE_LOGLEVEL', 'WARNING').upper())
with usb1.USBContext() as context:
bluetooth_device_count = 0
devices = {}
for device in context.getDeviceIterator(skip_on_error=True):
device_class = device.getDeviceClass()
device_subclass = device.getDeviceSubClass()
device_protocol = device.getDeviceProtocol()
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_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
if device_is_bluetooth_hci:
bluetooth_device_count += 1
fg_color = 'black'
bg_color = 'yellow'
else:
fg_color = 'yellow'
bg_color = 'black'
# Compute the different ways this can be referenced as a Bumble transport
bumble_transport_names = []
basic_transport_name = f'usb:{device.getVendorID():04X}:{device.getProductID():04X}'
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()}')
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()
devices.setdefault(device_id, []).append(device.getSerialNumber())
# -----------------------------------------------------------------------------
if __name__ == '__main__':
main()

View File

@@ -18,7 +18,8 @@
import json
import asyncio
import logging
from contextlib import asynccontextmanager, AsyncExitStack
import secrets
from contextlib import asynccontextmanager, AsyncExitStack
from .hci import *
from .host import Host
@@ -32,6 +33,8 @@ from . import smp
from . import sdp
from . import l2cap
from . import keys
from . import crypto
# -----------------------------------------------------------------------------
# Logging
@@ -51,6 +54,7 @@ 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_LE_RPA_TIMEOUT = 15 * 60 # 15 minutes (in seconds)
DEVICE_MIN_SCAN_INTERVAL = 25
DEVICE_MAX_SCAN_INTERVAL = 10240
DEVICE_MIN_SCAN_WINDOW = 25
@@ -123,6 +127,9 @@ class Peer:
async def subscribe(self, characteristic, subscriber=None):
return await self.gatt_client.subscribe(characteristic, subscriber)
async def unsubscribe(self, characteristic, subscriber=None):
return await self.gatt_client.unsubscribe(characteristic, subscriber)
async def read_value(self, attribute):
return await self.gatt_client.read_value(attribute)
@@ -166,7 +173,6 @@ class Peer:
async def __aexit__(self, exc_type, exc_value, traceback):
pass
def __str__(self):
return f'{self.connection.peer_address} as {self.connection.role_name}'
@@ -199,11 +205,22 @@ class Connection(CompositeEventEmitter):
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,
local_address,
peer_address,
peer_resolvable_address,
role,
parameters
):
super().__init__()
self.device = device
self.handle = handle
self.transport = transport
self.local_address = local_address
self.peer_address = peer_address
self.peer_resolvable_address = peer_resolvable_address
self.peer_name = None # Classic only
@@ -294,7 +311,12 @@ class Connection(CompositeEventEmitter):
raise
def __str__(self):
return f'Connection(handle=0x{self.handle:04X}, role={self.role_name}, address={self.peer_address})'
return (
f'Connection(handle=0x{self.handle:04X}, '
f'role={self.role_name}, '
f'local_address={self.local_address}, '
f'peer_address={self.peer_address})'
)
# -----------------------------------------------------------------------------
@@ -308,28 +330,37 @@ class DeviceConfiguration:
self.advertising_interval_min = DEVICE_DEFAULT_ADVERTISING_INTERVAL
self.advertising_interval_max = DEVICE_DEFAULT_ADVERTISING_INTERVAL
self.le_enabled = True
# LE host enable 2nd parameter
self.le_simultaneous_enabled = True
self.le_privacy_enabled = False
self.le_rpa_timeout = DEVICE_DEFAULT_LE_RPA_TIMEOUT
self.classic_enabled = False
self.classic_sc_enabled = True
self.classic_ssp_enabled = True
self.connectable = True
self.discoverable = True
self.advertising_data = bytes(
AdvertisingData([(AdvertisingData.COMPLETE_LOCAL_NAME, bytes(self.name, 'utf-8'))])
)
self.irk = bytes(16) # This really must be changed for any level of security
self.irk = bytes([0xFF] * 16) # This really must be changed for any level of security
self.keystore = None
def load_from_dict(self, config):
# Load simple properties
self.name = config.get('name', self.name)
self.address = Address(config.get('address', self.address))
self.class_of_device = config.get('class_of_device', self.class_of_device)
self.name = config.get('name', self.name)
self.address = Address(config.get('address', self.address))
self.class_of_device = config.get('class_of_device', self.class_of_device)
self.advertising_interval_min = config.get('advertising_interval', self.advertising_interval_min)
self.advertising_interval_max = self.advertising_interval_min
self.keystore = config.get('keystore')
self.le_enabled = config.get('le_enabled', self.le_enabled)
self.le_simultaneous_enabled = config.get('le_simultaneous_enabled', self.le_simultaneous_enabled)
self.le_privacy_enabled = config.get('le_privacy_enabled', self.le_privacy_enabled)
self.le_rpa_timeout = config.get('le_rpa_timeout', self.le_rpa_timeout)
self.classic_enabled = config.get('classic_enabled', self.classic_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.connectable = config.get('connectable', self.connectable)
self.discoverable = config.get('discoverable', self.discoverable)
# Load or synthesize an IRK
irk = config.get('irk')
@@ -345,6 +376,10 @@ class DeviceConfiguration:
advertising_data = config.get('advertising_data')
if advertising_data:
self.advertising_data = bytes.fromhex(advertising_data)
else:
self.advertising_data = bytes(
AdvertisingData([(AdvertisingData.COMPLETE_LOCAL_NAME, bytes(self.name, 'utf-8'))])
)
def load_from_file(self, filename):
with open(filename, 'r') as file:
@@ -443,18 +478,17 @@ class Device(CompositeEventEmitter):
self.command_timeout = 10 # seconds
self.gatt_server = gatt_server.Server(self)
self.sdp_server = sdp.Server(self)
self.l2cap_channel_manager = l2cap.ChannelManager()
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.discoverable = False
self.connectable = False
self.inquiry_response = None
self.address_resolver = None
self.le_rpa_task = None
# Use the initial config or a default
self.public_address = Address('00:00:00:00:00:00')
@@ -462,6 +496,7 @@ class Device(CompositeEventEmitter):
config = DeviceConfiguration()
self.name = config.name
self.random_address = config.address
self.identity_address = config.address
self.class_of_device = config.class_of_device
self.scan_response_data = config.scan_response_data
self.advertising_data = config.advertising_data
@@ -471,8 +506,13 @@ class Device(CompositeEventEmitter):
self.irk = config.irk
self.le_enabled = config.le_enabled
self.le_simultaneous_enabled = config.le_simultaneous_enabled
self.le_privacy_enabled = config.le_privacy_enabled
self.le_rpa_timeout = config.le_rpa_timeout
self.classic_enabled = config.classic_enabled
self.classic_ssp_enabled = config.classic_ssp_enabled
self.classic_sc_enabled = config.classic_sc_enabled
self.discoverable = config.discoverable
self.connectable = config.connectable
# If a name is passed, override the name from the config
if name:
@@ -482,11 +522,16 @@ class Device(CompositeEventEmitter):
if address:
if type(address) is str:
address = Address(address)
self.random_address = address
self.random_address = address
self.identity_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.random_address, self.identity_address)
self.l2cap_channel_manager.register_fixed_channel(
smp.SMP_CID, self.on_smp_pdu)
self.l2cap_channel_manager.register_fixed_channel(
smp.SMP_BR_CID, self.on_smp_pdu)
# Register the SDP server with the L2CAP Channel Manager
self.sdp_server.register(self.l2cap_channel_manager)
@@ -494,6 +539,7 @@ class Device(CompositeEventEmitter):
# Add a GAP Service if requested
if generic_access_service:
self.gatt_server.add_service(GenericAccessService(self.name))
self.l2cap_channel_manager.register_fixed_channel(ATT_CID, self.on_gatt_pdu)
# Forward some events
setup_event_forwarding(self.gatt_server, self, 'characteristic_subscription')
@@ -571,12 +617,21 @@ class Device(CompositeEventEmitter):
logger.debug(color(f'BD_ADDR: {response.return_parameters.bd_addr}', 'yellow'))
self.public_address = response.return_parameters.bd_addr
if self.host.supports_command(HCI_WRITE_LE_HOST_SUPPORT_COMMAND):
await self.send_command(HCI_Write_LE_Host_Support_Command(
le_supported_host = int(self.le_enabled),
simultaneous_le_host = int(self.le_simultaneous_enabled),
))
await self.send_command(HCI_Write_LE_Host_Support_Command(
le_supported_host = int(self.le_enabled),
simultaneous_le_host = int(self.le_simultaneous_enabled),
))
if self.le_enabled:
# If LE Privacy is enabled, generate an RPA
if self.le_privacy_enabled:
self.random_address = self.generate_le_rpa()
logger.info(f'Initial RPA: {self.random_address}')
if self.le_rpa_timeout > 0:
# Start a task to periodically generate a new RPA
self.le_rpa_task = asyncio.create_task(self.run_le_rpa_generation())
# Set the controller address
await self.send_command(HCI_LE_Set_Random_Address_Command(
random_address = self.random_address
@@ -620,14 +675,51 @@ class Device(CompositeEventEmitter):
HCI_Write_Secure_Connections_Host_Support_Command(
secure_connections_host_support=int(self.classic_sc_enabled))
)
# Let the SMP manager know about the address
# TODO: allow using a public address
self.smp_manager.address = self.random_address
await self.set_connectable(self.connectable)
await self.set_discoverable(self.discoverable)
# Done
self.powered_on = True
async def run_le_rpa_generation(self):
while self.le_rpa_timeout != 0:
await asyncio.sleep(self.le_rpa_timeout)
# Check if this is a good time to rotate the address
if self.advertising or self.scanning or self.connecting:
logger.debug('skipping RPA rotation')
continue
random_address = self.generate_le_rpa()
response = await self.send_command(HCI_LE_Set_Random_Address_Command(
random_address = self.random_address
))
if response.return_parameters == HCI_SUCCESS:
logger.info(f'New RPA: {random_address}')
self.random_address = random_address
else:
logger.warning(f'failed to set RPA: {response.return_parameters}')
def generate_le_rpa(self):
# See 1.3.2.2 Private device address generation
# Generate `prand`
while True:
# Generate a 22-bit random number for the random part of `prand`
prand_random = secrets.randbelow(0x400000)
# As least on bit shall be 0 and one bit shall be 1
if prand_random != 0 and prand_random != 0x3FFFFF:
break
prand = prand_random | 0x400000 # The two MSBs are |1|0|
# Generate `hash`
hash = crypto.ah(self.irk, struct.pack('<I', prand)[:3])
# Generate the address from `prand` and `hash`
return Address(hash + struct.pack('<I', prand)[:3], Address.RANDOM_IDENTITY_ADDRESS)
async def start_advertising(self, auto_restart=False):
self.auto_restart_advertising = auto_restart
@@ -659,18 +751,24 @@ class Device(CompositeEventEmitter):
))
# Enable advertising
await self.send_command(HCI_LE_Set_Advertising_Enable_Command(
response = await self.send_command(HCI_LE_Set_Advertising_Enable_Command(
advertising_enable = 1
))
if response.return_parameters != HCI_SUCCESS:
logger.warning(f'HCI_LE_Set_Advertising_Enable_Command failed ({response.return_parameters})')
raise HCI_Error(response.return_parameters)
self.advertising = True
async def stop_advertising(self):
# Disable advertising
if self.advertising:
await self.send_command(HCI_LE_Set_Advertising_Enable_Command(
response = await self.send_command(HCI_LE_Set_Advertising_Enable_Command(
advertising_enable = 0
))
if response.return_parameters != HCI_SUCCESS:
logger.warning(f'HCI_LE_Set_Advertising_Enable_Command failed ({response.return_parameters})')
raise HCI_Error(response.return_parameters)
self.advertising = False
@@ -705,17 +803,23 @@ class Device(CompositeEventEmitter):
))
# Enable scanning
await self.send_command(HCI_LE_Set_Scan_Enable_Command(
response = await self.send_command(HCI_LE_Set_Scan_Enable_Command(
le_scan_enable = 1,
filter_duplicates = 1 if filter_duplicates else 0
))
if response.return_parameters != HCI_SUCCESS:
raise HCI_Error(response.return_parameters)
self.scanning = True
async def stop_scanning(self):
await self.send_command(HCI_LE_Set_Scan_Enable_Command(
response = await self.send_command(HCI_LE_Set_Scan_Enable_Command(
le_scan_enable = 0,
filter_duplicates = 0
))
if response.return_parameters != HCI_SUCCESS:
raise HCI_Error(response.return_parameters)
self.scanning = False
@property
@@ -1226,6 +1330,7 @@ class Device(CompositeEventEmitter):
self,
connection_handle,
transport,
self.public_address if transport == BT_BR_EDR_TRANSPORT else self.random_address,
peer_address,
peer_resolvable_address,
role,
@@ -1494,7 +1599,6 @@ class Device(CompositeEventEmitter):
def on_pairing_failure(self, connection, reason):
connection.emit('pairing_failure', reason)
@host_event_handler
@with_connection_from_handle
def on_gatt_pdu(self, connection, pdu):
# Parse the L2CAP payload into an ATT PDU object
@@ -1513,7 +1617,6 @@ class Device(CompositeEventEmitter):
return
connection.gatt_server.on_gatt_pdu(connection, att_pdu)
@host_event_handler
@with_connection_from_handle
def on_smp_pdu(self, connection, pdu):
self.smp_manager.on_smp_pdu(connection, pdu)

View File

@@ -320,6 +320,12 @@ class CharacteristicAdapter:
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'}:
super().__setattr__(name, value)
else:
setattr(self.wrapped_characteristic, name, value)
def read_encoded_value(self, connection):
return self.encode_value(self.wrapped_characteristic.read_value(connection))
@@ -343,6 +349,10 @@ class CharacteristicAdapter:
None if subscriber is None else lambda value: subscriber(self.decode_value(value))
)
def __str__(self):
wrapped = str(self.wrapped_characteristic)
return f'{self.__class__.__name__}({wrapped})'
# -----------------------------------------------------------------------------
class DelegatedCharacteristicAdapter(CharacteristicAdapter):

View File

@@ -110,6 +110,9 @@ class CharacteristicProxy(AttributeProxy):
async def subscribe(self, subscriber=None):
return await self.client.subscribe(self, subscriber)
async def unsubscribe(self, subscriber=None):
return await self.client.unsubscribe(self, subscriber)
def __str__(self):
return f'Characteristic(handle=0x{self.handle:04X}, uuid={self.uuid}, properties={Characteristic.properties_as_string(self.properties)})'
@@ -544,10 +547,36 @@ class Client:
for subscriber_set in subscriber_sets:
if subscriber is not None:
subscriber_set.add(subscriber)
subscriber_set.add(lambda value: characteristic.emit('update', self.connection, value))
# 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)
async def unsubscribe(self, characteristic, subscriber=None):
# If we haven't already discovered the descriptors for this characteristic, do it now
if not characteristic.descriptors_discovered:
await self.discover_descriptors(characteristic)
# Look for the CCCD descriptor
cccd = characteristic.get_descriptor(GATT_CLIENT_CHARACTERISTIC_CONFIGURATION_DESCRIPTOR)
if not cccd:
logger.warning('unsubscribing from characteristic with no CCCD descriptor')
return
if subscriber is not None:
# Remove matching subscriber from subscriber sets
for subscriber_set in (self.notification_subscribers, self.indication_subscribers):
subscribers = subscriber_set.get(characteristic.handle, [])
if subscriber in subscribers:
subscribers.remove(subscriber)
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)
async def read_value(self, attribute, no_long_read=False):
'''
See Vol 3, Part G - 4.8.1 Read Characteristic Value
@@ -714,7 +743,10 @@ class Client:
if not subscribers:
logger.warning('!!! received notification with no subscriber')
for subscriber in subscribers:
subscriber(notification.attribute_value)
if callable(subscriber):
subscriber(notification.attribute_value)
else:
subscriber.emit('update', notification.attribute_value)
def on_att_handle_value_indication(self, indication):
# Call all subscribers
@@ -722,7 +754,10 @@ class Client:
if not subscribers:
logger.warning('!!! received indication with no subscriber')
for subscriber in subscribers:
subscriber(indication.attribute_value)
if callable(subscriber):
subscriber(indication.attribute_value)
else:
subscriber.emit('update', indication.attribute_value)
# Confirm that we received the indication
self.send_confirmation(ATT_Handle_Value_Confirmation())

View File

@@ -545,13 +545,13 @@ class Server(EventEmitter):
value = attribute.read_value(connection)
if request.value_offset > len(value):
response = ATT_Error_Response(
request_opcode_in_error = request.op_code,
request_opcode_in_error = request.op_code,
attribute_handle_in_error = request.attribute_handle,
error_code = ATT_INVALID_OFFSET_ERROR
)
elif len(value) <= mtu - 1:
response = ATT_Error_Response(
request_opcode_in_error = request.op_code,
request_opcode_in_error = request.op_code,
attribute_handle_in_error = request.attribute_handle,
error_code = ATT_ATTRIBUTE_NOT_LONG_ERROR
)

View File

@@ -1375,9 +1375,11 @@ class HCI_Error(ProtocolError):
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)
)
# -----------------------------------------------------------------------------
@@ -3276,6 +3278,9 @@ class HCI_Event(HCI_Packet):
parameters = b'' if self.parameters is None else self.parameters
return bytes([HCI_EVENT_PACKET, self.event_code, len(parameters)]) + parameters
def __bytes__(self):
return self.to_bytes()
def __str__(self):
result = color(self.name, 'magenta')
if fields := getattr(self, 'fields', None):

View File

@@ -44,25 +44,20 @@ HOST_HC_TOTAL_NUM_ACL_DATA_PACKETS = 1
# -----------------------------------------------------------------------------
class Connection:
def __init__(self, host, handle, role, peer_address):
def __init__(self, host, handle, role, peer_address, transport):
self.host = host
self.handle = handle
self.role = role
self.peer_address = peer_address
self.assembler = HCI_AclDataPacketAssembler(self.on_acl_pdu)
self.transport = transport
def on_hci_acl_data_packet(self, packet):
self.assembler.feed_packet(packet)
def on_acl_pdu(self, pdu):
l2cap_pdu = L2CAP_PDU.from_bytes(pdu)
if l2cap_pdu.cid == ATT_CID:
self.host.on_gatt_pdu(self, l2cap_pdu.payload)
elif l2cap_pdu.cid == SMP_CID:
self.host.on_smp_pdu(self, l2cap_pdu.payload)
else:
self.host.on_l2cap_pdu(self, l2cap_pdu.cid, l2cap_pdu.payload)
self.host.on_l2cap_pdu(self, l2cap_pdu.cid, l2cap_pdu.payload)
# -----------------------------------------------------------------------------
@@ -81,7 +76,7 @@ class Host(EventEmitter):
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 = None
self.local_version = HCI_VERSION_BLUETOOTH_CORE_4_0
self.local_supported_commands = bytes(64)
self.local_le_features = 0
self.command_semaphore = asyncio.Semaphore(1)
@@ -99,17 +94,18 @@ class Host(EventEmitter):
await self.send_command(HCI_Reset_Command())
self.ready = True
await self.send_command(HCI_Set_Event_Mask_Command(event_mask = bytes.fromhex('FFFFFFFFFFFFFFFF')))
await self.send_command(HCI_LE_Set_Event_Mask_Command(le_event_mask = bytes.fromhex('FFFFF00000000000')))
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}')
if self.supports_command(HCI_WRITE_LE_HOST_SUPPORT_COMMAND):
await self.send_command(HCI_Write_LE_Host_Support_Command(le_supported_host = 1, simultaneous_le_host = 0))
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}')
if self.supports_command(HCI_READ_LOCAL_VERSION_INFORMATION_COMMAND):
response = await self.send_command(HCI_Read_Local_Version_Information_Command())
@@ -118,32 +114,44 @@ class Host(EventEmitter):
else:
logger.warn(f'HCI_Read_Local_Version_Information_Command failed: {response.return_parameters.status}')
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:
# Some older controllers don't like event masks with bits they don't understand
le_event_mask = bytes.fromhex('1F00000000000000')
else:
le_event_mask = bytes.fromhex('FFFFF00000000000')
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}')
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_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
logger.debug(f'HCI LE ACL flow control: hc_le_acl_data_packet_length={response.return_parameters.hc_le_acl_data_packet_length}, 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}')
if response.return_parameters.hc_le_acl_data_packet_length == 0 or response.return_parameters.hc_total_num_le_acl_data_packets == 0:
# Read the non-LE-specific values
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_le_acl_data_packet_length
self.hc_le_acl_data_packet_length = self.hc_le_acl_data_packet_length or self.hc_acl_data_packet_length
self.hc_total_num_acl_data_packets = response.return_parameters.hc_total_num_le_acl_data_packets
self.hc_total_num_le_acl_data_packets = self.hc_total_num_le_acl_data_packets or 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}, hc_total_num_le_acl_data_packets={self.hc_total_num_le_acl_data_packets}')
else:
logger.warn(f'HCI_Read_Buffer_Size_Command failed: {response.return_parameters.status}')
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}')
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 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}'
)
self.reset_done = True
@@ -168,8 +176,8 @@ class Host(EventEmitter):
# Wait until we can send (only one pending command at a time)
async with self.command_semaphore:
assert(self.pending_command is None)
assert(self.pending_response is None)
assert self.pending_command 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()
@@ -202,6 +210,7 @@ class Host(EventEmitter):
offset = 0
pb_flag = 0
while bytes_remaining:
# TODO: support different LE/Classic lengths
data_total_length = min(bytes_remaining, self.hc_le_acl_data_packet_length)
acl_packet = HCI_AclDataPacket(
connection_handle = connection_handle,
@@ -224,7 +233,7 @@ class Host(EventEmitter):
logger.debug(f'{self.acl_packets_in_flight} ACL packets in flight, {len(self.acl_packet_queue)} in queue')
def check_acl_packet_queue(self):
# Send all we can
# Send all we can (TODO: support different LE/Classic limits)
while len(self.acl_packet_queue) > 0 and self.acl_packets_in_flight < self.hc_total_num_le_acl_data_packets:
packet = self.acl_packet_queue.pop()
self.send_hci_packet(packet)
@@ -299,12 +308,6 @@ class Host(EventEmitter):
if connection := self.connections.get(packet.connection_handle):
connection.on_hci_acl_data_packet(packet)
def on_gatt_pdu(self, connection, pdu):
self.emit('gatt_pdu', connection.handle, pdu)
def on_smp_pdu(self, connection, pdu):
self.emit('smp_pdu', connection.handle, pdu)
def on_l2cap_pdu(self, connection, cid, pdu):
self.emit('l2cap_pdu', connection.handle, cid, pdu)
@@ -362,7 +365,7 @@ class Host(EventEmitter):
connection = self.connections.get(event.connection_handle)
if connection is None:
connection = Connection(self, event.connection_handle, event.role, event.peer_address)
connection = Connection(self, event.connection_handle, event.role, event.peer_address, BT_LE_TRANSPORT)
self.connections[event.connection_handle] = connection
# Notify the client
@@ -397,7 +400,7 @@ class Host(EventEmitter):
connection = self.connections.get(event.connection_handle)
if connection is None:
connection = Connection(self, event.connection_handle, BT_CENTRAL_ROLE, event.bd_addr)
connection = Connection(self, event.connection_handle, BT_CENTRAL_ROLE, event.bd_addr, BT_BR_EDR_TRANSPORT)
self.connections[event.connection_handle] = connection
# Notify the client

View File

@@ -20,11 +20,11 @@ import logging
import struct
from colors import color
from pyee import EventEmitter
from .core import BT_CENTRAL_ROLE, InvalidStateError, ProtocolError
from .hci import (HCI_LE_Connection_Update_Command, HCI_Object, key_with_value,
name_or_number)
from .utils import EventEmitter
# -----------------------------------------------------------------------------
# Logging
@@ -414,6 +414,18 @@ class L2CAP_Information_Request(L2CAP_Control_Frame):
EXTENDED_FEATURES_SUPPORTED = 0x0002
FIXED_CHANNELS_SUPPORTED = 0x0003
EXTENDED_FEATURE_FLOW_MODE_CONTROL = 0x0001
EXTENDED_FEATURE_RETRANSMISSION_MODE = 0x0002
EXTENDED_FEATURE_BIDIRECTIONAL_QOS = 0x0004
EXTENDED_FEATURE_ENHANCED_RETRANSMISSION_MODE = 0x0008
EXTENDED_FEATURE_STREAMING_MODE = 0x0010
EXTENDED_FEATURE_FCS_OPTION = 0x0020
EXTENDED_FEATURE_EXTENDED_FLOW_SPEC = 0x0040
EXTENDED_FEATURE_FIXED_CHANNELS = 0x0080
EXTENDED_FEATURE_EXTENDED_WINDOW_SIZE = 0x0100
EXTENDED_FEATURE_UNICAST_CONNECTIONLESS_DATA = 0x0200
EXTENDED_FEATURE_ENHANCED_CREDIT_BASE_FLOW_CONTROL = 0x0400
INFO_TYPE_NAMES = {
CONNECTIONLESS_MTU: 'CONNECTIONLESS_MTU',
EXTENDED_FEATURES_SUPPORTED: 'EXTENDED_FEATURES_SUPPORTED',
@@ -817,11 +829,16 @@ class Channel(EventEmitter):
# -----------------------------------------------------------------------------
class ChannelManager:
def __init__(self):
self.host = None
self.channels = {} # Channels, mapped by connection and cid
self.identifiers = {} # Incrementing identifier values by connection
self.servers = {} # Servers accepting connections, by PSM
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}
self.identifiers = {} # Incrementing identifier values by connection
self.servers = {} # Servers accepting connections, by PSM
self.extended_features = [] if extended_features is None else extended_features
self.connectionless_mtu = connectionless_mtu
def find_channel(self, connection_handle, cid):
if connection_channels := self.channels.get(connection_handle):
@@ -840,6 +857,13 @@ class ChannelManager:
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):
self.servers[psm] = server
@@ -855,6 +879,8 @@ class ChannelManager:
control_frame = L2CAP_Control_Frame.from_bytes(pdu)
self.on_control_frame(connection, cid, control_frame)
elif cid in self.fixed_channels:
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'))
@@ -999,13 +1025,13 @@ class ChannelManager:
def on_l2cap_information_request(self, connection, cid, request):
if request.info_type == L2CAP_Information_Request.CONNECTIONLESS_MTU:
result = L2CAP_Information_Response.SUCCESS
data = struct.pack('<H', 1024) # TODO: don't use a fixed value
data = self.connectionless_mtu.to_bytes(2, 'little')
elif request.info_type == L2CAP_Information_Request.EXTENDED_FEATURES_SUPPORTED:
result = L2CAP_Information_Response.SUCCESS
data = bytes.fromhex('00000000') # TODO: don't use a fixed value
data = sum(self.extended_features).to_bytes(4, 'little')
elif request.info_type == L2CAP_Information_Request.FIXED_CHANNELS_SUPPORTED:
result = L2CAP_Information_Response.SUCCESS
data = bytes.fromhex('FFFFFFFFFFFFFFFF') # TODO: don't use a fixed value
data = sum(1 << cid for cid in self.fixed_channels).to_bytes(8, 'little')
else:
result = L2CAP_Information_Request.NO_SUPPORTED

View File

@@ -17,9 +17,10 @@
# -----------------------------------------------------------------------------
import logging
import asyncio
from colors import color
from .utils import EventEmitter
from colors import color
from pyee import EventEmitter
from .core import InvalidStateError, ProtocolError, ConnectionError
# -----------------------------------------------------------------------------

View File

@@ -44,6 +44,7 @@ logger = logging.getLogger(__name__)
# Constants
# -----------------------------------------------------------------------------
SMP_CID = 0x06
SMP_BR_CID = 0x07
SMP_PAIRING_REQUEST_COMMAND = 0x01
SMP_PAIRING_RESPONSE_COMMAND = 0x02
@@ -152,6 +153,7 @@ SMP_CT2_AUTHREQ = 0b00100000
# Crypto salt
SMP_CTKD_H7_LEBR_SALT = bytes.fromhex('00000000000000000000000000000000746D7031')
SMP_CTKD_H7_BRLE_SALT = bytes.fromhex('00000000000000000000000000000000746D7032')
# -----------------------------------------------------------------------------
# Utils
@@ -598,6 +600,7 @@ class Session:
self.pairing_config = pairing_config
self.wait_before_continuing = None
self.completed = False
self.ctkd_task = None
# Decide if we're the initiator or the responder
self.is_initiator = (connection.role == BT_CENTRAL_ROLE)
@@ -635,13 +638,13 @@ class Session:
# Set up addresses
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(connection.local_address)
self.iat = 1 if connection.local_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(connection.local_address)
self.rat = 1 if connection.local_address.is_random else 0
self.ia = bytes(peer_address)
self.iat = 1 if peer_address.is_random else 0
@@ -877,10 +880,21 @@ class Session:
)
)
async def derive_ltk(self):
link_key = await self.manager.device.get_link_key(self.connection.peer_address)
assert link_key is not None
ilk = crypto.h7(
salt=SMP_CTKD_H7_BRLE_SALT,
w=link_key) if self.ct2 else crypto.h6(link_key, b'tmp2')
self.ltk = crypto.h6(ilk, b'brle')
def distribute_keys(self):
# Distribute the keys as required
if self.is_initiator:
if not self.sc:
# CTKD: Derive LTK from LinkKey
if self.connection.transport == BT_BR_EDR_TRANSPORT and self.initiator_key_distribution & SMP_ENC_KEY_DISTRIBUTION_FLAG:
self.ctkd_task = asyncio.create_task(self.derive_ltk())
elif not self.sc:
# Distribute the LTK, EDIV and RAND
if self.initiator_key_distribution & SMP_ENC_KEY_DISTRIBUTION_FLAG:
self.send_command(SMP_Encryption_Information_Command(long_term_key=self.ltk))
@@ -892,15 +906,15 @@ class Session:
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.manager.identity_address.address_type,
bd_addr = self.manager.identity_address
))
# Distribute CSRK
csrk = bytes(16) # FIXME: testing
if self.initiator_key_distribution & SMP_SIGN_KEY_DISTRIBUTION_FLAG:
self.send_command(SMP_Signing_Information_Command(signature_key=csrk))
# CTKD, calculate BR/EDR link key
if self.initiator_key_distribution & SMP_LINK_KEY_DISTRIBUTION_FLAG:
ilk = crypto.h7(
@@ -909,8 +923,11 @@ class Session:
self.link_key = crypto.h6(ilk, b'lebr')
else:
# CTKD: Derive LTK from LinkKey
if self.connection.transport == BT_BR_EDR_TRANSPORT and self.responder_key_distribution & SMP_ENC_KEY_DISTRIBUTION_FLAG:
self.ctkd_task = asyncio.create_task(self.derive_ltk())
# Distribute the LTK, EDIV and RAND
if not self.sc:
elif not self.sc:
if self.responder_key_distribution & SMP_ENC_KEY_DISTRIBUTION_FLAG:
self.send_command(SMP_Encryption_Information_Command(long_term_key=self.ltk))
self.send_command(SMP_Master_Identification_Command(ediv=self.ltk_ediv, rand=self.ltk_rand))
@@ -921,15 +938,15 @@ class Session:
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.manager.identity_address.address_type,
bd_addr = self.manager.identity_address
))
# Distribute CSRK
csrk = bytes(16) # FIXME: testing
if self.responder_key_distribution & SMP_SIGN_KEY_DISTRIBUTION_FLAG:
self.send_command(SMP_Signing_Information_Command(signature_key=csrk))
# CTKD, calculate BR/EDR link key
if self.responder_key_distribution & SMP_LINK_KEY_DISTRIBUTION_FLAG:
ilk = crypto.h7(
@@ -940,7 +957,7 @@ class Session:
def compute_peer_expected_distributions(self, key_distribution_flags):
# Set our expectations for what to wait for in the key distribution phase
self.peer_expected_distributions = []
if not self.sc:
if not self.sc and self.connection.transport == BT_LE_TRANSPORT:
if (key_distribution_flags & SMP_ENC_KEY_DISTRIBUTION_FLAG != 0):
self.peer_expected_distributions.append(SMP_Encryption_Information_Command)
self.peer_expected_distributions.append(SMP_Master_Identification_Command)
@@ -968,7 +985,7 @@ class Session:
self.distribute_keys()
# Nothing left to expect, we're done
self.on_pairing()
asyncio.create_task(self.on_pairing())
else:
logger.warn(color(f'!!! unexpected key distribution command: {command_class.__name__}', 'red'))
self.send_pairing_failed(SMP_UNSPECIFIED_REASON_ERROR)
@@ -999,7 +1016,7 @@ class Session:
# Do as if the connection had just been encrypted
self.on_connection_encryption_change()
def on_pairing(self):
async def on_pairing(self):
logger.debug('pairing complete')
if self.completed:
@@ -1016,11 +1033,16 @@ class Session:
else:
peer_address = self.connection.peer_address
# Wait for link key fetch and key derivation
if self.ctkd_task is not None:
await self.ctkd_task
self.ctkd_task = None
# Create an object to hold the keys
keys = PairingKeys()
keys.address_type = peer_address.address_type
authenticated = self.pairing_method != self.JUST_WORKS
if self.sc:
if self.sc or self.connection.transport == BT_BR_EDR_TRANSPORT:
keys.ltk = PairingKeys.Key(
value = self.ltk,
authenticated = authenticated
@@ -1059,7 +1081,6 @@ class Session:
value = self.link_key,
authenticated = authenticated
)
self.manager.on_pairing(self, peer_address, keys)
def on_pairing_failure(self, reason):
@@ -1137,6 +1158,12 @@ class Session:
# Respond
self.send_pairing_response_command()
# Vol 3, Part C, 5.2.2.1.3
# CTKD over BR/EDR should happen after the connection has been encrypted,
# so when receiving pairing requests, responder should start distributing keys
if self.connection.transport == BT_BR_EDR_TRANSPORT and self.connection.is_encrypted and self.is_responder and accepted:
self.distribute_keys()
def on_smp_pairing_response_command(self, command):
if self.is_responder:
logger.warn(color('received pairing response as a responder', 'red'))
@@ -1452,17 +1479,18 @@ class Manager(EventEmitter):
Implements the Initiator and Responder roles of the Security Manager Protocol
'''
def __init__(self, device, address):
def __init__(self, device, address, identity_address):
super().__init__()
self.device = device
self.address = address
self.identity_address = identity_address
self.sessions = {}
self._ecc_key = None
self.pairing_config_factory = lambda connection: PairingConfig()
def send_command(self, connection, command):
logger.debug(f'>>> Sending SMP Command on connection [0x{connection.handle:04X}] {connection.peer_address}: {command}')
connection.send_l2cap_pdu(SMP_CID, command.to_bytes())
cid = SMP_BR_CID if connection.transport == BT_BR_EDR_TRANSPORT else SMP_CID
connection.send_l2cap_pdu(cid, command.to_bytes())
def on_smp_pdu(self, connection, pdu):
# Look for a session with this connection, and create one if none exists

View File

@@ -37,16 +37,20 @@ async def open_usb_transport(spec):
'''
Open a USB transport.
The parameter string has this syntax:
either <index> or <vendor>:<product>[/<serial-number>]
either <index> or
<vendor>:<product> or
<vendor>:<product>/<serial-number>] or
<vendor>:<product>#<index>
With <index> as the 0-based index to select amongst all the devices that appear
to be supporting Bluetooth HCI (0 being the first one), or
Where <vendor> and <product> are the vendor ID and product ID in hexadecimal. The
/<serial-number> suffix max be specified when more than one device with the same
vendor and product identifiers are present.
/<serial-number> suffix or #<index> suffix max be specified when more than one device with
the same vendor and product identifiers are present.
Examples:
0 --> the first BT USB dongle
04b4:f901 --> the BT USB dongle with vendor=04b4 and product=f901
04b4:f901#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
'''
@@ -190,7 +194,7 @@ async def open_usb_transport(spec):
def on_packet_received(self, transfer):
packet_type = transfer.getUserData()
status = transfer.getStatus()
# logger.debug(f'<<< USB IN transfer callback: status={status} packet_type={packet_type}')
# logger.debug(f'<<< USB IN transfer callback: status={status} packet_type={packet_type} length={transfer.getActualLength()}')
if status == usb1.TRANSFER_COMPLETED:
packet = bytes([packet_type]) + transfer.getBuffer()[:transfer.getActualLength()]
@@ -271,19 +275,25 @@ async def open_usb_transport(spec):
found = None
if ':' in spec:
vendor_id, product_id = spec.split(':')
serial_number = None
device_index = 0
if '/' in product_id:
product_id, serial_number = product_id.split('/')
for device in context.getDeviceIterator(skip_on_error=True):
if (
device.getVendorID() == int(vendor_id, 16) and
device.getProductID() == int(product_id, 16) and
device.getSerialNumber() == serial_number
):
elif '#' in product_id:
product_id, device_index_str = product_id.split('#')
device_index = int(device_index_str)
for device in context.getDeviceIterator(skip_on_error=True):
if (
device.getVendorID() == int(vendor_id, 16) and
device.getProductID() == int(product_id, 16) and
(serial_number is None or device.getSerialNumber() == serial_number)
):
if device_index == 0:
found = device
break
device.close()
else:
found = context.getByVendorIDAndProductID(int(vendor_id, 16), int(product_id, 16), skip_on_error=True)
device_index -= 1
device.close()
else:
device_index = int(spec)
for device in context.getDeviceIterator(skip_on_error=True):
@@ -305,17 +315,6 @@ async def open_usb_transport(spec):
logger.debug(f'USB Device: {found}')
device = found.open()
# Set the configuration if needed
try:
configuration = device.getConfiguration()
logger.debug(f'current configuration = {configuration}')
except usb1.USBError:
try:
logger.debug('setting configuration 1')
device.setConfiguration(1)
except usb1.USBError:
logger.debug('failed to set configuration 1')
# Use the first interface
interface = 0
@@ -328,6 +327,20 @@ async def open_usb_transport(spec):
except usb1.USBError:
pass
# Set the configuration if needed
try:
configuration = device.getConfiguration()
logger.debug(f'current configuration = {configuration}')
except usb1.USBError:
configuration = 0
if configuration != 1:
try:
logger.debug('setting configuration 1')
device.setConfiguration(1)
except usb1.USBError:
logger.warning('failed to set configuration 1')
source = UsbPacketSource(context, device)
sink = UsbPacketSink(device)
return UsbTransport(context, device, interface, source, sink)

View File

@@ -7,10 +7,12 @@ The Console app is an interactive text user interface that offers a number of fu
* scanning
* advertising
* connecting to devices
* connecting to and disconnecting from devices
* changing connection parameters
* enabling encryption
* discovering GATT services and characteristics
* read & write GATT characteristics
* reading and writing GATT characteristics
* subscribing to and unsubscribing from GATT characteristics
The console user interface has 3 main panes:

View File

@@ -0,0 +1,38 @@
USB PROBE TOOL
==============
This tool lists all the USB devices, with details about each device.
For each device, the different possible Bumble transport strings that can
refer to it are listed.
If the device is known to be a Bluetooth HCI device, its identifier is printed
in reverse colors, and the transport names in cyan color.
For other devices, regardless of their type, the transport names are printed
in red. Whether that device is actually a Bluetooth device or not depends on
whether it is a Bluetooth device that uses a non-standard Class, or some other
type of device (there's no way to tell).
## Usage
This command line tool takes no arguments.
When installed from PyPI, run as
```
$ bumble-usb-probe
```
When running from the source distribution:
```
$ python3 apps/usb-probe.py
```
!!! example
```
$ python3 apps/usb_probe.py
ID 0A12:0001
Bumble Transport Names: usb:0 or usb:0A12:0001
Bus/Device: 020/034
Class: Wireless Controller
Subclass/Protocol: 1/1 [Bluetooth]
Manufacturer: None
Product: USB2.0-BT
```

View File

@@ -4,16 +4,56 @@ USB TRANSPORT
The USB transport interfaces with a local Bluetooth USB dongle.
## Moniker
The moniker for a USB transport is either `usb:<index>` or `usb:<vendor>:<product>`
with `<index>` as the 0-based index to select amongst all the devices that appear to be supporting Bluetooth HCI (0 being the first one), or where `<vendor>` and `<product>` are a vendor ID and product ID in hexadecimal.
The moniker for a USB transport is either:
* `usb:<index>`
* `usb:<vendor>:<product>`
* `usb:<vendor>:<product>/<serial-number>`
* `usb:<vendor>:<product>#<index>`
!!! example
with `<index>` as a 0-based index (0 being the first one) to select amongst all the matching devices when there are more than one.
In the `usb:<index>` form, matching devices are the ones supporting Bluetooth HCI, as declared by their Class, Subclass and Protocol.
In the `usb:<vendor>:<product>#<index>` form, matching devices are the ones with the specified `<vendor>` and `<product>` identification.
`<vendor>` and `<product>` are a vendor ID and product ID in hexadecimal.
!!! examples
`usb:04b4:f901`
Use the USB dongle with `vendor` equal to `04b4` and `product` equal to `f901`
The USB dongle with `<vendor>` equal to `04b4` and `<product>` equal to `f901`
`usb:0`
Use the first Bluetooth dongle
The first Bluetooth HCI dongle that's declared as such by Class/Subclass/Protocol
`usb:04b4:f901/0016A45B05D8`
The USB dongle with `<vendor>` equal to `04b4`, `<product>` equal to `f901` and `<serial>` equal to `0016A45B05D8`
`usb:04b4:f901/#1`
The second USB dongle with `<vendor>` equal to `04b4` and `<product>` equal to `f901`
## 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.
## Listing Available USB Devices
### With `usb_probe`
You can use the [`usb_probe`](../apps_and_tools/usb_probe.md) tool to list all the USB devices attached to your host computer.
The tool will also show the `usb:XXX` transport name(s) you can use to reference each device.
### With `lsusb`
On Linux and macOS, the `lsusb` tool serves a similar purpose to Bumble's own `usb_probe` tool (without the Bumble specifics)
#### Installing lsusb
On Mac: `brew install lsusb`
On Linux: `sudo apt-get install usbutils`
#### Using lsusb
```
$ lsusb
Bus 004 Device 001: ID 1d6b:0003 Linux Foundation 3.0 root hub
Bus 003 Device 014: ID 0b05:17cb ASUSTek Computer, Inc. Broadcom BCM20702A0 Bluetooth
```
The device id for the Bluetooth interface in this case is `0b05:17cb`.

View File

@@ -20,7 +20,7 @@ import sys
import os
import logging
from colors import color
from bumble.device import Device, Peer
from bumble.device import Device
from bumble.transport import open_transport
from bumble.profiles.battery_service import BatteryServiceProxy

View File

@@ -16,13 +16,15 @@
name = bumble
use_scm_version = True
description = Bluetooth Stack for Apps, Emulation, Test and Experimentation
long_description = file: README.md
long_description_content_type = text/markdown
author = Google
author_email = tbd@tbd.com
url = https://github.com/google/bumble
[options]
python_requires = >=3.8
packages = bumble, bumble.transport, bumble.apps, bumble.apps.link_relay
packages = bumble, bumble.transport, bumble.profiles, bumble.apps, bumble.apps.link_relay
package_dir =
bumble = bumble
bumble.apps = apps
@@ -52,15 +54,17 @@ console_scripts =
bumble-scan = bumble.apps.scan:main
bumble-show = bumble.apps.show:main
bumble-unbond = bumble.apps.unbond:main
bumble-usb-probe = bumble.apps.usb_probe:main
bumble-link-relay = bumble.apps.link_relay.link_relay:main
[options.extras_require]
build =
build >= 0.7
test =
pytest >= 6.2
pytest-asyncio >= 0.17
development =
invoke >= 1.4
build >= 0.7
nox >= 2022
documentation =
mkdocs >= 1.2.3

View File

@@ -23,35 +23,52 @@ ROOT_DIR = os.path.dirname(os.path.realpath(__file__))
ns = Collection()
# Building
build_tasks = Collection()
ns.add_collection(build_tasks, name='build')
ns.add_collection(build_tasks, name="build")
@task
def build(ctx):
ctx.run('python -m build')
def build(ctx, install=False):
if install:
ctx.run('python -m pip install .[build]')
build_tasks.add_task(build, default=True, name='build')
ctx.run("python -m build")
build_tasks.add_task(build, default=True)
@task
def release_build(ctx):
build(ctx, install=True)
build_tasks.add_task(release_build, name="release")
@task
def mkdocs(ctx):
ctx.run("mkdocs build -f docs/mkdocs/mkdocs.yml")
build_tasks.add_task(mkdocs, name="mkdocs")
# Testing
test_tasks = Collection()
ns.add_collection(test_tasks, name='test')
ns.add_collection(test_tasks, name="test")
@task
def test(ctx, filter=None, junit=False):
def test(ctx, filter=None, junit=False, install=False):
# Install the package before running the tests
if install:
ctx.run("python -m pip install .[test]")
args = ""
if junit:
args += "--junit-xml test-results.xml"
if filter is not None:
args += " -k '{}'".format(filter)
ctx.run('python -m pytest {} {}'
.format(os.path.join(ROOT_DIR, "tests"), args))
test_tasks.add_task(test, name='test', default=True)
ctx.run("python -m pytest {} {}".format(os.path.join(ROOT_DIR, "tests"), args))
test_tasks.add_task(test, default=True)
@task
def mkdocs(ctx):
ctx.run('mkdocs build -f docs/mkdocs/mkdocs.yml')
def release_test(ctx):
test(ctx, install=True)
ns.add_task(mkdocs)
test_tasks.add_task(release_test, name="release")

View File

@@ -419,10 +419,12 @@ async def test_subscribe_notify():
assert(len(c) == 1)
c3 = c[0]
c1._called = False
c1._last_update = None
def on_c1_update(connection, value):
c1._last_update = (connection, value)
def on_c1_update(value):
c1._called = True
c1._last_update = value
c1.on('update', on_c1_update)
await peer.subscribe(c1)
@@ -434,44 +436,73 @@ async def test_subscribe_notify():
assert(not characteristic1._last_subscription[2])
await server.indicate_subscribers(characteristic1)
await async_barrier()
assert(c1._last_update is None)
assert(not c1._called)
await server.notify_subscribers(characteristic1)
await async_barrier()
assert(c1._last_update is not None)
assert(c1._last_update[1] == characteristic1.value)
assert(c1._called)
assert(c1._last_update == characteristic1.value)
c1._called = False
await peer.unsubscribe(c1)
await server.notify_subscribers(characteristic1)
assert(not c1._called)
c2._called = False
c2._last_update = None
def on_c2_update(value):
c2._last_update = (connection, value)
c2._called = True
c2._last_update = value
await peer.subscribe(c2, on_c2_update)
await async_barrier()
await server.notify_subscriber(characteristic2._last_subscription[0], characteristic2)
await async_barrier()
assert(c2._last_update is None)
assert(not c2._called)
await server.indicate_subscriber(characteristic2._last_subscription[0], characteristic2)
await async_barrier()
assert(c2._last_update is not None)
assert(c2._last_update[1] == characteristic2.value)
assert(c2._called)
assert(c2._last_update == characteristic2.value)
c3._last_update = None
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)
def on_c3_update(connection, value):
c3._last_update = (connection, value)
def on_c3_update(value):
c3._called = True
c3._last_update = value
def on_c3_update_2(value):
c3._called_2 = True
c3._last_update_2 = value
c3.on('update', on_c3_update)
await peer.subscribe(c3)
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._last_update is not None)
assert(c3._last_update[1] == characteristic3.value)
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._last_update is not None)
assert(c3._last_update[1] == characteristic3.value)
assert(c3._called)
assert(c3._last_update == characteristic3.value)
assert(c3._called_2)
assert(c3._last_update_2 == characteristic3.value)
c3._called = False
c3._called_2 = 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)
# -----------------------------------------------------------------------------

View File

@@ -163,6 +163,44 @@ async def test_self_gatt():
assert(result == c1.value)
# -----------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_self_gatt_long_read():
# Create two devices, each with a controller, attached to the same link
two_devices = TwoDevices()
# Add some GATT characteristics to device 1
characteristics = [
Characteristic(
f'3A143AD7-D4A7-436B-97D6-5B62C315{i:04X}',
Characteristic.READ,
Characteristic.READABLE,
bytes([x & 255 for x in range(i)])
)
for i in range(0, 513)
]
service = Service('8140E247-04F0-42C1-BC34-534C344DAFCA', characteristics)
two_devices.devices[1].add_service(service)
# Start
await two_devices.devices[0].power_on()
await two_devices.devices[1].power_on()
# Connect the two devices
connection = await two_devices.devices[0].connect(two_devices.devices[1].random_address)
peer = Peer(connection)
result = await peer.discover_service(service.uuid)
assert(len(result) == 1)
found_service = result[0]
found_characteristics = await found_service.discover_characteristics()
assert(len(found_characteristics) == 513)
for (i, characteristic) in enumerate(found_characteristics):
value = await characteristic.read_value()
assert(value == characteristics[i].value)
# -----------------------------------------------------------------------------
async def _test_self_smp_with_configs(pairing_config1, pairing_config2):
# Create two devices, each with a controller, attached to the same link
@@ -274,7 +312,7 @@ async def test_self_smp(io_cap, sc, mitm, key_dist):
pairing_config2.delegate.peer_delegate = pairing_config1.delegate
await _test_self_smp_with_configs(pairing_config1, pairing_config2)
# -----------------------------------------------------------------------------
@@ -323,6 +361,7 @@ async def test_self_smp_wrong_pin():
async def run_test_self():
await test_self_connection()
await test_self_gatt()
await test_self_gatt_long_read()
await test_self_smp()
await test_self_smp_reject()
await test_self_smp_wrong_pin()