Compare commits

...

12 Commits

Author SHA1 Message Date
Gilles Boccon-Gibod
6ff52df8bd better/safer Linux recommendations 2022-10-10 20:11:55 -07:00
Gilles Boccon-Gibod
86618e52ef Refactor and improve the doc for Bumble on Linux 2022-10-09 12:56:06 -07:00
Gilles Boccon-Gibod
fbb46dd736 Merge pull request #41 from google/gbg/cli-scripts
use arg-less main() functions in all scripts
2022-10-07 16:16:35 -07:00
Gilles Boccon-Gibod
d1e119f176 use arg-less main() functions in all scripts 2022-10-07 13:56:42 -07:00
Gilles Boccon-Gibod
2fc7a0bf04 Merge pull request #39 from google/gbg/usb-descriptors
improve USB device detection logic
2022-10-06 15:39:32 -07:00
Gilles Boccon-Gibod
d6c4644b23 reorder the order of printing 2022-10-06 10:40:28 -07:00
Gilles Boccon-Gibod
073757d5dd Merge pull request #40 from google/gbg/gatt-mtu
maintain the att mtu only at the connection level
2022-10-05 13:53:47 -07:00
Gilles Boccon-Gibod
20dedbd923 maintain the att mtu only at the connection level 2022-10-04 20:04:43 -07:00
Octavian Purdila
df1962e8da apps/usb_probe.py: handle libusb1 exceptions
Some USB device properties are only accessible if the user has the
appropriate permissions. Handle libusb1 errors to graciously skip
showing details for these devices.
2022-10-04 23:38:13 +00:00
Gilles Boccon-Gibod
0edd6b731f Merge pull request #37 from google/gbg/gatt-notify-with-value
add support for notifying with a transient value
2022-10-04 10:33:04 -07:00
Gilles Boccon-Gibod
d2227f017f improve USB device detection logic 2022-10-04 09:59:48 -07:00
Gilles Boccon-Gibod
80569bc9f3 add support for notifying with a transient value 2022-09-06 12:42:35 -07:00
15 changed files with 698 additions and 255 deletions

View File

@@ -90,7 +90,7 @@ class SnoopPacketReader:
@click.command() @click.command()
@click.option('--format', type=click.Choice(['h4', 'snoop']), default='h4', help='Format of the input file') @click.option('--format', type=click.Choice(['h4', 'snoop']), default='h4', help='Format of the input file')
@click.argument('filename') @click.argument('filename')
def show(format, filename): def main(format, filename):
input = open(filename, 'rb') input = open(filename, 'rb')
if format == 'h4': if format == 'h4':
packet_reader = PacketReader(input) packet_reader = PacketReader(input)
@@ -117,4 +117,4 @@ def show(format, filename):
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
if __name__ == '__main__': if __name__ == '__main__':
show() main()

View File

@@ -28,6 +28,8 @@
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
import os import os
import logging import logging
import sys
import click
import usb1 import usb1
from colors import color from colors import color
@@ -35,6 +37,7 @@ from colors import color
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
# Constants # Constants
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
USB_DEVICE_CLASS_DEVICE = 0x00
USB_DEVICE_CLASS_WIRELESS_CONTROLLER = 0xE0 USB_DEVICE_CLASS_WIRELESS_CONTROLLER = 0xE0
USB_DEVICE_SUBCLASS_RF_CONTROLLER = 0x01 USB_DEVICE_SUBCLASS_RF_CONTROLLER = 0x01
USB_DEVICE_PROTOCOL_BLUETOOTH_PRIMARY_CONTROLLER = 0x01 USB_DEVICE_PROTOCOL_BLUETOOTH_PRIMARY_CONTROLLER = 0x01
@@ -75,9 +78,81 @@ USB_DEVICE_CLASSES = {
0xFF: 'Vendor Specific' 0xFF: 'Vendor Specific'
} }
USB_ENDPOINT_IN = 0x80
USB_ENDPOINT_TYPES = ['CONTROL', 'ISOCHRONOUS', 'BULK', 'INTERRUPT']
USB_BT_HCI_CLASS_TUPLE = (
USB_DEVICE_CLASS_WIRELESS_CONTROLLER,
USB_DEVICE_SUBCLASS_RF_CONTROLLER,
USB_DEVICE_PROTOCOL_BLUETOOTH_PRIMARY_CONTROLLER
)
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
def main(): def show_device_details(device):
for configuration in device:
print(f' Configuration {configuration.getConfigurationValue()}')
for interface in configuration:
for setting in interface:
alternateSetting = setting.getAlternateSetting()
suffix = f'/{alternateSetting}' if interface.getNumSettings() > 1 else ''
(class_string, subclass_string) = get_class_info(
setting.getClass(),
setting.getSubClass(),
setting.getProtocol()
)
details = f'({class_string}, {subclass_string})'
print(f' Interface: {setting.getNumber()}{suffix} {details}')
for endpoint in setting:
endpoint_type = USB_ENDPOINT_TYPES[endpoint.getAttributes() & 3]
endpoint_direction = 'OUT' if (endpoint.getAddress() & USB_ENDPOINT_IN == 0) else 'IN'
print(f' Endpoint 0x{endpoint.getAddress():02X}: {endpoint_type} {endpoint_direction}')
# -----------------------------------------------------------------------------
def get_class_info(cls, subclass, protocol):
class_info = USB_DEVICE_CLASSES.get(cls)
protocol_string = ''
if class_info is None:
class_string = f'0x{cls:02X}'
else:
if type(class_info) is tuple:
class_string = class_info[0]
subclass_info = class_info[1].get(subclass)
if subclass_info:
protocol_string = subclass_info.get(protocol)
if protocol_string is not None:
protocol_string = f' [{protocol_string}]'
else:
class_string = class_info
subclass_string = f'{subclass}/{protocol}{protocol_string}'
return (class_string, subclass_string)
# -----------------------------------------------------------------------------
def is_bluetooth_hci(device):
# Check if the device class indicates a match
if (device.getDeviceClass(), device.getDeviceSubClass(), device.getDeviceProtocol()) == USB_BT_HCI_CLASS_TUPLE:
return True
# If the device class is 'Device', look for a matching interface
if device.getDeviceClass() == USB_DEVICE_CLASS_DEVICE:
for configuration in device:
for interface in configuration:
for setting in interface:
if (setting.getClass(), setting.getSubClass(), setting.getProtocol()) == USB_BT_HCI_CLASS_TUPLE:
return True
return False
# -----------------------------------------------------------------------------
@click.command()
@click.option('--verbose', is_flag=True, default=False, help='Print more details')
def main(verbose):
logging.basicConfig(level = os.environ.get('BUMBLE_LOGLEVEL', 'WARNING').upper()) logging.basicConfig(level = os.environ.get('BUMBLE_LOGLEVEL', 'WARNING').upper())
with usb1.USBContext() as context: with usb1.USBContext() as context:
@@ -91,23 +166,28 @@ def main():
device_id = (device.getVendorID(), device.getProductID()) device_id = (device.getVendorID(), device.getProductID())
device_is_bluetooth_hci = ( (device_class_string, device_subclass_string) = get_class_info(
device_class == USB_DEVICE_CLASS_WIRELESS_CONTROLLER and device_class,
device_subclass == USB_DEVICE_SUBCLASS_RF_CONTROLLER and device_subclass,
device_protocol == USB_DEVICE_PROTOCOL_BLUETOOTH_PRIMARY_CONTROLLER device_protocol
) )
device_class_details = '' try:
device_class_info = USB_DEVICE_CLASSES.get(device_class) device_serial_number = device.getSerialNumber()
if device_class_info is not None: except usb1.USBError:
if type(device_class_info) is tuple: device_serial_number = None
device_class = device_class_info[0]
device_subclass_info = device_class_info[1].get(device_subclass)
if device_subclass_info:
device_class_details = f' [{device_subclass_info.get(device_protocol)}]'
else:
device_class = device_class_info
try:
device_manufacturer = device.getManufacturer()
except usb1.USBError:
device_manufacturer = None
try:
device_product = device.getProduct()
except usb1.USBError:
device_product = None
device_is_bluetooth_hci = is_bluetooth_hci(device)
if device_is_bluetooth_hci: if device_is_bluetooth_hci:
bluetooth_device_count += 1 bluetooth_device_count += 1
fg_color = 'black' fg_color = 'black'
@@ -123,33 +203,35 @@ def main():
if device_is_bluetooth_hci: if device_is_bluetooth_hci:
bumble_transport_names.append(f'usb:{bluetooth_device_count - 1}') 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: if device_id not in devices:
bumble_transport_names.append(basic_transport_name) bumble_transport_names.append(basic_transport_name)
else: else:
bumble_transport_names.append(f'{basic_transport_name}#{len(devices[device_id])}') bumble_transport_names.append(f'{basic_transport_name}#{len(devices[device_id])}')
if device.getSerialNumber() and not serial_number_collision: if device_serial_number is not None:
bumble_transport_names.append(f'{basic_transport_name}/{device.getSerialNumber()}') if device_id not in devices or device_serial_number not in devices[device_id]:
bumble_transport_names.append(f'{basic_transport_name}/{device_serial_number}')
# Print the results
print(color(f'ID {device.getVendorID():04X}:{device.getProductID():04X}', fg=fg_color, bg=bg_color)) print(color(f'ID {device.getVendorID():04X}:{device.getProductID():04X}', fg=fg_color, bg=bg_color))
if bumble_transport_names: 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(' 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}') print(color(' Bus/Device: ', 'green'), f'{device.getBusNumber():03}/{device.getDeviceAddress():03}')
if device.getSerialNumber(): print(color(' Class: ', 'green'), device_class_string)
print(color(' Serial: ', 'green'), device.getSerialNumber()) print(color(' Subclass/Protocol: ', 'green'), device_subclass_string)
print(color(' Class: ', 'green'), device_class) if device_serial_number is not None:
print(color(' Subclass/Protocol: ', 'green'), f'{device_subclass}/{device_protocol}{device_class_details}') print(color(' Serial: ', 'green'), device_serial_number)
print(color(' Manufacturer: ', 'green'), device.getManufacturer()) if device_manufacturer is not None:
print(color(' Product: ', 'green'), device.getProduct()) print(color(' Manufacturer: ', 'green'), device_manufacturer)
if device_product is not None:
print(color(' Product: ', 'green'), device_product)
if verbose:
show_device_details(device)
print() print()
devices.setdefault(device_id, []).append(device.getSerialNumber()) devices.setdefault(device_id, []).append(device_serial_number)
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------

View File

@@ -700,16 +700,26 @@ class Attribute(EventEmitter):
else: else:
self.value = value self.value = value
def encode_value(self, value):
return value
def decode_value(self, value_bytes):
return value_bytes
def read_value(self, connection): def read_value(self, connection):
if read := getattr(self.value, 'read', None): if read := getattr(self.value, 'read', None):
try: try:
return read(connection) value = read(connection)
except ATT_Error as error: except ATT_Error as error:
raise ATT_Error(error_code=error.error_code, att_handle=self.handle) raise ATT_Error(error_code=error.error_code, att_handle=self.handle)
else: else:
return self.value value = self.value
return self.encode_value(value)
def write_value(self, connection, value_bytes):
value = self.decode_value(value_bytes)
def write_value(self, connection, value):
if write := getattr(self.value, 'write', None): if write := getattr(self.value, 'write', None):
try: try:
write(connection, value) write(connection, value)
@@ -721,7 +731,11 @@ class Attribute(EventEmitter):
self.emit('write', connection, value) self.emit('write', connection, value)
def __repr__(self): def __repr__(self):
if len(self.value) > 0: if type(self.value) is bytes:
value_str = self.value.hex()
else:
value_str = str(self.value)
if value_str:
value_string = f', value={self.value.hex()}' value_string = f', value={self.value.hex()}'
else: else:
value_string = '' value_string = ''

View File

@@ -1210,17 +1210,17 @@ class Device(CompositeEventEmitter):
def add_services(self, services): def add_services(self, services):
self.gatt_server.add_services(services) self.gatt_server.add_services(services)
async def notify_subscriber(self, connection, attribute, force=False): async def notify_subscriber(self, connection, attribute, value=None, force=False):
await self.gatt_server.notify_subscriber(connection, attribute, force) await self.gatt_server.notify_subscriber(connection, attribute, value, force)
async def notify_subscribers(self, attribute, force=False): async def notify_subscribers(self, attribute, value=None, force=False):
await self.gatt_server.notify_subscribers(attribute, force) await self.gatt_server.notify_subscribers(attribute, value, force)
async def indicate_subscriber(self, connection, attribute, force=False): async def indicate_subscriber(self, connection, attribute, value=None, force=False):
await self.gatt_server.indicate_subscriber(connection, attribute, force) await self.gatt_server.indicate_subscriber(connection, attribute, value, force)
async def indicate_subscribers(self, attribute): async def indicate_subscribers(self, attribute, value=None, force=False):
await self.gatt_server.indicate_subscribers(attribute) await self.gatt_server.indicate_subscribers(attribute, value, force)
@host_event_handler @host_event_handler
def on_connection(self, connection_handle, transport, peer_address, peer_resolvable_address, role, connection_parameters): def on_connection(self, connection_handle, transport, peer_address, peer_resolvable_address, role, connection_parameters):

View File

@@ -303,6 +303,7 @@ class CharacteristicAdapter:
''' '''
def __init__(self, characteristic): def __init__(self, characteristic):
self.wrapped_characteristic = characteristic self.wrapped_characteristic = characteristic
self.subscribers = {} # Map from subscriber to proxy subscriber
if ( if (
asyncio.iscoroutinefunction(characteristic.read_value) and asyncio.iscoroutinefunction(characteristic.read_value) and
@@ -317,11 +318,21 @@ class CharacteristicAdapter:
if hasattr(self.wrapped_characteristic, 'subscribe'): if hasattr(self.wrapped_characteristic, 'subscribe'):
self.subscribe = self.wrapped_subscribe self.subscribe = self.wrapped_subscribe
if hasattr(self.wrapped_characteristic, 'unsubscribe'):
self.unsubscribe = self.wrapped_unsubscribe
def __getattr__(self, name): def __getattr__(self, name):
return getattr(self.wrapped_characteristic, name) return getattr(self.wrapped_characteristic, name)
def __setattr__(self, name, value): def __setattr__(self, name, value):
if name in {'wrapped_characteristic', 'read_value', 'write_value', 'subscribe'}: if name in {
'wrapped_characteristic',
'subscribers',
'read_value',
'write_value',
'subscribe',
'unsubscribe'
}:
super().__setattr__(name, value) super().__setattr__(name, value)
else: else:
setattr(self.wrapped_characteristic, name, value) setattr(self.wrapped_characteristic, name, value)
@@ -345,9 +356,26 @@ class CharacteristicAdapter:
return value return value
def wrapped_subscribe(self, subscriber=None): def wrapped_subscribe(self, subscriber=None):
return self.wrapped_characteristic.subscribe( if subscriber is not None:
None if subscriber is None else lambda value: subscriber(self.decode_value(value)) if subscriber in self.subscribers:
) # We already have a proxy subscriber
subscriber = self.subscribers[subscriber]
else:
# Create and register a proxy that will decode the value
original_subscriber = subscriber
def on_change(value):
original_subscriber(self.decode_value(value))
self.subscribers[subscriber] = on_change
subscriber = on_change
return self.wrapped_characteristic.subscribe(subscriber)
def wrapped_unsubscribe(self, subscriber=None):
if subscriber in self.subscribers:
subscriber = self.subscribers.pop(subscriber)
return self.wrapped_characteristic.unsubscribe(subscriber)
def __str__(self): def __str__(self):
wrapped = str(self.wrapped_characteristic) wrapped = str(self.wrapped_characteristic)

View File

@@ -58,10 +58,16 @@ class AttributeProxy(EventEmitter):
self.type = attribute_type self.type = attribute_type
async def read_value(self, no_long_read=False): async def read_value(self, no_long_read=False):
return await self.client.read_value(self.handle, no_long_read) return self.decode_value(await self.client.read_value(self.handle, no_long_read))
async def write_value(self, value, with_response=False): async def write_value(self, value, with_response=False):
return await self.client.write_value(self.handle, value, with_response) return await self.client.write_value(self.handle, self.encode_value(value), with_response)
def encode_value(self, value):
return value
def decode_value(self, value_bytes):
return value_bytes
def __str__(self): def __str__(self):
return f'Attribute(handle=0x{self.handle:04X}, type={self.uuid})' return f'Attribute(handle=0x{self.handle:04X}, type={self.uuid})'
@@ -98,6 +104,7 @@ class CharacteristicProxy(AttributeProxy):
self.properties = properties self.properties = properties
self.descriptors = [] self.descriptors = []
self.descriptors_discovered = False self.descriptors_discovered = False
self.subscribers = {} # Map from subscriber to proxy subscriber
def get_descriptor(self, descriptor_type): def get_descriptor(self, descriptor_type):
for descriptor in self.descriptors: for descriptor in self.descriptors:
@@ -108,9 +115,25 @@ class CharacteristicProxy(AttributeProxy):
return await self.client.discover_descriptors(self) return await self.client.discover_descriptors(self)
async def subscribe(self, subscriber=None): async def subscribe(self, subscriber=None):
if subscriber is not None:
if subscriber in self.subscribers:
# We already have a proxy subscriber
subscriber = self.subscribers[subscriber]
else:
# Create and register a proxy that will decode the value
original_subscriber = subscriber
def on_change(value):
original_subscriber(self.decode_value(value))
self.subscribers[subscriber] = on_change
subscriber = on_change
return await self.client.subscribe(self, subscriber) return await self.client.subscribe(self, subscriber)
async def unsubscribe(self, subscriber=None): async def unsubscribe(self, subscriber=None):
if subscriber in self.subscribers:
subscriber = self.subscribers.pop(subscriber)
return await self.client.unsubscribe(self, subscriber) return await self.client.unsubscribe(self, subscriber)
def __str__(self): def __str__(self):
@@ -140,7 +163,6 @@ class ProfileServiceProxy:
class Client: class Client:
def __init__(self, connection): def __init__(self, connection):
self.connection = connection self.connection = connection
self.mtu = ATT_DEFAULT_MTU
self.mtu_exchange_done = False self.mtu_exchange_done = False
self.request_semaphore = asyncio.Semaphore(1) self.request_semaphore = asyncio.Semaphore(1)
self.pending_request = None self.pending_request = None
@@ -194,7 +216,7 @@ class Client:
# We can only send one request per connection # We can only send one request per connection
if self.mtu_exchange_done: if self.mtu_exchange_done:
return return self.connection.att_mtu
# Send the request # Send the request
self.mtu_exchange_done = True self.mtu_exchange_done = True
@@ -207,8 +229,10 @@ class Client:
response response
) )
self.mtu = max(ATT_DEFAULT_MTU, response.server_rx_mtu) # Compute the final MTU
return self.mtu self.connection.att_mtu = min(mtu, response.server_rx_mtu)
return self.connection.att_mtu
def get_services_by_uuid(self, uuid): def get_services_by_uuid(self, uuid):
return [service for service in self.services if service.uuid == uuid] return [service for service in self.services if service.uuid == uuid]
@@ -570,12 +594,18 @@ class Client:
subscribers = subscriber_set.get(characteristic.handle, []) subscribers = subscriber_set.get(characteristic.handle, [])
if subscriber in subscribers: if subscriber in subscribers:
subscribers.remove(subscriber) subscribers.remove(subscriber)
# Cleanup if we removed the last one
if not subscribers:
subscriber_set.remove(characteristic.handle)
else: else:
# Remove all subscribers for this attribute from the sets! # Remove all subscribers for this attribute from the sets!
self.notification_subscribers.pop(characteristic.handle, None) self.notification_subscribers.pop(characteristic.handle, None)
self.indication_subscribers.pop(characteristic.handle, None) self.indication_subscribers.pop(characteristic.handle, None)
await self.write_value(cccd, b'\x00\x00', with_response=True) if not self.notification_subscribers and not self.indication_subscribers:
# No more subscribers left
await self.write_value(cccd, b'\x00\x00', with_response=True)
async def read_value(self, attribute, no_long_read=False): async def read_value(self, attribute, no_long_read=False):
''' '''
@@ -600,7 +630,7 @@ class Client:
# If the value is the max size for the MTU, try to read more unless the caller # If the value is the max size for the MTU, try to read more unless the caller
# specifically asked not to do that # specifically asked not to do that
attribute_value = response.attribute_value attribute_value = response.attribute_value
if not no_long_read and len(attribute_value) == self.mtu - 1: if not no_long_read and len(attribute_value) == self.connection.att_mtu - 1:
logger.debug('using READ BLOB to get the rest of the value') logger.debug('using READ BLOB to get the rest of the value')
offset = len(attribute_value) offset = len(attribute_value)
while True: while True:
@@ -622,7 +652,7 @@ class Client:
part = response.part_attribute_value part = response.part_attribute_value
attribute_value += part attribute_value += part
if len(part) < self.mtu - 1: if len(part) < self.connection.att_mtu - 1:
break break
offset += len(part) offset += len(part)

View File

@@ -40,6 +40,12 @@ from .gatt import *
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
# -----------------------------------------------------------------------------
# Constants
# -----------------------------------------------------------------------------
GATT_SERVER_DEFAULT_MAX_MTU = 517
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
# GATT Server # GATT Server
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
@@ -49,9 +55,8 @@ class Server(EventEmitter):
self.device = device self.device = device
self.attributes = [] # Attributes, ordered by increasing handle values self.attributes = [] # Attributes, ordered by increasing handle values
self.attributes_by_handle = {} # Map for fast attribute access by handle self.attributes_by_handle = {} # Map for fast attribute access by handle
self.max_mtu = 23 # FIXME: 517 # The max MTU we're willing to negotiate self.max_mtu = GATT_SERVER_DEFAULT_MAX_MTU # The max MTU we're willing to negotiate
self.subscribers = {} # Map of subscriber states by connection handle and attribute handle self.subscribers = {} # Map of subscriber states by connection handle and attribute handle
self.mtus = {} # Map of ATT MTU values by connection handle
self.indication_semaphores = defaultdict(lambda: asyncio.Semaphore(1)) self.indication_semaphores = defaultdict(lambda: asyncio.Semaphore(1))
self.pending_confirmations = defaultdict(lambda: None) self.pending_confirmations = defaultdict(lambda: None)
@@ -169,7 +174,7 @@ class Server(EventEmitter):
logger.debug(f'GATT Response from server: [0x{connection.handle:04X}] {response}') logger.debug(f'GATT Response from server: [0x{connection.handle:04X}] {response}')
self.send_gatt_pdu(connection.handle, response.to_bytes()) self.send_gatt_pdu(connection.handle, response.to_bytes())
async def notify_subscriber(self, connection, attribute, force=False): async def notify_subscriber(self, connection, attribute, value=None, force=False):
# Check if there's a subscriber # Check if there's a subscriber
if not force: if not force:
subscribers = self.subscribers.get(connection.handle) subscribers = self.subscribers.get(connection.handle)
@@ -184,13 +189,12 @@ class Server(EventEmitter):
logger.debug(f'not notifying, cccd={cccd.hex()}') logger.debug(f'not notifying, cccd={cccd.hex()}')
return return
# Get the value # Get or encode the value
value = attribute.read_value(connection) value = attribute.read_value(connection) if value is None else attribute.encode_value(value)
# Truncate if needed # Truncate if needed
mtu = self.get_mtu(connection) if len(value) > connection.att_mtu - 3:
if len(value) > mtu - 3: value = value[:connection.att_mtu - 3]
value = value[:mtu - 3]
# Notify # Notify
notification = ATT_Handle_Value_Notification( notification = ATT_Handle_Value_Notification(
@@ -198,27 +202,9 @@ class Server(EventEmitter):
attribute_value = value attribute_value = value
) )
logger.debug(f'GATT Notify from server: [0x{connection.handle:04X}] {notification}') logger.debug(f'GATT Notify from server: [0x{connection.handle:04X}] {notification}')
self.send_gatt_pdu(connection.handle, notification.to_bytes()) self.send_gatt_pdu(connection.handle, bytes(notification))
async def notify_subscribers(self, attribute, force=False): async def indicate_subscriber(self, connection, attribute, value=None, force=False):
# Get all the connections for which there's at least one subscription
connections = [
connection for connection in [
self.device.lookup_connection(connection_handle)
for (connection_handle, subscribers) in self.subscribers.items()
if force or subscribers.get(attribute.handle)
]
if connection is not None
]
# Notify for each connection
if connections:
await asyncio.wait([
self.notify_subscriber(connection, attribute, force)
for connection in connections
])
async def indicate_subscriber(self, connection, attribute, force=False):
# Check if there's a subscriber # Check if there's a subscriber
if not force: if not force:
subscribers = self.subscribers.get(connection.handle) subscribers = self.subscribers.get(connection.handle)
@@ -233,13 +219,12 @@ class Server(EventEmitter):
logger.debug(f'not indicating, cccd={cccd.hex()}') logger.debug(f'not indicating, cccd={cccd.hex()}')
return return
# Get the value # Get or encode the value
value = attribute.read_value(connection) value = attribute.read_value(connection) if value is None else attribute.encode_value(value)
# Truncate if needed # Truncate if needed
mtu = self.get_mtu(connection) if len(value) > connection.att_mtu - 3:
if len(value) > mtu - 3: value = value[:connection.att_mtu - 3]
value = value[:mtu - 3]
# Indicate # Indicate
indication = ATT_Handle_Value_Indication( indication = ATT_Handle_Value_Indication(
@@ -264,27 +249,32 @@ class Server(EventEmitter):
finally: finally:
self.pending_confirmations[connection.handle] = None self.pending_confirmations[connection.handle] = None
async def indicate_subscribers(self, attribute): async def notify_or_indicate_subscribers(self, indicate, attribute, value=None, force=False):
# Get all the connections for which there's at least one subscription # Get all the connections for which there's at least one subscription
connections = [ connections = [
connection for connection in [ connection for connection in [
self.device.lookup_connection(connection_handle) self.device.lookup_connection(connection_handle)
for (connection_handle, subscribers) in self.subscribers.items() for (connection_handle, subscribers) in self.subscribers.items()
if subscribers.get(attribute.handle) if force or subscribers.get(attribute.handle)
] ]
if connection is not None if connection is not None
] ]
# Indicate for each connection # Indicate or notify for each connection
if connections: if connections:
coroutine = self.indicate_subscriber if indicate else self.notify_subscriber
await asyncio.wait([ await asyncio.wait([
self.indicate_subscriber(connection, attribute) asyncio.create_task(coroutine(connection, attribute, value, force))
for connection in connections for connection in connections
]) ])
async def notify_subscribers(self, attribute, value=None, force=False):
return await self.notify_or_indicate_subscribers(False, attribute, value, force)
async def indicate_subscribers(self, attribute, value=None, force=False):
return await self.notify_or_indicate_subscribers(True, attribute, value, force)
def on_disconnection(self, connection): def on_disconnection(self, connection):
if connection.handle in self.mtus:
del self.mtus[connection.handle]
if connection.handle in self.subscribers: if connection.handle in self.subscribers:
del self.subscribers[connection.handle] del self.subscribers[connection.handle]
if connection.handle in self.indication_semaphores: if connection.handle in self.indication_semaphores:
@@ -325,9 +315,6 @@ class Server(EventEmitter):
# Just ignore # Just ignore
logger.warning(f'{color("--- Ignoring GATT Request from [0x{connection.handle:04X}]:", "red")} {att_pdu}') logger.warning(f'{color("--- Ignoring GATT Request from [0x{connection.handle:04X}]:", "red")} {att_pdu}')
def get_mtu(self, connection):
return self.mtus.get(connection.handle, ATT_DEFAULT_MTU)
####################################################### #######################################################
# ATT handlers # ATT handlers
####################################################### #######################################################
@@ -347,12 +334,16 @@ class Server(EventEmitter):
''' '''
See Bluetooth spec Vol 3, Part F - 3.4.2.1 Exchange MTU Request See Bluetooth spec Vol 3, Part F - 3.4.2.1 Exchange MTU Request
''' '''
mtu = max(ATT_DEFAULT_MTU, min(self.max_mtu, request.client_rx_mtu)) self.send_response(connection, ATT_Exchange_MTU_Response(server_rx_mtu = self.max_mtu))
self.mtus[connection.handle] = mtu
self.send_response(connection, ATT_Exchange_MTU_Response(server_rx_mtu = mtu))
# Notify the device # Compute the final MTU
self.device.on_connection_att_mtu_update(connection.handle, mtu) if request.client_rx_mtu >= ATT_DEFAULT_MTU:
mtu = min(self.max_mtu, request.client_rx_mtu)
# Notify the device
self.device.on_connection_att_mtu_update(connection.handle, mtu)
else:
logger.warning('invalid client_rx_mtu received, MTU not changed')
def on_att_find_information_request(self, connection, request): def on_att_find_information_request(self, connection, request):
''' '''
@@ -369,7 +360,7 @@ class Server(EventEmitter):
return return
# Build list of returned attributes # Build list of returned attributes
pdu_space_available = self.get_mtu(connection) - 2 pdu_space_available = connection.att_mtu - 2
attributes = [] attributes = []
uuid_size = 0 uuid_size = 0
for attribute in ( for attribute in (
@@ -420,7 +411,7 @@ class Server(EventEmitter):
''' '''
# Build list of returned attributes # Build list of returned attributes
pdu_space_available = self.get_mtu(connection) - 2 pdu_space_available = connection.att_mtu - 2
attributes = [] attributes = []
for attribute in ( for attribute in (
attribute for attribute in self.attributes if attribute for attribute in self.attributes if
@@ -468,8 +459,7 @@ class Server(EventEmitter):
See Bluetooth spec Vol 3, Part F - 3.4.4.1 Read By Type Request See Bluetooth spec Vol 3, Part F - 3.4.4.1 Read By Type Request
''' '''
mtu = self.get_mtu(connection) pdu_space_available = connection.att_mtu - 2
pdu_space_available = mtu - 2
attributes = [] attributes = []
for attribute in ( for attribute in (
attribute for attribute in self.attributes if attribute for attribute in self.attributes if
@@ -482,7 +472,7 @@ class Server(EventEmitter):
# Check the attribute value size # Check the attribute value size
attribute_value = attribute.read_value(connection) attribute_value = attribute.read_value(connection)
max_attribute_size = min(mtu - 4, 253) max_attribute_size = min(connection.att_mtu - 4, 253)
if len(attribute_value) > max_attribute_size: if len(attribute_value) > max_attribute_size:
# We need to truncate # We need to truncate
attribute_value = attribute_value[:max_attribute_size] attribute_value = attribute_value[:max_attribute_size]
@@ -522,7 +512,7 @@ class Server(EventEmitter):
if attribute := self.get_attribute(request.attribute_handle): if attribute := self.get_attribute(request.attribute_handle):
# TODO: check permissions # TODO: check permissions
value = attribute.read_value(connection) value = attribute.read_value(connection)
value_size = min(self.get_mtu(connection) - 1, len(value)) value_size = min(connection.att_mtu - 1, len(value))
response = ATT_Read_Response( response = ATT_Read_Response(
attribute_value = value[:value_size] attribute_value = value[:value_size]
) )
@@ -541,7 +531,6 @@ class Server(EventEmitter):
if attribute := self.get_attribute(request.attribute_handle): if attribute := self.get_attribute(request.attribute_handle):
# TODO: check permissions # TODO: check permissions
mtu = self.get_mtu(connection)
value = attribute.read_value(connection) value = attribute.read_value(connection)
if request.value_offset > len(value): if request.value_offset > len(value):
response = ATT_Error_Response( response = ATT_Error_Response(
@@ -549,14 +538,14 @@ class Server(EventEmitter):
attribute_handle_in_error = request.attribute_handle, attribute_handle_in_error = request.attribute_handle,
error_code = ATT_INVALID_OFFSET_ERROR error_code = ATT_INVALID_OFFSET_ERROR
) )
elif len(value) <= mtu - 1: elif len(value) <= connection.att_mtu - 1:
response = ATT_Error_Response( response = ATT_Error_Response(
request_opcode_in_error = request.op_code, request_opcode_in_error = request.op_code,
attribute_handle_in_error = request.attribute_handle, attribute_handle_in_error = request.attribute_handle,
error_code = ATT_ATTRIBUTE_NOT_LONG_ERROR error_code = ATT_ATTRIBUTE_NOT_LONG_ERROR
) )
else: else:
part_size = min(mtu - 1, len(value) - request.value_offset) part_size = min(connection.att_mtu - 1, len(value) - request.value_offset)
response = ATT_Read_Blob_Response( response = ATT_Read_Blob_Response(
part_attribute_value = value[request.value_offset:request.value_offset + part_size] part_attribute_value = value[request.value_offset:request.value_offset + part_size]
) )
@@ -585,8 +574,7 @@ class Server(EventEmitter):
self.send_response(connection, response) self.send_response(connection, response)
return return
mtu = self.get_mtu(connection) pdu_space_available = connection.att_mtu - 2
pdu_space_available = mtu - 2
attributes = [] attributes = []
for attribute in ( for attribute in (
attribute for attribute in self.attributes if attribute for attribute in self.attributes if
@@ -597,7 +585,7 @@ class Server(EventEmitter):
): ):
# Check the attribute value size # Check the attribute value size
attribute_value = attribute.read_value(connection) attribute_value = attribute.read_value(connection)
max_attribute_size = min(mtu - 6, 251) max_attribute_size = min(connection.att_mtu - 6, 251)
if len(attribute_value) > max_attribute_size: if len(attribute_value) > max_attribute_size:
# We need to truncate # We need to truncate
attribute_value = attribute_value[:max_attribute_size] attribute_value = attribute_value[:max_attribute_size]

View File

@@ -36,7 +36,7 @@ logger = logging.getLogger(__name__)
async def open_usb_transport(spec): async def open_usb_transport(spec):
''' '''
Open a USB transport. Open a USB transport.
The parameter string has this syntax: The moniker string has this syntax:
either <index> or either <index> or
<vendor>:<product> or <vendor>:<product> or
<vendor>:<product>/<serial-number>] or <vendor>:<product>/<serial-number>] or
@@ -47,15 +47,21 @@ async def open_usb_transport(spec):
/<serial-number> suffix or #<index> suffix max be specified when more than one device with /<serial-number> suffix or #<index> suffix max be specified when more than one device with
the same vendor and product identifiers are present. the same vendor and product identifiers are present.
In addition, if the moniker ends with the symbol "!", the device will be used in "forced" mode:
the first USB interface of the device will be used, regardless of the interface class/subclass.
This may be useful for some devices that use a custom class/subclass but may nonetheless work as-is.
Examples: 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#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
usb:0B05:17CB! --> the BT USB dongle vendor=0B05 and product=17CB, in "forced" mode.
''' '''
USB_RECIPIENT_DEVICE = 0x00 USB_RECIPIENT_DEVICE = 0x00
USB_REQUEST_TYPE_CLASS = 0x01 << 5 USB_REQUEST_TYPE_CLASS = 0x01 << 5
USB_DEVICE_CLASS_DEVICE = 0x00
USB_DEVICE_CLASS_WIRELESS_CONTROLLER = 0xE0 USB_DEVICE_CLASS_WIRELESS_CONTROLLER = 0xE0
USB_DEVICE_SUBCLASS_RF_CONTROLLER = 0x01 USB_DEVICE_SUBCLASS_RF_CONTROLLER = 0x01
USB_DEVICE_PROTOCOL_BLUETOOTH_PRIMARY_CONTROLLER = 0x01 USB_DEVICE_PROTOCOL_BLUETOOTH_PRIMARY_CONTROLLER = 0x01
@@ -63,6 +69,12 @@ async def open_usb_transport(spec):
USB_ENDPOINT_TRANSFER_TYPE_INTERRUPT = 0x03 USB_ENDPOINT_TRANSFER_TYPE_INTERRUPT = 0x03
USB_ENDPOINT_IN = 0x80 USB_ENDPOINT_IN = 0x80
USB_BT_HCI_CLASS_TUPLE = (
USB_DEVICE_CLASS_WIRELESS_CONTROLLER,
USB_DEVICE_SUBCLASS_RF_CONTROLLER,
USB_DEVICE_PROTOCOL_BLUETOOTH_PRIMARY_CONTROLLER
)
READ_SIZE = 1024 READ_SIZE = 1024
class UsbPacketSink: class UsbPacketSink:
@@ -280,6 +292,13 @@ async def open_usb_transport(spec):
context.open() context.open()
try: try:
found = None found = None
if spec.endswith('!'):
spec = spec[:-1]
forced_mode = True
else:
forced_mode = False
if ':' in spec: if ':' in spec:
vendor_id, product_id = spec.split(':') vendor_id, product_id = spec.split(':')
serial_number = None serial_number = None
@@ -291,10 +310,14 @@ async def open_usb_transport(spec):
device_index = int(device_index_str) device_index = int(device_index_str)
for device in context.getDeviceIterator(skip_on_error=True): for device in context.getDeviceIterator(skip_on_error=True):
try:
device_serial_number = device.getSerialNumber()
except usb1.USBError:
device_serial_number = None
if ( if (
device.getVendorID() == int(vendor_id, 16) and device.getVendorID() == int(vendor_id, 16) and
device.getProductID() == int(product_id, 16) and device.getProductID() == int(product_id, 16) and
(serial_number is None or device.getSerialNumber() == serial_number) (serial_number is None or serial_number == device_serial_number)
): ):
if device_index == 0: if device_index == 0:
found = device found = device
@@ -302,13 +325,27 @@ async def open_usb_transport(spec):
device_index -= 1 device_index -= 1
device.close() device.close()
else: else:
# Look for a compatible device by index
def device_is_bluetooth_hci(device):
# Check if the device class indicates a match
if (device.getDeviceClass(), device.getDeviceSubClass(), device.getDeviceProtocol()) == \
USB_BT_HCI_CLASS_TUPLE:
return True
# If the device class is 'Device', look for a matching interface
if device.getDeviceClass() == USB_DEVICE_CLASS_DEVICE:
for configuration in device:
for interface in configuration:
for setting in interface:
if (setting.getClass(), setting.getSubClass(), setting.getProtocol()) == \
USB_BT_HCI_CLASS_TUPLE:
return True
return False
device_index = int(spec) device_index = int(spec)
for device in context.getDeviceIterator(skip_on_error=True): for device in context.getDeviceIterator(skip_on_error=True):
if ( if device_is_bluetooth_hci(device):
device.getDeviceClass() == USB_DEVICE_CLASS_WIRELESS_CONTROLLER and
device.getDeviceSubClass() == USB_DEVICE_SUBCLASS_RF_CONTROLLER and
device.getDeviceProtocol() == USB_DEVICE_PROTOCOL_BLUETOOTH_PRIMARY_CONTROLLER
):
if device_index == 0: if device_index == 0:
found = device found = device
break break
@@ -329,9 +366,8 @@ async def open_usb_transport(spec):
setting = None setting = None
for setting in interface: for setting in interface:
if ( if (
setting.getClass() != USB_DEVICE_CLASS_WIRELESS_CONTROLLER or not forced_mode and
setting.getSubClass() != USB_DEVICE_SUBCLASS_RF_CONTROLLER or (setting.getClass(), setting.getSubClass(), setting.getProtocol()) != USB_BT_HCI_CLASS_TUPLE
setting.getProtocol() != USB_DEVICE_PROTOCOL_BLUETOOTH_PRIMARY_CONTROLLER
): ):
continue continue

View File

@@ -45,6 +45,10 @@ nav:
- HCI Bridge: apps_and_tools/hci_bridge.md - HCI Bridge: apps_and_tools/hci_bridge.md
- Golden Gate Bridge: apps_and_tools/gg_bridge.md - Golden Gate Bridge: apps_and_tools/gg_bridge.md
- Show: apps_and_tools/show.md - Show: apps_and_tools/show.md
- GATT Dump: apps_and_tools/gatt_dump.md
- Pair: apps_and_tools/pair.md
- Unbond: apps_and_tools/unbond.md
- USB Probe: apps_and_tools/usb_probe.md
- Hardware: - Hardware:
- Overview: hardware/index.md - Overview: hardware/index.md
- Platforms: - Platforms:

View File

@@ -1,6 +1,6 @@
# This requirements file is for python3 # This requirements file is for python3
mkdocs == 1.2.3 mkdocs == 1.4.0
mkdocs-material == 7.1.7 mkdocs-material == 8.5.6
mkdocs-material-extensions == 1.0.1 mkdocs-material-extensions == 1.0.3
pymdown-extensions == 8.2 pymdown-extensions == 9.6
mkdocstrings == 0.15.1 mkdocstrings-python == 0.7.1

View File

@@ -13,17 +13,29 @@ type of device (there's no way to tell).
## Usage ## Usage
This command line tool takes no arguments. This command line tool may be invoked with no arguments, or with `--verbose`
for extra details.
When installed from PyPI, run as When installed from PyPI, run as
``` ```
$ bumble-usb-probe $ bumble-usb-probe
``` ```
or, for extra details, with the `--verbose` argument
```
$ bumble-usb-probe --v
```
When running from the source distribution: When running from the source distribution:
``` ```
$ python3 apps/usb-probe.py $ python3 apps/usb-probe.py
``` ```
or
```
$ python3 apps/usb-probe.py --verbose
```
!!! example !!! example
``` ```
$ python3 apps/usb_probe.py $ python3 apps/usb_probe.py

View File

@@ -1,47 +1,85 @@
:material-linux: LINUX PLATFORM :material-linux: LINUX PLATFORM
=============================== ===============================
In addition to all the standard functionality available from the project by running the python tools and/or writing your own apps by leveraging the API, it is also possible on Linux hosts to interface the Bumble stack with the native BlueZ stack, and with Bluetooth controllers. Using Bumble With Physical Bluetooth Controllers
------------------------------------------------
Using Bumble With BlueZ A Bumble application can interface with a local Bluetooth controller on a Linux host.
----------------------- The 3 main types of physical Bluetooth controllers are:
A Bumble virtual controller can be attached to the BlueZ stack. * Bluetooth USB Dongle
Attaching a controller to BlueZ can be done by either simulating a UART HCI interface, or by using the VHCI driver interface if available. * HCI over UART (via a serial port)
In both cases, the controller can run locally on the Linux host, or remotely on a different host, with a bridge between the remote controller and the local BlueZ host, which may be useful when the BlueZ stack is running on an embedded system, or a host on which running the Bumble controller is not convenient. * Kernel-managed Bluetooth HCI (HCI Sockets)
### Using VHCI !!! tip "Conflicts with the kernel and BlueZ"
If your use a USB dongle that is recognized by your kernel as a supported Bluetooth device, it is
likely that the kernel driver will claim that USB device and attach it to the BlueZ stack.
If you want to claim ownership of it to use with Bumble, you will need to set the state of the corresponding HCI interface as `DOWN`.
HCI interfaces are numbered, starting from 0 (i.e `hci0`, `hci1`, ...).
With the [VHCI transport](../transports/vhci.md) you can attach a Bumble virtual controller to the BlueZ stack. Once attached, the controller will appear just like any other controller, and thus can be used with the standard BlueZ tools. For example, to bring `hci0` down:
!!! example "Attaching a virtual controller"
With the example app `run_controller.py`:
``` ```
PYTHONPATH=. python3 examples/run_controller.py F6:F7:F8:F9:FA:FB examples/device1.json vhci $ sudo hciconfig hci0 down
``` ```
You should see a 'Virtual Bus' controller. For example: You can use the `hciconfig` command with no arguments to get a list of HCI interfaces seen by
``` the kernel.
$ hciconfig
hci0: Type: Primary Bus: Virtual
BD Address: F6:F7:F8:F9:FA:FB ACL MTU: 27:64 SCO MTU: 0:0
UP RUNNING
RX bytes:0 acl:0 sco:0 events:43 errors:0
TX bytes:274 acl:0 sco:0 commands:43 errors:0
```
And scanning for devices should show the virtual 'Bumble' device that's running as part of the `run_controller.py` example app: Also, if `bluetoothd` is running on your system, it will likely re-claim the interface after you
close it, so you may need to bring the interface back `UP` before using it again, or to disable
`bluetoothd` altogether (see the section further below about BlueZ and `bluetoothd`).
### Using a USB Dongle
See the [USB Transport page](../transports/usb.md) for general information on how to use HCI USB controllers.
!!! tip "USB Permissions"
By default, when running as a regular user, you won't have the permission to use
arbitrary USB devices.
You can change the permissions for a specific USB device based on its bus number and
device number (you can use `lsusb` to find the Bus and Device numbers for your Bluetooth
dongle).
Example:
``` ```
pi@raspberrypi:~ $ sudo hcitool -i hci2 lescan $ sudo chmod o+w /dev/bus/usb/001/004
LE Scan ...
F0:F1:F2:F3:F4:F5 Bumble
``` ```
This will change the permissions for Device 4 on Bus 1.
Note that the USB Bus number and Device number may change depending on where you plug the USB
dongle and what other USB devices and hubs are also plugged in.
If you need to make the permission changes permanent across reboots, you can create a `udev`
rule for your specific Bluetooth dongle. Visit [this Arch Linux Wiki page](https://wiki.archlinux.org/title/udev) for a
good overview of how you may do that.
### Using HCI over UART
See the [Serial Transport page](../transports/serial.md) for general information on how to use HCI over a UART (serial port).
### Using HCI Sockets ### Using HCI Sockets
HCI sockets provide a way to send/receive HCI packets to/from a Bluetooth controller managed by the kernel. HCI sockets provide a way to send/receive HCI packets to/from a Bluetooth controller managed by the kernel.
The HCI device referenced by an `hci-socket` transport (`hciX`, where `X` is an integer, with `hci0` being the first controller device, and so on) must be in the `DOWN` state before it can be opened as a transport. See the [HCI Socket Transport page](../transports/hci_socket.md) for details on the `hci-socket` tansport syntax.
You can bring a HCI controller `UP` or `DOWN` with `hciconfig`.
The HCI device referenced by an `hci-socket` transport (`hci<X>`, where `<X>` is an integer, with `hci0` being the first controller device, and so on) must be in the `DOWN` state before it can be opened as a transport.
You can bring a HCI controller `UP` or `DOWN` with `hciconfig hci<X> up` and `hciconfig hci<X> up`.
!!! tip "HCI Socket Permissions"
By default, when running as a regular user, you won't have the permission to use
an HCI socket to a Bluetooth controller (you may see an exception like `PermissionError: [Errno 1] Operation not permitted`).
If you want to run without using `sudo`, you need to manage the capabilities by adding the appropriate entries in `/etc/security/capability.conf` to grant a user or group the `cap_net_admin` capability.
See [this manpage](https://manpages.ubuntu.com/manpages/bionic/man5/capability.conf.5.html) for details.
Alternatively, if you are just experimenting temporarily, the `capsh` command may be useful in order
to execute a single command with enhanced permissions, as in this example:
```
$ sudo capsh --caps="cap_net_admin+eip cap_setpcap,cap_setuid,cap_setgid+ep" --keep=1 --user=$USER --addamb=cap_net_admin -- -c "<path/to/executable> <executable-args>"
```
Where `<path/to/executable>` is the path to your `python3` executable or to one of the Bumble bundled command-line applications.
!!! tip "List all available controllers" !!! tip "List all available controllers"
The command The command
@@ -72,29 +110,16 @@ You can bring a HCI controller `UP` or `DOWN` with `hciconfig`.
``` ```
$ hciconfig hci0 down $ hciconfig hci0 down
``` ```
(or `hciX` with `X` being the index of the controller device you want to use), but a simpler solution is to just stop the `bluetoothd` daemon, with a command like: (or `hci<X>` with `<X>` being the index of the controller device you want to use), but a simpler solution is to just stop the `bluetoothd` daemon, with a command like:
``` ```
$ sudo systemctl stop bluetooth.service $ sudo systemctl stop bluetooth.service
``` ```
You can always re-start the daemon with You can always re-start the daemon with
``` ```
$ sudo systemctl start bluetooth.service $ sudo systemctl start bluetooth.service
```
### Using a Simulated UART HCI Bumble on the Raspberry Pi
--------------------------
### Bridge to a Remote Controller
Using Bumble With Bluetooth Controllers
---------------------------------------
A Bumble application can interface with a local Bluetooth controller.
If your Bluetooth controller is a standard HCI USB controller, see the [USB Transport page](../transports/usb.md) for details on how to use HCI USB controllers.
If your Bluetooth controller is a standard HCI UART controller, see the [Serial Transport page](../transports/serial.md).
Alternatively, a Bumble Host object can communicate with one of the platform's controllers via an HCI Socket.
`<details to be filled in>`
### Raspberry Pi 4 :fontawesome-brands-raspberry-pi: ### Raspberry Pi 4 :fontawesome-brands-raspberry-pi:
@@ -102,9 +127,10 @@ You can use the Bluetooth controller either via the kernel, or directly to the d
#### Via The Kernel #### Via The Kernel
Use an HCI Socket transport Use an HCI Socket transport (see section above)
#### Directly #### Directly
In order to use the Bluetooth controller directly on a Raspberry Pi 4 board, you need to ensure that it isn't being used by the BlueZ stack (which it probably is by default). In order to use the Bluetooth controller directly on a Raspberry Pi 4 board, you need to ensure that it isn't being used by the BlueZ stack (which it probably is by default).
``` ```
@@ -136,3 +162,47 @@ should detach the controller from the stack, after which you can use the HCI UAR
python3 run_scanner.py serial:/dev/serial1,3000000 python3 run_scanner.py serial:/dev/serial1,3000000
``` ```
Using Bumble With BlueZ
-----------------------
In addition to all the standard functionality available from the project by running the python tools and/or writing your own apps by leveraging the API, it is also possible on Linux hosts to interface the Bumble stack with the native BlueZ stack, and with Bluetooth controllers.
A Bumble virtual controller can be attached to the BlueZ stack.
Attaching a controller to BlueZ can be done by either simulating a UART HCI interface, or by using the VHCI driver interface if available.
In both cases, the controller can run locally on the Linux host, or remotely on a different host, with a bridge between the remote controller and the local BlueZ host, which may be useful when the BlueZ stack is running on an embedded system, or a host on which running the Bumble controller is not convenient.
### Using VHCI
With the [VHCI transport](../transports/vhci.md) you can attach a Bumble virtual controller to the BlueZ stack. Once attached, the controller will appear just like any other controller, and thus can be used with the standard BlueZ tools.
!!! example "Attaching a virtual controller"
With the example app `run_controller.py`:
```
python3 examples/run_controller.py F6:F7:F8:F9:FA:FB examples/device1.json vhci
```
You should see a 'Virtual Bus' controller. For example:
```
$ hciconfig
hci0: Type: Primary Bus: Virtual
BD Address: F6:F7:F8:F9:FA:FB ACL MTU: 27:64 SCO MTU: 0:0
UP RUNNING
RX bytes:0 acl:0 sco:0 events:43 errors:0
TX bytes:274 acl:0 sco:0 commands:43 errors:0
```
And scanning for devices should show the virtual 'Bumble' device that's running as part of the `run_controller.py` example app:
```
pi@raspberrypi:~ $ sudo hcitool -i hci2 lescan
LE Scan ...
F0:F1:F2:F3:F4:F5 Bumble
```
```
### Using a Simulated UART HCI
### Bridge to a Remote Controller

View File

@@ -5,6 +5,7 @@ The USB transport interfaces with a local Bluetooth USB dongle.
## Moniker ## Moniker
The moniker for a USB transport is either: The moniker for a USB transport is either:
* `usb:<index>` * `usb:<index>`
* `usb:<vendor>:<product>` * `usb:<vendor>:<product>`
* `usb:<vendor>:<product>/<serial-number>` * `usb:<vendor>:<product>/<serial-number>`
@@ -16,6 +17,10 @@ In the `usb:<vendor>:<product>#<index>` form, matching devices are the ones with
`<vendor>` and `<product>` are a vendor ID and product ID in hexadecimal. `<vendor>` and `<product>` are a vendor ID and product ID in hexadecimal.
In addition, if the moniker ends with the symbol "!", the device will be used in "forced" mode:
the first USB interface of the device will be used, regardless of the interface class/subclass.
This may be useful for some devices that use a custom class/subclass but may nonetheless work as-is.
!!! examples !!! examples
`usb:04b4:f901` `usb:04b4:f901`
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`
@@ -29,6 +34,10 @@ In the `usb:<vendor>:<product>#<index>` form, matching devices are the ones with
`usb:04b4:f901/#1` `usb:04b4:f901/#1`
The second USB dongle with `<vendor>` equal to `04b4` and `<product>` equal to `f901` The second USB dongle with `<vendor>` equal to `04b4` and `<product>` equal to `f901`
`usb:0B05:17CB!`
The BT USB dongle vendor=0B05 and product=17CB, in "forced" mode.
## Alternative ## 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.

View File

@@ -67,6 +67,6 @@ development =
invoke >= 1.4 invoke >= 1.4
nox >= 2022 nox >= 2022
documentation = documentation =
mkdocs >= 1.2.3 mkdocs >= 1.4.0
mkdocs-material >= 8.1.9 mkdocs-material >= 8.5.6
mkdocstrings[python] >= 0.19.0 mkdocstrings[python] >= 0.19.0

View File

@@ -22,6 +22,7 @@ import struct
import pytest import pytest
from bumble.controller import Controller from bumble.controller import Controller
from bumble.gatt_client import CharacteristicProxy
from bumble.link import LocalLink from bumble.link import LocalLink
from bumble.device import Device, Peer from bumble.device import Device, Peer
from bumble.host import Host from bumble.host import Host
@@ -53,29 +54,29 @@ def basic_check(x):
parsed = ATT_PDU.from_bytes(pdu) parsed = ATT_PDU.from_bytes(pdu)
x_str = str(x) x_str = str(x)
parsed_str = str(parsed) parsed_str = str(parsed)
assert(x_str == parsed_str) assert x_str == parsed_str
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
def test_UUID(): def test_UUID():
u = UUID.from_16_bits(0x7788) u = UUID.from_16_bits(0x7788)
assert(str(u) == 'UUID-16:7788') assert str(u) == 'UUID-16:7788'
u = UUID.from_32_bits(0x11223344) u = UUID.from_32_bits(0x11223344)
assert(str(u) == 'UUID-32:11223344') assert str(u) == 'UUID-32:11223344'
u = UUID('61A3512C-09BE-4DDC-A6A6-0B03667AAFC6') u = UUID('61A3512C-09BE-4DDC-A6A6-0B03667AAFC6')
assert(str(u) == '61A3512C-09BE-4DDC-A6A6-0B03667AAFC6') assert str(u) == '61A3512C-09BE-4DDC-A6A6-0B03667AAFC6'
v = UUID(str(u)) v = UUID(str(u))
assert(str(v) == '61A3512C-09BE-4DDC-A6A6-0B03667AAFC6') assert str(v) == '61A3512C-09BE-4DDC-A6A6-0B03667AAFC6'
w = UUID.from_bytes(v.to_bytes()) w = UUID.from_bytes(v.to_bytes())
assert(str(w) == '61A3512C-09BE-4DDC-A6A6-0B03667AAFC6') assert str(w) == '61A3512C-09BE-4DDC-A6A6-0B03667AAFC6'
u1 = UUID.from_16_bits(0x1234) u1 = UUID.from_16_bits(0x1234)
b1 = u1.to_bytes(force_128 = True) b1 = u1.to_bytes(force_128 = True)
u2 = UUID.from_bytes(b1) u2 = UUID.from_bytes(b1)
assert(u1 == u2) assert u1 == u2
u3 = UUID.from_16_bits(0x180a) u3 = UUID.from_16_bits(0x180a)
assert(str(u3) == 'UUID-16:180A (Device Information)') assert str(u3) == 'UUID-16:180A (Device Information)'
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
@@ -98,6 +99,122 @@ def test_ATT_Read_By_Group_Type_Request():
basic_check(pdu) basic_check(pdu)
# -----------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_characteristic_encoding():
class Foo(Characteristic):
def encode_value(self, value):
return bytes([value])
def decode_value(self, value_bytes):
return value_bytes[0]
c = Foo(GATT_BATTERY_LEVEL_CHARACTERISTIC, Characteristic.READ, Characteristic.READABLE, 123)
x = c.read_value(None)
assert x == bytes([123])
c.write_value(None, bytes([122]))
assert c.value == 122
class FooProxy(CharacteristicProxy):
def __init__(self, characteristic):
super().__init__(
characteristic.client,
characteristic.handle,
characteristic.end_group_handle,
characteristic.uuid,
characteristic.properties
)
def encode_value(self, value):
return bytes([value])
def decode_value(self, value_bytes):
return value_bytes[0]
[client, server] = LinkedDevices().devices[:2]
characteristic = Characteristic(
'FDB159DB-036C-49E3-B3DB-6325AC750806',
Characteristic.READ | Characteristic.WRITE | Characteristic.NOTIFY,
Characteristic.READABLE | Characteristic.WRITEABLE,
bytes([123])
)
service = Service(
'3A657F47-D34F-46B3-B1EC-698E29B6B829',
[characteristic]
)
server.add_service(service)
await client.power_on()
await server.power_on()
connection = await client.connect(server.random_address)
peer = Peer(connection)
await peer.discover_services()
await peer.discover_characteristics()
c = peer.get_characteristics_by_uuid(characteristic.uuid)
assert len(c) == 1
c = c[0]
cp = FooProxy(c)
v = await cp.read_value()
assert v == 123
await cp.write_value(124)
await async_barrier()
assert characteristic.value == bytes([124])
last_change = None
def on_change(value):
nonlocal last_change
last_change = value
await c.subscribe(on_change)
await server.notify_subscribers(characteristic)
await async_barrier()
assert last_change == characteristic.value
last_change = None
await server.notify_subscribers(characteristic, value=bytes([125]))
await async_barrier()
assert last_change == bytes([125])
last_change = None
await c.unsubscribe(on_change)
await server.notify_subscribers(characteristic)
await async_barrier()
assert last_change is None
await cp.subscribe(on_change)
await server.notify_subscribers(characteristic)
await async_barrier()
assert last_change == characteristic.value[0]
last_change = None
await server.notify_subscribers(characteristic, value=bytes([126]))
await async_barrier()
assert last_change == 126
last_change = None
await cp.unsubscribe(on_change)
await server.notify_subscribers(characteristic)
await async_barrier()
assert last_change is None
cd = DelegatedCharacteristicAdapter(c, decode=lambda x: x[0])
await cd.subscribe(on_change)
await server.notify_subscribers(characteristic)
await async_barrier()
assert last_change == characteristic.value[0]
last_change = None
await cd.unsubscribe(on_change)
await server.notify_subscribers(characteristic)
await async_barrier()
assert last_change is None
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
def test_CharacteristicAdapter(): def test_CharacteristicAdapter():
# Check that the CharacteristicAdapter base class is transparent # Check that the CharacteristicAdapter base class is transparent
@@ -106,21 +223,21 @@ def test_CharacteristicAdapter():
a = CharacteristicAdapter(c) a = CharacteristicAdapter(c)
value = a.read_value(None) value = a.read_value(None)
assert(value == v) assert value == v
v = bytes([3, 4, 5]) v = bytes([3, 4, 5])
a.write_value(None, v) a.write_value(None, v)
assert(c.value == v) assert c.value == v
# Simple delegated adapter # Simple delegated adapter
a = DelegatedCharacteristicAdapter(c, lambda x: bytes(reversed(x)), lambda x: bytes(reversed(x))) a = DelegatedCharacteristicAdapter(c, lambda x: bytes(reversed(x)), lambda x: bytes(reversed(x)))
value = a.read_value(None) value = a.read_value(None)
assert(value == bytes(reversed(v))) assert value == bytes(reversed(v))
v = bytes([3, 4, 5]) v = bytes([3, 4, 5])
a.write_value(None, v) a.write_value(None, v)
assert(a.value == bytes(reversed(v))) assert a.value == bytes(reversed(v))
# Packed adapter with single element format # Packed adapter with single element format
v = 1234 v = 1234
@@ -129,10 +246,10 @@ def test_CharacteristicAdapter():
a = PackedCharacteristicAdapter(c, '>H') a = PackedCharacteristicAdapter(c, '>H')
value = a.read_value(None) value = a.read_value(None)
assert(value == pv) assert value == pv
c.value = None c.value = None
a.write_value(None, pv) a.write_value(None, pv)
assert(a.value == v) assert a.value == v
# Packed adapter with multi-element format # Packed adapter with multi-element format
v1 = 1234 v1 = 1234
@@ -142,10 +259,10 @@ def test_CharacteristicAdapter():
a = PackedCharacteristicAdapter(c, '>HH') a = PackedCharacteristicAdapter(c, '>HH')
value = a.read_value(None) value = a.read_value(None)
assert(value == pv) assert value == pv
c.value = None c.value = None
a.write_value(None, pv) a.write_value(None, pv)
assert(a.value == (v1, v2)) assert a.value == (v1, v2)
# Mapped adapter # Mapped adapter
v1 = 1234 v1 = 1234
@@ -156,10 +273,10 @@ def test_CharacteristicAdapter():
a = MappedCharacteristicAdapter(c, '>HH', ('v1', 'v2')) a = MappedCharacteristicAdapter(c, '>HH', ('v1', 'v2'))
value = a.read_value(None) value = a.read_value(None)
assert(value == pv) assert value == pv
c.value = None c.value = None
a.write_value(None, pv) a.write_value(None, pv)
assert(a.value == mapped) assert a.value == mapped
# UTF-8 adapter # UTF-8 adapter
v = 'Hello π' v = 'Hello π'
@@ -168,10 +285,10 @@ def test_CharacteristicAdapter():
a = UTF8CharacteristicAdapter(c) a = UTF8CharacteristicAdapter(c)
value = a.read_value(None) value = a.read_value(None)
assert(value == ev) assert value == ev
c.value = None c.value = None
a.write_value(None, ev) a.write_value(None, ev)
assert(a.value == v) assert a.value == v
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
@@ -179,24 +296,25 @@ def test_CharacteristicValue():
b = bytes([1, 2, 3]) b = bytes([1, 2, 3])
c = CharacteristicValue(read=lambda _: b) c = CharacteristicValue(read=lambda _: b)
x = c.read(None) x = c.read(None)
assert(x == b) assert x == b
result = [] result = []
c = CharacteristicValue(write=lambda connection, value: result.append((connection, value))) c = CharacteristicValue(write=lambda connection, value: result.append((connection, value)))
z = object() z = object()
c.write(z, b) c.write(z, b)
assert(result == [(z, b)]) assert result == [(z, b)]
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
class TwoDevices: class LinkedDevices:
def __init__(self): def __init__(self):
self.connections = [None, None] self.connections = [None, None, None]
self.link = LocalLink() self.link = LocalLink()
self.controllers = [ self.controllers = [
Controller('C1', link = self.link), Controller('C1', link = self.link),
Controller('C2', link = self.link) Controller('C2', link = self.link),
Controller('C3', link = self.link)
] ]
self.devices = [ self.devices = [
Device( Device(
@@ -204,12 +322,16 @@ class TwoDevices:
host = Host(self.controllers[0], AsyncPipeSink(self.controllers[0])) host = Host(self.controllers[0], AsyncPipeSink(self.controllers[0]))
), ),
Device( Device(
address = 'F5:F4:F3:F2:F1:F0', address = 'F1:F2:F3:F4:F5:F6',
host = Host(self.controllers[1], AsyncPipeSink(self.controllers[1])) host = Host(self.controllers[1], AsyncPipeSink(self.controllers[1]))
),
Device(
address = 'F2:F3:F4:F5:F6:F7',
host = Host(self.controllers[2], AsyncPipeSink(self.controllers[2]))
) )
] ]
self.paired = [None, None] self.paired = [None, None, None]
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
@@ -222,7 +344,7 @@ async def async_barrier():
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_read_write(): async def test_read_write():
[client, server] = TwoDevices().devices [client, server] = LinkedDevices().devices[:2]
characteristic1 = Characteristic( characteristic1 = Characteristic(
'FDB159DB-036C-49E3-B3DB-6325AC750806', 'FDB159DB-036C-49E3-B3DB-6325AC750806',
@@ -265,41 +387,41 @@ async def test_read_write():
await peer.discover_services() await peer.discover_services()
await peer.discover_characteristics() await peer.discover_characteristics()
c = peer.get_characteristics_by_uuid(characteristic1.uuid) c = peer.get_characteristics_by_uuid(characteristic1.uuid)
assert(len(c) == 1) assert len(c) == 1
c1 = c[0] c1 = c[0]
c = peer.get_characteristics_by_uuid(characteristic2.uuid) c = peer.get_characteristics_by_uuid(characteristic2.uuid)
assert(len(c) == 1) assert len(c) == 1
c2 = c[0] c2 = c[0]
v1 = await peer.read_value(c1) v1 = await peer.read_value(c1)
assert(v1 == b'') assert v1 == b''
b = bytes([1, 2, 3]) b = bytes([1, 2, 3])
await peer.write_value(c1, b) await peer.write_value(c1, b)
await async_barrier() await async_barrier()
assert(characteristic1.value == b) assert characteristic1.value == b
v1 = await peer.read_value(c1) v1 = await peer.read_value(c1)
assert(v1 == b) assert v1 == b
assert(type(characteristic1._last_value) is tuple) assert type(characteristic1._last_value is tuple)
assert(len(characteristic1._last_value) == 2) assert len(characteristic1._last_value) == 2
assert(str(characteristic1._last_value[0].peer_address) == str(client.random_address)) assert str(characteristic1._last_value[0].peer_address) == str(client.random_address)
assert(characteristic1._last_value[1] == b) assert characteristic1._last_value[1] == b
bb = bytes([3, 4, 5, 6]) bb = bytes([3, 4, 5, 6])
characteristic1.value = bb characteristic1.value = bb
v1 = await peer.read_value(c1) v1 = await peer.read_value(c1)
assert(v1 == bb) assert v1 == bb
await peer.write_value(c2, b) await peer.write_value(c2, b)
await async_barrier() await async_barrier()
assert(type(characteristic2._last_value) is tuple) assert type(characteristic2._last_value is tuple)
assert(len(characteristic2._last_value) == 2) assert len(characteristic2._last_value) == 2
assert(str(characteristic2._last_value[0].peer_address) == str(client.random_address)) assert str(characteristic2._last_value[0].peer_address) == str(client.random_address)
assert(characteristic2._last_value[1] == b) assert characteristic2._last_value[1] == b
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_read_write2(): async def test_read_write2():
[client, server] = TwoDevices().devices [client, server] = LinkedDevices().devices[:2]
v = bytes([0x11, 0x22, 0x33, 0x44]) v = bytes([0x11, 0x22, 0x33, 0x44])
characteristic1 = Characteristic( characteristic1 = Characteristic(
@@ -324,32 +446,32 @@ async def test_read_write2():
await peer.discover_services() await peer.discover_services()
c = peer.get_services_by_uuid(service1.uuid) c = peer.get_services_by_uuid(service1.uuid)
assert(len(c) == 1) assert len(c) == 1
s = c[0] s = c[0]
await s.discover_characteristics() await s.discover_characteristics()
c = s.get_characteristics_by_uuid(characteristic1.uuid) c = s.get_characteristics_by_uuid(characteristic1.uuid)
assert(len(c) == 1) assert len(c) == 1
c1 = c[0] c1 = c[0]
v1 = await c1.read_value() v1 = await c1.read_value()
assert(v1 == v) assert v1 == v
a1 = PackedCharacteristicAdapter(c1, '>I') a1 = PackedCharacteristicAdapter(c1, '>I')
v1 = await a1.read_value() v1 = await a1.read_value()
assert(v1 == struct.unpack('>I', v)[0]) assert v1 == struct.unpack('>I', v)[0]
b = bytes([0x55, 0x66, 0x77, 0x88]) b = bytes([0x55, 0x66, 0x77, 0x88])
await a1.write_value(struct.unpack('>I', b)[0]) await a1.write_value(struct.unpack('>I', b)[0])
await async_barrier() await async_barrier()
assert(characteristic1.value == b) assert characteristic1.value == b
v1 = await a1.read_value() v1 = await a1.read_value()
assert(v1 == struct.unpack('>I', b)[0]) assert v1 == struct.unpack('>I', b)[0]
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_subscribe_notify(): async def test_subscribe_notify():
[client, server] = TwoDevices().devices [client, server] = LinkedDevices().devices[:2]
characteristic1 = Characteristic( characteristic1 = Characteristic(
'FDB159DB-036C-49E3-B3DB-6325AC750806', 'FDB159DB-036C-49E3-B3DB-6325AC750806',
@@ -410,13 +532,13 @@ async def test_subscribe_notify():
await peer.discover_services() await peer.discover_services()
await peer.discover_characteristics() await peer.discover_characteristics()
c = peer.get_characteristics_by_uuid(characteristic1.uuid) c = peer.get_characteristics_by_uuid(characteristic1.uuid)
assert(len(c) == 1) assert len(c) == 1
c1 = c[0] c1 = c[0]
c = peer.get_characteristics_by_uuid(characteristic2.uuid) c = peer.get_characteristics_by_uuid(characteristic2.uuid)
assert(len(c) == 1) assert len(c) == 1
c2 = c[0] c2 = c[0]
c = peer.get_characteristics_by_uuid(characteristic3.uuid) c = peer.get_characteristics_by_uuid(characteristic3.uuid)
assert(len(c) == 1) assert len(c) == 1
c3 = c[0] c3 = c[0]
c1._called = False c1._called = False
@@ -429,23 +551,32 @@ async def test_subscribe_notify():
c1.on('update', on_c1_update) c1.on('update', on_c1_update)
await peer.subscribe(c1) await peer.subscribe(c1)
await async_barrier() await async_barrier()
assert(server._last_subscription[1] == characteristic1) assert server._last_subscription[1] == characteristic1
assert(server._last_subscription[2]) assert server._last_subscription[2]
assert(not server._last_subscription[3]) assert not server._last_subscription[3]
assert(characteristic1._last_subscription[1]) assert characteristic1._last_subscription[1]
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(not c1._called) assert not c1._called
await server.notify_subscribers(characteristic1) await server.notify_subscribers(characteristic1)
await async_barrier() await async_barrier()
assert(c1._called) assert c1._called
assert(c1._last_update == characteristic1.value) assert c1._last_update == characteristic1.value
c1._called = False
c1._last_update = None
c1_value = characteristic1.value
await server.notify_subscribers(characteristic1, bytes([0, 1, 2]))
await async_barrier()
assert c1._called
assert c1._last_update == bytes([0, 1, 2])
assert characteristic1.value == c1_value
c1._called = False c1._called = False
await peer.unsubscribe(c1) await peer.unsubscribe(c1)
await server.notify_subscribers(characteristic1) await server.notify_subscribers(characteristic1)
assert(not c1._called) assert not c1._called
c2._called = False c2._called = False
c2._last_update = None c2._last_update = None
@@ -458,17 +589,17 @@ async def test_subscribe_notify():
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(not c2._called) 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._called) assert c2._called
assert(c2._last_update == characteristic2.value) assert c2._last_update == characteristic2.value
c2._called = False c2._called = False
await peer.unsubscribe(c2, on_c2_update) await peer.unsubscribe(c2, on_c2_update)
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(not c2._called) assert not c2._called
def on_c3_update(value): def on_c3_update(value):
c3._called = True c3._called = True
@@ -483,17 +614,17 @@ async def test_subscribe_notify():
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._called) assert c3._called
assert(c3._last_update == characteristic3.value) assert c3._last_update == characteristic3.value
assert(c3._called_2) assert c3._called_2
assert(c3._last_update_2 == characteristic3.value) 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._called) assert c3._called
assert(c3._last_update == characteristic3.value) assert c3._last_update == characteristic3.value
assert(c3._called_2) assert c3._called_2
assert(c3._last_update_2 == characteristic3.value) assert c3._last_update_2 == characteristic3.value
c3._called = False c3._called = False
c3._called_2 = False c3._called_2 = False
@@ -501,8 +632,44 @@ async def test_subscribe_notify():
await server.notify_subscriber(characteristic3._last_subscription[0], characteristic3) await server.notify_subscriber(characteristic3._last_subscription[0], characteristic3)
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(not c3._called) assert not c3._called
assert(not c3._called_2) assert not c3._called_2
# -----------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_mtu_exchange():
[d1, d2, d3] = LinkedDevices().devices[:3]
d3.gatt_server.max_mtu = 100
d3_connections = []
@d3.on('connection')
def on_d3_connection(connection):
d3_connections.append(connection)
await d1.power_on()
await d2.power_on()
await d3.power_on()
d1_connection = await d1.connect(d3.random_address)
assert len(d3_connections) == 1
assert d3_connections[0] is not None
d2_connection = await d2.connect(d3.random_address)
assert len(d3_connections) == 2
assert d3_connections[1] is not None
d1_peer = Peer(d1_connection)
d2_peer = Peer(d2_connection)
d1_client_mtu = await d1_peer.request_mtu(220)
assert d1_client_mtu == 100
assert d1_connection.att_mtu == 100
d2_client_mtu = await d2_peer.request_mtu(50)
assert d2_client_mtu == 50
assert d2_connection.att_mtu == 50
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
@@ -510,6 +677,9 @@ async def async_main():
await test_read_write() await test_read_write()
await test_read_write2() await test_read_write2()
await test_subscribe_notify() await test_subscribe_notify()
await test_characteristic_encoding()
await test_mtu_exchange()
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
if __name__ == '__main__': if __name__ == '__main__':