Compare commits

..

13 Commits

Author SHA1 Message Date
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
17 changed files with 568 additions and 154 deletions

View File

@@ -29,11 +29,11 @@ jobs:
- name: Install dependencies - name: Install dependencies
run: | run: |
python -m pip install --upgrade pip 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 - name: Test with pytest
run: | run: |
pytest pytest
- name: Build - name: Build
run: | run: |
inv build inv build
inv mkdocs inv build.mkdocs

View File

@@ -1,11 +1,9 @@
name: Upload Python Package name: Upload Python Package
on: on:
push: release:
branches: [ main ] types: [published]
pull_request:
branches: [ main ]
permissions: permissions:
contents: read contents: read
@@ -32,7 +30,7 @@ jobs:
- name: Build package - name: Build package
run: python -m build run: python -m build
- name: Publish package to PyPI - name: Publish package to PyPI
if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags') if: github.event_name == 'release' && startsWith(github.ref, 'refs/tags')
uses: pypa/gh-action-pypi-publish@release/v1 uses: pypa/gh-action-pypi-publish@release/v1
with: with:
user: __token__ user: __token__

View File

@@ -9,7 +9,7 @@
Bluetooth Stack for Apps, Emulation, Test and Experimentation 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. 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 ### 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. 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. 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 ## License
Licensed under the [Apache 2.0](LICENSE) License. Licensed under the [Apache 2.0](LICENSE) License.

View File

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

@@ -123,6 +123,9 @@ class Peer:
async def subscribe(self, characteristic, subscriber=None): async def subscribe(self, characteristic, subscriber=None):
return await self.gatt_client.subscribe(characteristic, subscriber) 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): async def read_value(self, attribute):
return await self.gatt_client.read_value(attribute) return await self.gatt_client.read_value(attribute)
@@ -312,6 +315,8 @@ class DeviceConfiguration:
self.le_simultaneous_enabled = True self.le_simultaneous_enabled = True
self.classic_sc_enabled = True self.classic_sc_enabled = True
self.classic_ssp_enabled = True self.classic_ssp_enabled = True
self.connectable = True
self.discoverable = True
self.advertising_data = bytes( self.advertising_data = bytes(
AdvertisingData([(AdvertisingData.COMPLETE_LOCAL_NAME, bytes(self.name, 'utf-8'))]) AdvertisingData([(AdvertisingData.COMPLETE_LOCAL_NAME, bytes(self.name, 'utf-8'))])
) )
@@ -330,6 +335,8 @@ class DeviceConfiguration:
self.le_simultaneous_enabled = config.get('le_simultaneous_enabled', self.le_simultaneous_enabled) 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_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_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 # Load or synthesize an IRK
irk = config.get('irk') irk = config.get('irk')
@@ -443,7 +450,8 @@ class Device(CompositeEventEmitter):
self.command_timeout = 10 # seconds self.command_timeout = 10 # seconds
self.gatt_server = gatt_server.Server(self) self.gatt_server = gatt_server.Server(self)
self.sdp_server = sdp.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.advertisement_data = {}
self.scanning = False self.scanning = False
self.discovering = False self.discovering = False
@@ -451,8 +459,6 @@ class Device(CompositeEventEmitter):
self.disconnecting = False self.disconnecting = False
self.connections = {} # Connections, by connection handle self.connections = {} # Connections, by connection handle
self.classic_enabled = False self.classic_enabled = False
self.discoverable = False
self.connectable = False
self.inquiry_response = None self.inquiry_response = None
self.address_resolver = None self.address_resolver = None
@@ -473,6 +479,8 @@ class Device(CompositeEventEmitter):
self.le_simultaneous_enabled = config.le_simultaneous_enabled self.le_simultaneous_enabled = config.le_simultaneous_enabled
self.classic_ssp_enabled = config.classic_ssp_enabled self.classic_ssp_enabled = config.classic_ssp_enabled
self.classic_sc_enabled = config.classic_sc_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 a name is passed, override the name from the config
if name: if name:
@@ -487,6 +495,8 @@ class Device(CompositeEventEmitter):
# Setup SMP # Setup SMP
# TODO: allow using a public address # 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.l2cap_channel_manager.register_fixed_channel(
smp.SMP_CID, self.on_smp_pdu)
# Register the SDP server with the L2CAP Channel Manager # Register the SDP server with the L2CAP Channel Manager
self.sdp_server.register(self.l2cap_channel_manager) self.sdp_server.register(self.l2cap_channel_manager)
@@ -494,6 +504,7 @@ class Device(CompositeEventEmitter):
# Add a GAP Service if requested # Add a GAP Service if requested
if generic_access_service: if generic_access_service:
self.gatt_server.add_service(GenericAccessService(self.name)) self.gatt_server.add_service(GenericAccessService(self.name))
self.l2cap_channel_manager.register_fixed_channel(ATT_CID, self.on_gatt_pdu)
# Forward some events # Forward some events
setup_event_forwarding(self.gatt_server, self, 'characteristic_subscription') setup_event_forwarding(self.gatt_server, self, 'characteristic_subscription')
@@ -571,11 +582,12 @@ class Device(CompositeEventEmitter):
logger.debug(color(f'BD_ADDR: {response.return_parameters.bd_addr}', 'yellow')) logger.debug(color(f'BD_ADDR: {response.return_parameters.bd_addr}', 'yellow'))
self.public_address = response.return_parameters.bd_addr 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 self.le_enabled:
# Set the controller address # Set the controller address
await self.send_command(HCI_LE_Set_Random_Address_Command( await self.send_command(HCI_LE_Set_Random_Address_Command(
@@ -620,6 +632,8 @@ class Device(CompositeEventEmitter):
HCI_Write_Secure_Connections_Host_Support_Command( HCI_Write_Secure_Connections_Host_Support_Command(
secure_connections_host_support=int(self.classic_sc_enabled)) secure_connections_host_support=int(self.classic_sc_enabled))
) )
await self.set_connectable(self.connectable)
await self.set_discoverable(self.discoverable)
# Let the SMP manager know about the address # Let the SMP manager know about the address
# TODO: allow using a public address # TODO: allow using a public address
@@ -1494,7 +1508,6 @@ class Device(CompositeEventEmitter):
def on_pairing_failure(self, connection, reason): def on_pairing_failure(self, connection, reason):
connection.emit('pairing_failure', reason) connection.emit('pairing_failure', reason)
@host_event_handler
@with_connection_from_handle @with_connection_from_handle
def on_gatt_pdu(self, connection, pdu): def on_gatt_pdu(self, connection, pdu):
# Parse the L2CAP payload into an ATT PDU object # Parse the L2CAP payload into an ATT PDU object
@@ -1513,7 +1526,6 @@ class Device(CompositeEventEmitter):
return return
connection.gatt_server.on_gatt_pdu(connection, att_pdu) connection.gatt_server.on_gatt_pdu(connection, att_pdu)
@host_event_handler
@with_connection_from_handle @with_connection_from_handle
def on_smp_pdu(self, connection, pdu): def on_smp_pdu(self, connection, pdu):
self.smp_manager.on_smp_pdu(connection, pdu) self.smp_manager.on_smp_pdu(connection, pdu)

View File

@@ -110,6 +110,9 @@ class CharacteristicProxy(AttributeProxy):
async def subscribe(self, subscriber=None): async def subscribe(self, subscriber=None):
return await self.client.subscribe(self, subscriber) return await self.client.subscribe(self, subscriber)
async def unsubscribe(self, subscriber=None):
return await self.client.unsubscribe(self, subscriber)
def __str__(self): def __str__(self):
return f'Characteristic(handle=0x{self.handle:04X}, uuid={self.uuid}, properties={Characteristic.properties_as_string(self.properties)})' 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: for subscriber_set in subscriber_sets:
if subscriber is not None: if subscriber is not None:
subscriber_set.add(subscriber) 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) 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): async def read_value(self, attribute, no_long_read=False):
''' '''
See Vol 3, Part G - 4.8.1 Read Characteristic Value See Vol 3, Part G - 4.8.1 Read Characteristic Value
@@ -714,7 +743,10 @@ class Client:
if not subscribers: if not subscribers:
logger.warning('!!! received notification with no subscriber') logger.warning('!!! received notification with no subscriber')
for subscriber in subscribers: 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): def on_att_handle_value_indication(self, indication):
# Call all subscribers # Call all subscribers
@@ -722,7 +754,10 @@ class Client:
if not subscribers: if not subscribers:
logger.warning('!!! received indication with no subscriber') logger.warning('!!! received indication with no subscriber')
for subscriber in subscribers: 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 # Confirm that we received the indication
self.send_confirmation(ATT_Handle_Value_Confirmation()) self.send_confirmation(ATT_Handle_Value_Confirmation())

View File

@@ -56,13 +56,7 @@ class Connection:
def on_acl_pdu(self, pdu): def on_acl_pdu(self, pdu):
l2cap_pdu = L2CAP_PDU.from_bytes(pdu) l2cap_pdu = L2CAP_PDU.from_bytes(pdu)
self.host.on_l2cap_pdu(self, l2cap_pdu.cid, l2cap_pdu.payload)
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)
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
@@ -81,7 +75,7 @@ class Host(EventEmitter):
self.hc_total_num_acl_data_packets = HOST_HC_TOTAL_NUM_ACL_DATA_PACKETS self.hc_total_num_acl_data_packets = HOST_HC_TOTAL_NUM_ACL_DATA_PACKETS
self.acl_packet_queue = collections.deque() self.acl_packet_queue = collections.deque()
self.acl_packets_in_flight = 0 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_supported_commands = bytes(64)
self.local_le_features = 0 self.local_le_features = 0
self.command_semaphore = asyncio.Semaphore(1) self.command_semaphore = asyncio.Semaphore(1)
@@ -99,17 +93,18 @@ class Host(EventEmitter):
await self.send_command(HCI_Reset_Command()) await self.send_command(HCI_Reset_Command())
self.ready = True 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()) response = await self.send_command(HCI_Read_Local_Supported_Commands_Command())
if response.return_parameters.status == HCI_SUCCESS: if response.return_parameters.status == HCI_SUCCESS:
self.local_supported_commands = response.return_parameters.supported_commands self.local_supported_commands = response.return_parameters.supported_commands
else: else:
logger.warn(f'HCI_Read_Local_Supported_Commands_Command failed: {response.return_parameters.status}') logger.warn(f'HCI_Read_Local_Supported_Commands_Command failed: {response.return_parameters.status}')
if self.supports_command(HCI_WRITE_LE_HOST_SUPPORT_COMMAND): if self.supports_command(HCI_LE_READ_LOCAL_SUPPORTED_FEATURES_COMMAND):
await self.send_command(HCI_Write_LE_Host_Support_Command(le_supported_host = 1, simultaneous_le_host = 0)) 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): if self.supports_command(HCI_READ_LOCAL_VERSION_INFORMATION_COMMAND):
response = await self.send_command(HCI_Read_Local_Version_Information_Command()) response = await self.send_command(HCI_Read_Local_Version_Information_Command())
@@ -118,32 +113,44 @@ class Host(EventEmitter):
else: else:
logger.warn(f'HCI_Read_Local_Version_Information_Command failed: {response.return_parameters.status}') 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): if self.supports_command(HCI_LE_READ_BUFFER_SIZE_COMMAND):
response = await self.send_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: 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 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: else:
logger.warn(f'HCI_LE_Read_Buffer_Size_Command failed: {response.return_parameters.status}') 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): if response.return_parameters.hc_le_acl_data_packet_length == 0 or response.return_parameters.hc_total_num_le_acl_data_packets == 0:
response = await self.send_command(HCI_LE_Read_Local_Supported_Features_Command()) # LE and Classic share the same values
if response.return_parameters.status == HCI_SUCCESS: self.hc_le_acl_data_packet_length = self.hc_acl_data_packet_length
self.local_le_features = struct.unpack('<Q', response.return_parameters.le_features)[0] self.hc_total_num_le_acl_data_packets = self.hc_total_num_acl_data_packets
else:
logger.warn(f'HCI_LE_Read_Supported_Features_Command failed: {response.return_parameters.status}') 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 self.reset_done = True
@@ -168,8 +175,8 @@ class Host(EventEmitter):
# Wait until we can send (only one pending command at a time) # Wait until we can send (only one pending command at a time)
async with self.command_semaphore: async with self.command_semaphore:
assert(self.pending_command is None) assert self.pending_command is None
assert(self.pending_response is None) assert self.pending_response is None
# Create a future value to hold the eventual response # Create a future value to hold the eventual response
self.pending_response = asyncio.get_running_loop().create_future() self.pending_response = asyncio.get_running_loop().create_future()
@@ -202,6 +209,7 @@ class Host(EventEmitter):
offset = 0 offset = 0
pb_flag = 0 pb_flag = 0
while bytes_remaining: while bytes_remaining:
# TODO: support different LE/Classic lengths
data_total_length = min(bytes_remaining, self.hc_le_acl_data_packet_length) data_total_length = min(bytes_remaining, self.hc_le_acl_data_packet_length)
acl_packet = HCI_AclDataPacket( acl_packet = HCI_AclDataPacket(
connection_handle = connection_handle, connection_handle = connection_handle,
@@ -224,7 +232,7 @@ class Host(EventEmitter):
logger.debug(f'{self.acl_packets_in_flight} ACL packets in flight, {len(self.acl_packet_queue)} in queue') 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): 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: 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() packet = self.acl_packet_queue.pop()
self.send_hci_packet(packet) self.send_hci_packet(packet)
@@ -299,12 +307,6 @@ class Host(EventEmitter):
if connection := self.connections.get(packet.connection_handle): if connection := self.connections.get(packet.connection_handle):
connection.on_hci_acl_data_packet(packet) 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): def on_l2cap_pdu(self, connection, cid, pdu):
self.emit('l2cap_pdu', connection.handle, cid, pdu) self.emit('l2cap_pdu', connection.handle, cid, pdu)

View File

@@ -20,11 +20,11 @@ import logging
import struct import struct
from colors import color from colors import color
from pyee import EventEmitter
from .core import BT_CENTRAL_ROLE, InvalidStateError, ProtocolError from .core import BT_CENTRAL_ROLE, InvalidStateError, ProtocolError
from .hci import (HCI_LE_Connection_Update_Command, HCI_Object, key_with_value, from .hci import (HCI_LE_Connection_Update_Command, HCI_Object, key_with_value,
name_or_number) name_or_number)
from .utils import EventEmitter
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
# Logging # Logging
@@ -414,6 +414,18 @@ class L2CAP_Information_Request(L2CAP_Control_Frame):
EXTENDED_FEATURES_SUPPORTED = 0x0002 EXTENDED_FEATURES_SUPPORTED = 0x0002
FIXED_CHANNELS_SUPPORTED = 0x0003 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 = { INFO_TYPE_NAMES = {
CONNECTIONLESS_MTU: 'CONNECTIONLESS_MTU', CONNECTIONLESS_MTU: 'CONNECTIONLESS_MTU',
EXTENDED_FEATURES_SUPPORTED: 'EXTENDED_FEATURES_SUPPORTED', EXTENDED_FEATURES_SUPPORTED: 'EXTENDED_FEATURES_SUPPORTED',
@@ -817,11 +829,16 @@ class Channel(EventEmitter):
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
class ChannelManager: class ChannelManager:
def __init__(self): def __init__(self, extended_features=None, connectionless_mtu=1024):
self.host = None self.host = None
self.channels = {} # Channels, mapped by connection and cid self.channels = {} # Channels, mapped by connection and cid
self.identifiers = {} # Incrementing identifier values by connection # Fixed channel handlers, mapped by cid
self.servers = {} # Servers accepting connections, by PSM 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): def find_channel(self, connection_handle, cid):
if connection_channels := self.channels.get(connection_handle): if connection_channels := self.channels.get(connection_handle):
@@ -840,6 +857,13 @@ class ChannelManager:
identifier = (self.identifiers.setdefault(connection.handle, 0) + 1) % 256 identifier = (self.identifiers.setdefault(connection.handle, 0) + 1) % 256
self.identifiers[connection.handle] = identifier self.identifiers[connection.handle] = identifier
return 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): def register_server(self, psm, server):
self.servers[psm] = server self.servers[psm] = server
@@ -855,6 +879,8 @@ class ChannelManager:
control_frame = L2CAP_Control_Frame.from_bytes(pdu) control_frame = L2CAP_Control_Frame.from_bytes(pdu)
self.on_control_frame(connection, cid, control_frame) self.on_control_frame(connection, cid, control_frame)
elif cid in self.fixed_channels:
self.fixed_channels[cid](connection.handle, pdu)
else: else:
if (channel := self.find_channel(connection.handle, cid)) is None: 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.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): def on_l2cap_information_request(self, connection, cid, request):
if request.info_type == L2CAP_Information_Request.CONNECTIONLESS_MTU: if request.info_type == L2CAP_Information_Request.CONNECTIONLESS_MTU:
result = L2CAP_Information_Response.SUCCESS 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: elif request.info_type == L2CAP_Information_Request.EXTENDED_FEATURES_SUPPORTED:
result = L2CAP_Information_Response.SUCCESS 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: elif request.info_type == L2CAP_Information_Request.FIXED_CHANNELS_SUPPORTED:
result = L2CAP_Information_Response.SUCCESS 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: else:
result = L2CAP_Information_Request.NO_SUPPORTED result = L2CAP_Information_Request.NO_SUPPORTED

View File

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

View File

@@ -37,16 +37,20 @@ async def open_usb_transport(spec):
''' '''
Open a USB transport. Open a USB transport.
The parameter string has this syntax: 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 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 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 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 /<serial-number> suffix or #<index> suffix max be specified when more than one device with
vendor and product identifiers are present. the same vendor and product identifiers are present.
Examples: Examples:
0 --> the first BT USB dongle 0 --> the first BT USB dongle
04b4:f901 --> the BT USB dongle with vendor=04b4 and product=f901 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 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): def on_packet_received(self, transfer):
packet_type = transfer.getUserData() packet_type = transfer.getUserData()
status = transfer.getStatus() 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: if status == usb1.TRANSFER_COMPLETED:
packet = bytes([packet_type]) + transfer.getBuffer()[:transfer.getActualLength()] packet = bytes([packet_type]) + transfer.getBuffer()[:transfer.getActualLength()]
@@ -271,19 +275,25 @@ async def open_usb_transport(spec):
found = None found = None
if ':' in spec: if ':' in spec:
vendor_id, product_id = spec.split(':') vendor_id, product_id = spec.split(':')
serial_number = None
device_index = 0
if '/' in product_id: if '/' in product_id:
product_id, serial_number = product_id.split('/') product_id, serial_number = product_id.split('/')
for device in context.getDeviceIterator(skip_on_error=True): elif '#' in product_id:
if ( product_id, device_index_str = product_id.split('#')
device.getVendorID() == int(vendor_id, 16) and device_index = int(device_index_str)
device.getProductID() == int(product_id, 16) and
device.getSerialNumber() == serial_number 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 found = device
break break
device.close() device_index -= 1
else: device.close()
found = context.getByVendorIDAndProductID(int(vendor_id, 16), int(product_id, 16), skip_on_error=True)
else: else:
device_index = int(spec) device_index = int(spec)
for device in context.getDeviceIterator(skip_on_error=True): 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}') logger.debug(f'USB Device: {found}')
device = found.open() 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 # Use the first interface
interface = 0 interface = 0
@@ -328,6 +327,20 @@ async def open_usb_transport(spec):
except usb1.USBError: except usb1.USBError:
pass 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) source = UsbPacketSource(context, device)
sink = UsbPacketSink(device) sink = UsbPacketSink(device)
return UsbTransport(context, device, interface, source, sink) 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 * scanning
* advertising * advertising
* connecting to devices * connecting to and disconnecting from devices
* changing connection parameters * changing connection parameters
* enabling encryption
* discovering GATT services and characteristics * 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: 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. The USB transport interfaces with a local Bluetooth USB dongle.
## Moniker ## Moniker
The moniker for a USB transport is either `usb:<index>` or `usb:<vendor>:<product>` The moniker for a USB transport is either:
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. * `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` `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` `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 ## Alternative
The library includes two different implementations of the USB transport, implemented using different python bindings for `libusb`. 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. 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

@@ -24,7 +24,7 @@ url = https://github.com/google/bumble
[options] [options]
python_requires = >=3.8 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 = package_dir =
bumble = bumble bumble = bumble
bumble.apps = apps bumble.apps = apps
@@ -54,15 +54,17 @@ console_scripts =
bumble-scan = bumble.apps.scan:main bumble-scan = bumble.apps.scan:main
bumble-show = bumble.apps.show:main bumble-show = bumble.apps.show:main
bumble-unbond = bumble.apps.unbond: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 bumble-link-relay = bumble.apps.link_relay.link_relay:main
[options.extras_require] [options.extras_require]
build =
build >= 0.7
test = test =
pytest >= 6.2 pytest >= 6.2
pytest-asyncio >= 0.17 pytest-asyncio >= 0.17
development = development =
invoke >= 1.4 invoke >= 1.4
build >= 0.7
nox >= 2022 nox >= 2022
documentation = documentation =
mkdocs >= 1.2.3 mkdocs >= 1.2.3

View File

@@ -23,35 +23,52 @@ ROOT_DIR = os.path.dirname(os.path.realpath(__file__))
ns = Collection() ns = Collection()
# Building
build_tasks = Collection() build_tasks = Collection()
ns.add_collection(build_tasks, name='build') ns.add_collection(build_tasks, name="build")
@task @task
def build(ctx): def build(ctx, install=False):
ctx.run('python -m build') 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() test_tasks = Collection()
ns.add_collection(test_tasks, name='test') ns.add_collection(test_tasks, name="test")
@task @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 = "" args = ""
if junit: if junit:
args += "--junit-xml test-results.xml" args += "--junit-xml test-results.xml"
if filter is not None: if filter is not None:
args += " -k '{}'".format(filter) args += " -k '{}'".format(filter)
ctx.run('python -m pytest {} {}' ctx.run("python -m pytest {} {}".format(os.path.join(ROOT_DIR, "tests"), args))
.format(os.path.join(ROOT_DIR, "tests"), args))
test_tasks.add_task(test, name='test', default=True)
test_tasks.add_task(test, default=True)
@task @task
def mkdocs(ctx): def release_test(ctx):
ctx.run('mkdocs build -f docs/mkdocs/mkdocs.yml') test(ctx, install=True)
test_tasks.add_task(release_test, name="release")
ns.add_task(mkdocs)

View File

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