diff options
author | Android Build Coastguard Worker <android-build-coastguard-worker@google.com> | 2023-04-01 04:31:29 +0000 |
---|---|---|
committer | Android Build Coastguard Worker <android-build-coastguard-worker@google.com> | 2023-04-01 04:31:29 +0000 |
commit | cdee317f4f54eb5ce1359558708feacfa79a9208 (patch) | |
tree | b22c15edb005cc2f21c7545fe8f85528e4d134df | |
parent | 1fa92b501af592d0da4351d278aaae62b900d7ca (diff) | |
parent | 26c9e40629463a7c7b71e5665809207e047204d9 (diff) | |
download | bumble-android14-d1-s5-release.tar.gz |
Snap for 9867398 from 26c9e40629463a7c7b71e5665809207e047204d9 to udc-d1-releaseandroid-14.0.0_r9android-14.0.0_r8android-14.0.0_r7android-14.0.0_r6android-14.0.0_r5android-14.0.0_r4android-14.0.0_r3android-14.0.0_r12android-14.0.0_r11android-14.0.0_r10android14-d1-s7-releaseandroid14-d1-s6-releaseandroid14-d1-s5-releaseandroid14-d1-s4-releaseandroid14-d1-s3-releaseandroid14-d1-s2-releaseandroid14-d1-s1-releaseandroid14-d1-release
Change-Id: I9f3cafa21546eeb1e067d6fa007d988fcffdcc2b
-rw-r--r-- | apps/bench.py | 1207 | ||||
-rw-r--r-- | apps/console.py | 10 | ||||
-rw-r--r-- | apps/controller_info.py | 21 | ||||
-rw-r--r-- | bumble/att.py | 47 | ||||
-rw-r--r-- | bumble/controller.py | 266 | ||||
-rw-r--r-- | bumble/decoder.py | 416 | ||||
-rw-r--r-- | bumble/device.py | 150 | ||||
-rw-r--r-- | bumble/gatt_client.py | 46 | ||||
-rw-r--r-- | bumble/gatt_server.py | 2 | ||||
-rw-r--r-- | bumble/hci.py | 20 | ||||
-rw-r--r-- | bumble/host.py | 35 | ||||
-rw-r--r-- | bumble/l2cap.py | 13 | ||||
-rw-r--r-- | bumble/link.py | 124 | ||||
-rw-r--r-- | bumble/profiles/asha_service.py | 32 | ||||
-rw-r--r-- | bumble/rfcomm.py | 32 | ||||
-rw-r--r-- | bumble/smp.py | 12 | ||||
-rw-r--r-- | bumble/utils.py | 18 | ||||
-rw-r--r-- | docs/mkdocs/mkdocs.yml | 5 | ||||
-rw-r--r-- | docs/mkdocs/src/apps_and_tools/bench.md | 158 | ||||
-rw-r--r-- | docs/mkdocs/src/apps_and_tools/index.md | 1 | ||||
-rw-r--r-- | docs/mkdocs/src/index.md | 3 | ||||
-rw-r--r-- | setup.cfg | 1 | ||||
-rw-r--r-- | tests/a2dp_test.py | 40 | ||||
-rw-r--r-- | tests/decoder_test.py | 47 | ||||
-rw-r--r-- | tests/g722_sample.g722 | 1 | ||||
-rw-r--r-- | tests/gatt_test.py | 25 | ||||
-rw-r--r-- | tests/hci_test.py | 19 | ||||
-rw-r--r-- | tests/self_test.py | 64 |
28 files changed, 2694 insertions, 121 deletions
diff --git a/apps/bench.py b/apps/bench.py new file mode 100644 index 0000000..19cdcfa --- /dev/null +++ b/apps/bench.py @@ -0,0 +1,1207 @@ +# Copyright 2021-2023 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +# ----------------------------------------------------------------------------- +# Imports +# ----------------------------------------------------------------------------- +import asyncio +import enum +import logging +import os +import struct +import time + +import click + +from bumble.core import ( + BT_BR_EDR_TRANSPORT, + BT_LE_TRANSPORT, + BT_L2CAP_PROTOCOL_ID, + BT_RFCOMM_PROTOCOL_ID, + UUID, + CommandTimeoutError, +) +from bumble.colors import color +from bumble.device import Connection, ConnectionParametersPreferences, Device, Peer +from bumble.gatt import Characteristic, CharacteristicValue, Service +from bumble.hci import ( + HCI_LE_1M_PHY, + HCI_LE_2M_PHY, + HCI_LE_CODED_PHY, + HCI_Constant, + HCI_Error, + HCI_StatusError, +) +from bumble.sdp import ( + SDP_BROWSE_GROUP_LIST_ATTRIBUTE_ID, + SDP_PROTOCOL_DESCRIPTOR_LIST_ATTRIBUTE_ID, + SDP_PUBLIC_BROWSE_ROOT, + SDP_SERVICE_CLASS_ID_LIST_ATTRIBUTE_ID, + SDP_SERVICE_RECORD_HANDLE_ATTRIBUTE_ID, + DataElement, + ServiceAttribute, +) +from bumble.transport import open_transport_or_link +import bumble.rfcomm +import bumble.core +from bumble.utils import AsyncRunner + + +# ----------------------------------------------------------------------------- +# Logging +# ----------------------------------------------------------------------------- +logger = logging.getLogger(__name__) + + +# ----------------------------------------------------------------------------- +# Constants +# ----------------------------------------------------------------------------- +DEFAULT_CENTRAL_ADDRESS = 'F0:F0:F0:F0:F0:F0' +DEFAULT_CENTRAL_NAME = 'Speed Central' +DEFAULT_PERIPHERAL_ADDRESS = 'F1:F1:F1:F1:F1:F1' +DEFAULT_PERIPHERAL_NAME = 'Speed Peripheral' + +SPEED_SERVICE_UUID = '50DB505C-8AC4-4738-8448-3B1D9CC09CC5' +SPEED_TX_UUID = 'E789C754-41A1-45F4-A948-A0A1A90DBA53' +SPEED_RX_UUID = '016A2CC7-E14B-4819-935F-1F56EAE4098D' + +DEFAULT_L2CAP_PSM = 1234 +DEFAULT_L2CAP_MAX_CREDITS = 128 +DEFAULT_L2CAP_MTU = 1022 +DEFAULT_L2CAP_MPS = 1024 + +DEFAULT_LINGER_TIME = 1.0 + +DEFAULT_RFCOMM_CHANNEL = 8 + +# ----------------------------------------------------------------------------- +# Utils +# ----------------------------------------------------------------------------- +def parse_packet(packet): + if len(packet) < 1: + print( + color(f'!!! Packet too short (got {len(packet)} bytes, need >= 1)', 'red') + ) + raise ValueError('packet too short') + + try: + packet_type = PacketType(packet[0]) + except ValueError: + print(color(f'!!! Invalid packet type 0x{packet[0]:02X}', 'red')) + raise + + return (packet_type, packet[1:]) + + +def parse_packet_sequence(packet_data): + if len(packet_data) < 5: + print( + color( + f'!!!Packet too short (got {len(packet_data)} bytes, need >= 5)', + 'red', + ) + ) + raise ValueError('packet too short') + return struct.unpack_from('>bI', packet_data, 0) + + +def le_phy_name(phy_id): + return {HCI_LE_1M_PHY: '1M', HCI_LE_2M_PHY: '2M', HCI_LE_CODED_PHY: 'CODED'}.get( + phy_id, HCI_Constant.le_phy_name(phy_id) + ) + + +def print_connection(connection): + if connection.transport == BT_LE_TRANSPORT: + phy_state = ( + 'PHY=' + f'RX:{le_phy_name(connection.phy.rx_phy)}/' + f'TX:{le_phy_name(connection.phy.tx_phy)}' + ) + + data_length = f'DL={connection.data_length}' + connection_parameters = ( + 'Parameters=' + f'{connection.parameters.connection_interval * 1.25:.2f}/' + f'{connection.parameters.peripheral_latency}/' + f'{connection.parameters.supervision_timeout * 10} ' + ) + + else: + phy_state = '' + data_length = '' + connection_parameters = '' + + mtu = connection.att_mtu + + print( + f'{color("@@@ Connection:", "yellow")} ' + f'{connection_parameters} ' + f'{data_length} ' + f'{phy_state} ' + f'MTU={mtu}' + ) + + +def make_sdp_records(channel): + return { + 0x00010001: [ + ServiceAttribute( + SDP_SERVICE_RECORD_HANDLE_ATTRIBUTE_ID, + DataElement.unsigned_integer_32(0x00010001), + ), + ServiceAttribute( + SDP_BROWSE_GROUP_LIST_ATTRIBUTE_ID, + DataElement.sequence([DataElement.uuid(SDP_PUBLIC_BROWSE_ROOT)]), + ), + ServiceAttribute( + SDP_SERVICE_CLASS_ID_LIST_ATTRIBUTE_ID, + DataElement.sequence( + [DataElement.uuid(UUID('E6D55659-C8B4-4B85-96BB-B1143AF6D3AE'))] + ), + ), + ServiceAttribute( + SDP_PROTOCOL_DESCRIPTOR_LIST_ATTRIBUTE_ID, + DataElement.sequence( + [ + DataElement.sequence([DataElement.uuid(BT_L2CAP_PROTOCOL_ID)]), + DataElement.sequence( + [ + DataElement.uuid(BT_RFCOMM_PROTOCOL_ID), + DataElement.unsigned_integer_8(channel), + ] + ), + ] + ), + ), + ] + } + + +class PacketType(enum.IntEnum): + RESET = 0 + SEQUENCE = 1 + ACK = 2 + + +PACKET_FLAG_LAST = 1 + +# ----------------------------------------------------------------------------- +# Sender +# ----------------------------------------------------------------------------- +class Sender: + def __init__(self, packet_io, start_delay, packet_size, packet_count): + self.tx_start_delay = start_delay + self.tx_packet_size = packet_size + self.tx_packet_count = packet_count + self.packet_io = packet_io + self.packet_io.packet_listener = self + self.start_time = 0 + self.bytes_sent = 0 + self.done = asyncio.Event() + + def reset(self): + pass + + async def run(self): + print(color('--- Waiting for I/O to be ready...', 'blue')) + await self.packet_io.ready.wait() + print(color('--- Go!', 'blue')) + + if self.tx_start_delay: + print(color(f'*** Startup delay: {self.tx_start_delay}', 'blue')) + await asyncio.sleep(self.tx_start_delay) # FIXME + + print(color('=== Sending RESET', 'magenta')) + await self.packet_io.send_packet(bytes([PacketType.RESET])) + self.start_time = time.time() + for tx_i in range(self.tx_packet_count): + packet_flags = PACKET_FLAG_LAST if tx_i == self.tx_packet_count - 1 else 0 + packet = struct.pack( + '>bbI', + PacketType.SEQUENCE, + packet_flags, + tx_i, + ) + bytes(self.tx_packet_size - 6) + print(color(f'Sending packet {tx_i}: {len(packet)} bytes', 'yellow')) + self.bytes_sent += len(packet) + await self.packet_io.send_packet(packet) + + await self.done.wait() + print(color('=== Done!', 'magenta')) + + def on_packet_received(self, packet): + try: + packet_type, _ = parse_packet(packet) + except ValueError: + return + + if packet_type == PacketType.ACK: + elapsed = time.time() - self.start_time + average_tx_speed = self.bytes_sent / elapsed + print( + color( + f'@@@ Received ACK. Speed: average={average_tx_speed:.4f}' + f' ({self.bytes_sent} bytes in {elapsed:.2f} seconds)', + 'green', + ) + ) + self.done.set() + + +# ----------------------------------------------------------------------------- +# Receiver +# ----------------------------------------------------------------------------- +class Receiver: + def __init__(self, packet_io): + self.reset() + self.packet_io = packet_io + self.packet_io.packet_listener = self + self.done = asyncio.Event() + + def reset(self): + self.expected_packet_index = 0 + self.start_timestamp = 0.0 + self.last_timestamp = 0.0 + self.bytes_received = 0 + + def on_packet_received(self, packet): + try: + packet_type, packet_data = parse_packet(packet) + except ValueError: + return + + now = time.time() + + if packet_type == PacketType.RESET: + print(color('=== Received RESET', 'magenta')) + self.reset() + self.start_timestamp = now + return + + try: + packet_flags, packet_index = parse_packet_sequence(packet_data) + except ValueError: + return + print( + f'<<< Received packet {packet_index}: ' + f'flags=0x{packet_flags:02X}, {len(packet)} bytes' + ) + + if packet_index != self.expected_packet_index: + print( + color( + f'!!! Unexpected packet, expected {self.expected_packet_index} ' + f'but received {packet_index}' + ) + ) + + elapsed_since_start = now - self.start_timestamp + elapsed_since_last = now - self.last_timestamp + self.bytes_received += len(packet) + instant_rx_speed = len(packet) / elapsed_since_last + average_rx_speed = self.bytes_received / elapsed_since_start + print( + color( + f'Speed: instant={instant_rx_speed:.4f}, average={average_rx_speed:.4f}', + 'yellow', + ) + ) + + self.last_timestamp = now + self.expected_packet_index = packet_index + 1 + + if packet_flags & PACKET_FLAG_LAST: + AsyncRunner.spawn( + self.packet_io.send_packet( + struct.pack('>bbI', PacketType.ACK, packet_flags, packet_index) + ) + ) + print(color('@@@ Received last packet', 'green')) + self.done.set() + + async def run(self): + await self.done.wait() + print(color('=== Done!', 'magenta')) + + +# ----------------------------------------------------------------------------- +# Ping +# ----------------------------------------------------------------------------- +class Ping: + def __init__(self, packet_io, start_delay, packet_size, packet_count): + self.tx_start_delay = start_delay + self.tx_packet_size = packet_size + self.tx_packet_count = packet_count + self.packet_io = packet_io + self.packet_io.packet_listener = self + self.done = asyncio.Event() + self.current_packet_index = 0 + self.ping_sent_time = 0.0 + self.latencies = [] + + def reset(self): + pass + + async def run(self): + print(color('--- Waiting for I/O to be ready...', 'blue')) + await self.packet_io.ready.wait() + print(color('--- Go!', 'blue')) + + if self.tx_start_delay: + print(color(f'*** Startup delay: {self.tx_start_delay}', 'blue')) + await asyncio.sleep(self.tx_start_delay) # FIXME + + print(color('=== Sending RESET', 'magenta')) + await self.packet_io.send_packet(bytes([PacketType.RESET])) + + await self.send_next_ping() + + await self.done.wait() + average_latency = sum(self.latencies) / len(self.latencies) + print(color(f'@@@ Average latency: {average_latency:.2f}')) + print(color('=== Done!', 'magenta')) + + async def send_next_ping(self): + packet = struct.pack( + '>bbI', + PacketType.SEQUENCE, + PACKET_FLAG_LAST + if self.current_packet_index == self.tx_packet_count - 1 + else 0, + self.current_packet_index, + ) + bytes(self.tx_packet_size - 6) + print(color(f'Sending packet {self.current_packet_index}', 'yellow')) + self.ping_sent_time = time.time() + await self.packet_io.send_packet(packet) + + def on_packet_received(self, packet): + elapsed = time.time() - self.ping_sent_time + + try: + packet_type, packet_data = parse_packet(packet) + except ValueError: + return + + try: + packet_flags, packet_index = parse_packet_sequence(packet_data) + except ValueError: + return + + if packet_type == PacketType.ACK: + latency = elapsed * 1000 + self.latencies.append(latency) + print( + color( + f'<<< Received ACK [{packet_index}], latency={latency:.2f}ms', + 'green', + ) + ) + + if packet_index == self.current_packet_index: + self.current_packet_index += 1 + else: + print( + color( + f'!!! Unexpected packet, expected {self.current_packet_index} ' + f'but received {packet_index}' + ) + ) + + if packet_flags & PACKET_FLAG_LAST: + self.done.set() + return + + AsyncRunner.spawn(self.send_next_ping()) + + +# ----------------------------------------------------------------------------- +# Pong +# ----------------------------------------------------------------------------- +class Pong: + def __init__(self, packet_io): + self.reset() + self.packet_io = packet_io + self.packet_io.packet_listener = self + self.done = asyncio.Event() + + def reset(self): + self.expected_packet_index = 0 + + def on_packet_received(self, packet): + try: + packet_type, packet_data = parse_packet(packet) + except ValueError: + return + + if packet_type == PacketType.RESET: + print(color('=== Received RESET', 'magenta')) + self.reset() + return + + try: + packet_flags, packet_index = parse_packet_sequence(packet_data) + except ValueError: + return + print( + color( + f'<<< Received packet {packet_index}: ' + f'flags=0x{packet_flags:02X}, {len(packet)} bytes', + 'green', + ) + ) + + if packet_index != self.expected_packet_index: + print( + color( + f'!!! Unexpected packet, expected {self.expected_packet_index} ' + f'but received {packet_index}' + ) + ) + + self.expected_packet_index = packet_index + 1 + + AsyncRunner.spawn( + self.packet_io.send_packet( + struct.pack('>bbI', PacketType.ACK, packet_flags, packet_index) + ) + ) + + if packet_flags & PACKET_FLAG_LAST: + self.done.set() + + async def run(self): + await self.done.wait() + print(color('=== Done!', 'magenta')) + + +# ----------------------------------------------------------------------------- +# GattClient +# ----------------------------------------------------------------------------- +class GattClient: + def __init__(self, _device, att_mtu=None): + self.att_mtu = att_mtu + self.speed_rx = None + self.speed_tx = None + self.packet_listener = None + self.ready = asyncio.Event() + + async def on_connection(self, connection): + peer = Peer(connection) + + if self.att_mtu: + print(color(f'*** Requesting MTU update: {self.att_mtu}', 'blue')) + await peer.request_mtu(self.att_mtu) + + print(color('*** Discovering services...', 'blue')) + await peer.discover_services() + + speed_services = peer.get_services_by_uuid(SPEED_SERVICE_UUID) + if not speed_services: + print(color('!!! Speed Service not found', 'red')) + return + speed_service = speed_services[0] + print(color('*** Discovering characteristics...', 'blue')) + await speed_service.discover_characteristics() + + speed_txs = speed_service.get_characteristics_by_uuid(SPEED_TX_UUID) + if not speed_txs: + print(color('!!! Speed TX not found', 'red')) + return + self.speed_tx = speed_txs[0] + + speed_rxs = speed_service.get_characteristics_by_uuid(SPEED_RX_UUID) + if not speed_rxs: + print(color('!!! Speed RX not found', 'red')) + return + self.speed_rx = speed_rxs[0] + + print(color('*** Subscribing to RX', 'blue')) + await self.speed_rx.subscribe(self.on_packet_received) + + print(color('*** Discovery complete', 'blue')) + + connection.on('disconnection', self.on_disconnection) + self.ready.set() + + def on_disconnection(self, _): + self.ready.clear() + + def on_packet_received(self, packet): + if self.packet_listener: + self.packet_listener.on_packet_received(packet) + + async def send_packet(self, packet): + await self.speed_tx.write_value(packet) + + +# ----------------------------------------------------------------------------- +# GattServer +# ----------------------------------------------------------------------------- +class GattServer: + def __init__(self, device): + self.device = device + self.packet_listener = None + self.ready = asyncio.Event() + + # Setup the GATT service + self.speed_tx = Characteristic( + SPEED_TX_UUID, + Characteristic.WRITE, + Characteristic.WRITEABLE, + CharacteristicValue(write=self.on_tx_write), + ) + self.speed_rx = Characteristic(SPEED_RX_UUID, Characteristic.NOTIFY, 0) + + speed_service = Service( + SPEED_SERVICE_UUID, + [self.speed_tx, self.speed_rx], + ) + device.add_services([speed_service]) + + self.speed_rx.on('subscription', self.on_rx_subscription) + + async def on_connection(self, connection): + connection.on('disconnection', self.on_disconnection) + + def on_disconnection(self, _): + self.ready.clear() + + def on_rx_subscription(self, _connection, notify_enabled, _indicate_enabled): + if notify_enabled: + print(color('*** RX subscription', 'blue')) + self.ready.set() + else: + print(color('*** RX un-subscription', 'blue')) + self.ready.clear() + + def on_tx_write(self, _, value): + if self.packet_listener: + self.packet_listener.on_packet_received(value) + + async def send_packet(self, packet): + await self.device.notify_subscribers(self.speed_rx, packet) + + +# ----------------------------------------------------------------------------- +# StreamedPacketIO +# ----------------------------------------------------------------------------- +class StreamedPacketIO: + def __init__(self): + self.packet_listener = None + self.io_sink = None + self.rx_packet = b'' + self.rx_packet_header = b'' + self.rx_packet_need = 0 + + def on_packet(self, packet): + while packet: + if self.rx_packet_need: + chunk = packet[: self.rx_packet_need] + self.rx_packet += chunk + packet = packet[len(chunk) :] + self.rx_packet_need -= len(chunk) + if not self.rx_packet_need: + # Packet completed + if self.packet_listener: + self.packet_listener.on_packet_received(self.rx_packet) + + self.rx_packet = b'' + self.rx_packet_header = b'' + else: + # Expect the next packet + header_bytes_needed = 2 - len(self.rx_packet_header) + header_bytes = packet[:header_bytes_needed] + self.rx_packet_header += header_bytes + if len(self.rx_packet_header) != 2: + return + packet = packet[len(header_bytes) :] + self.rx_packet_need = struct.unpack('>H', self.rx_packet_header)[0] + + async def send_packet(self, packet): + if not self.io_sink: + print(color('!!! No sink, dropping packet', 'red')) + return + + # pylint: disable-next=not-callable + self.io_sink(struct.pack('>H', len(packet)) + packet) + + +# ----------------------------------------------------------------------------- +# L2capClient +# ----------------------------------------------------------------------------- +class L2capClient(StreamedPacketIO): + def __init__( + self, + _device, + psm=DEFAULT_L2CAP_PSM, + max_credits=DEFAULT_L2CAP_MAX_CREDITS, + mtu=DEFAULT_L2CAP_MTU, + mps=DEFAULT_L2CAP_MPS, + ): + super().__init__() + self.psm = psm + self.max_credits = max_credits + self.mtu = mtu + self.mps = mps + self.ready = asyncio.Event() + + async def on_connection(self, connection): + connection.on('disconnection', self.on_disconnection) + + # Connect a new L2CAP channel + print(color(f'>>> Opening L2CAP channel on PSM = {self.psm}', 'yellow')) + try: + l2cap_channel = await connection.open_l2cap_channel( + psm=self.psm, + max_credits=self.max_credits, + mtu=self.mtu, + mps=self.mps, + ) + print(color('*** L2CAP channel:', 'cyan'), l2cap_channel) + except Exception as error: + print(color(f'!!! Connection failed: {error}', 'red')) + return + + l2cap_channel.sink = self.on_packet + l2cap_channel.on('close', self.on_l2cap_close) + self.io_sink = l2cap_channel.write + + self.ready.set() + + def on_disconnection(self, _): + pass + + def on_l2cap_close(self): + print(color('*** L2CAP channel closed', 'red')) + + +# ----------------------------------------------------------------------------- +# L2capServer +# ----------------------------------------------------------------------------- +class L2capServer(StreamedPacketIO): + def __init__( + self, + device, + psm=DEFAULT_L2CAP_PSM, + max_credits=DEFAULT_L2CAP_MAX_CREDITS, + mtu=DEFAULT_L2CAP_MTU, + mps=DEFAULT_L2CAP_MPS, + ): + super().__init__() + self.l2cap_channel = None + self.ready = asyncio.Event() + + # Listen for incoming L2CAP CoC connections + device.register_l2cap_channel_server( + psm=psm, + server=self.on_l2cap_channel, + max_credits=max_credits, + mtu=mtu, + mps=mps, + ) + print(color(f'### Listening for CoC connection on PSM {psm}', 'yellow')) + + async def on_connection(self, connection): + connection.on('disconnection', self.on_disconnection) + + def on_disconnection(self, _): + pass + + def on_l2cap_channel(self, l2cap_channel): + print(color('*** L2CAP channel:', 'cyan'), l2cap_channel) + + self.io_sink = l2cap_channel.write + l2cap_channel.on('close', self.on_l2cap_close) + l2cap_channel.sink = self.on_packet + + self.ready.set() + + def on_l2cap_close(self): + print(color('*** L2CAP channel closed', 'red')) + self.l2cap_channel = None + + +# ----------------------------------------------------------------------------- +# RfcommClient +# ----------------------------------------------------------------------------- +class RfcommClient(StreamedPacketIO): + def __init__(self, device): + super().__init__() + self.device = device + self.ready = asyncio.Event() + + async def on_connection(self, connection): + connection.on('disconnection', self.on_disconnection) + + # Create a client and start it + print(color('*** Starting RFCOMM client...', 'blue')) + rfcomm_client = bumble.rfcomm.Client(self.device, connection) + rfcomm_mux = await rfcomm_client.start() + print(color('*** Started', 'blue')) + + channel = DEFAULT_RFCOMM_CHANNEL + print(color(f'### Opening session for channel {channel}...', 'yellow')) + try: + rfcomm_session = await rfcomm_mux.open_dlc(channel) + print(color('### Session open', 'yellow'), rfcomm_session) + except bumble.core.ConnectionError as error: + print(color(f'!!! Session open failed: {error}', 'red')) + await rfcomm_mux.disconnect() + return + + rfcomm_session.sink = self.on_packet + self.io_sink = rfcomm_session.write + + self.ready.set() + + def on_disconnection(self, _): + pass + + +# ----------------------------------------------------------------------------- +# RfcommServer +# ----------------------------------------------------------------------------- +class RfcommServer(StreamedPacketIO): + def __init__(self, device): + super().__init__() + self.ready = asyncio.Event() + + # Create and register a server + rfcomm_server = bumble.rfcomm.Server(device) + + # Listen for incoming DLC connections + channel_number = rfcomm_server.listen(self.on_dlc, DEFAULT_RFCOMM_CHANNEL) + + # Setup the SDP to advertise this channel + device.sdp_service_records = make_sdp_records(channel_number) + + print( + color( + f'### Listening for RFComm connection on channel {channel_number}', + 'yellow', + ) + ) + + async def on_connection(self, connection): + connection.on('disconnection', self.on_disconnection) + + def on_disconnection(self, _): + pass + + def on_dlc(self, dlc): + print(color('*** DLC connected:', 'blue'), dlc) + dlc.sink = self.on_packet + self.io_sink = dlc.write + + +# ----------------------------------------------------------------------------- +# Central +# ----------------------------------------------------------------------------- +class Central(Connection.Listener): + def __init__( + self, + transport, + peripheral_address, + classic, + role_factory, + mode_factory, + connection_interval, + phy, + ): + super().__init__() + self.transport = transport + self.peripheral_address = peripheral_address + self.classic = classic + self.role_factory = role_factory + self.mode_factory = mode_factory + self.device = None + self.connection = None + + if phy: + self.phy = { + '1m': HCI_LE_1M_PHY, + '2m': HCI_LE_2M_PHY, + 'coded': HCI_LE_CODED_PHY, + }[phy] + else: + self.phy = None + + if connection_interval: + connection_parameter_preferences = ConnectionParametersPreferences() + connection_parameter_preferences.connection_interval_min = ( + connection_interval + ) + connection_parameter_preferences.connection_interval_max = ( + connection_interval + ) + + # Preferences for the 1M PHY are always set. + self.connection_parameter_preferences = { + HCI_LE_1M_PHY: connection_parameter_preferences, + } + + if self.phy not in (None, HCI_LE_1M_PHY): + # Add an connections parameters entry for this PHY. + self.connection_parameter_preferences[ + self.phy + ] = connection_parameter_preferences + else: + self.connection_parameter_preferences = None + + async def run(self): + print(color('>>> Connecting to HCI...', 'green')) + async with await open_transport_or_link(self.transport) as ( + hci_source, + hci_sink, + ): + print(color('>>> Connected', 'green')) + + central_address = DEFAULT_CENTRAL_ADDRESS + self.device = Device.with_hci( + DEFAULT_CENTRAL_NAME, central_address, hci_source, hci_sink + ) + mode = self.mode_factory(self.device) + role = self.role_factory(mode) + self.device.classic_enabled = self.classic + + await self.device.power_on() + + print(color(f'### Connecting to {self.peripheral_address}...', 'cyan')) + try: + self.connection = await self.device.connect( + self.peripheral_address, + connection_parameters_preferences=self.connection_parameter_preferences, + transport=BT_BR_EDR_TRANSPORT if self.classic else BT_LE_TRANSPORT, + ) + except CommandTimeoutError: + print(color('!!! Connection timed out', 'red')) + return + except bumble.core.ConnectionError as error: + print(color(f'!!! Connection error: {error}', 'red')) + return + except HCI_StatusError as error: + print(color(f'!!! Connection failed: {error.error_name}')) + return + print(color('### Connected', 'cyan')) + self.connection.listener = self + print_connection(self.connection) + + await mode.on_connection(self.connection) + + # Set the PHY if requested + if self.phy is not None: + try: + await self.connection.set_phy( + tx_phys=[self.phy], rx_phys=[self.phy] + ) + except HCI_Error as error: + print( + color( + f'!!! Unable to set the PHY: {error.error_name}', 'yellow' + ) + ) + + await role.run() + await asyncio.sleep(DEFAULT_LINGER_TIME) + + def on_disconnection(self, reason): + print(color(f'!!! Disconnection: reason={reason}', 'red')) + self.connection = None + + def on_connection_parameters_update(self): + print_connection(self.connection) + + def on_connection_phy_update(self): + print_connection(self.connection) + + def on_connection_att_mtu_update(self): + print_connection(self.connection) + + def on_connection_data_length_change(self): + print_connection(self.connection) + + +# ----------------------------------------------------------------------------- +# Peripheral +# ----------------------------------------------------------------------------- +class Peripheral(Device.Listener, Connection.Listener): + def __init__(self, transport, classic, role_factory, mode_factory): + self.transport = transport + self.classic = classic + self.role_factory = role_factory + self.role = None + self.mode_factory = mode_factory + self.mode = None + self.device = None + self.connection = None + self.connected = asyncio.Event() + + async def run(self): + print(color('>>> Connecting to HCI...', 'green')) + async with await open_transport_or_link(self.transport) as ( + hci_source, + hci_sink, + ): + print(color('>>> Connected', 'green')) + + peripheral_address = DEFAULT_PERIPHERAL_ADDRESS + self.device = Device.with_hci( + DEFAULT_PERIPHERAL_NAME, peripheral_address, hci_source, hci_sink + ) + self.device.listener = self + self.mode = self.mode_factory(self.device) + self.role = self.role_factory(self.mode) + self.device.classic_enabled = self.classic + + await self.device.power_on() + + if self.classic: + await self.device.set_discoverable(True) + await self.device.set_connectable(True) + else: + await self.device.start_advertising(auto_restart=True) + + if self.classic: + print( + color( + '### Waiting for connection on' + f' {self.device.public_address}...', + 'cyan', + ) + ) + else: + print( + color( + f'### Waiting for connection on {peripheral_address}...', + 'cyan', + ) + ) + await self.connected.wait() + print(color('### Connected', 'cyan')) + + await self.mode.on_connection(self.connection) + await self.role.run() + await asyncio.sleep(DEFAULT_LINGER_TIME) + + def on_connection(self, connection): + connection.listener = self + self.connection = connection + self.connected.set() + + def on_disconnection(self, reason): + print(color(f'!!! Disconnection: reason={reason}', 'red')) + self.connection = None + self.role.reset() + + def on_connection_parameters_update(self): + print_connection(self.connection) + + def on_connection_phy_update(self): + print_connection(self.connection) + + def on_connection_att_mtu_update(self): + print_connection(self.connection) + + def on_connection_data_length_change(self): + print_connection(self.connection) + + +# ----------------------------------------------------------------------------- +def create_mode_factory(ctx, default_mode): + mode = ctx.obj['mode'] + if mode is None: + mode = default_mode + + def create_mode(device): + if mode == 'gatt-client': + return GattClient(device, att_mtu=ctx.obj['att_mtu']) + + if mode == 'gatt-server': + return GattServer(device) + + if mode == 'l2cap-client': + return L2capClient(device) + + if mode == 'l2cap-server': + return L2capServer(device) + + if mode == 'rfcomm-client': + return RfcommClient(device) + + if mode == 'rfcomm-server': + return RfcommServer(device) + + raise ValueError('invalid mode') + + return create_mode + + +# ----------------------------------------------------------------------------- +def create_role_factory(ctx, default_role): + role = ctx.obj['role'] + if role is None: + role = default_role + + def create_role(packet_io): + if role == 'sender': + return Sender( + packet_io, + start_delay=ctx.obj['start_delay'], + packet_size=ctx.obj['packet_size'], + packet_count=ctx.obj['packet_count'], + ) + + if role == 'receiver': + return Receiver(packet_io) + + if role == 'ping': + return Ping( + packet_io, + start_delay=ctx.obj['start_delay'], + packet_size=ctx.obj['packet_size'], + packet_count=ctx.obj['packet_count'], + ) + + if role == 'pong': + return Pong(packet_io) + + raise ValueError('invalid role') + + return create_role + + +# ----------------------------------------------------------------------------- +# Main +# ----------------------------------------------------------------------------- +@click.group() +@click.option('--device-config', metavar='FILENAME', help='Device configuration file') +@click.option('--role', type=click.Choice(['sender', 'receiver', 'ping', 'pong'])) +@click.option( + '--mode', + type=click.Choice( + [ + 'gatt-client', + 'gatt-server', + 'l2cap-client', + 'l2cap-server', + 'rfcomm-client', + 'rfcomm-server', + ] + ), +) +@click.option( + '--att-mtu', + metavar='MTU', + type=click.IntRange(23, 517), + help='GATT MTU (gatt-client mode)', +) +@click.option( + '--packet-size', + '-s', + metavar='SIZE', + type=click.IntRange(8, 4096), + default=500, + help='Packet size (server role)', +) +@click.option( + '--packet-count', + '-c', + metavar='COUNT', + type=int, + default=10, + help='Packet count (server role)', +) +@click.option( + '--start-delay', + '-sd', + metavar='SECONDS', + type=int, + default=1, + help='Start delay (server role)', +) +@click.pass_context +def bench( + ctx, device_config, role, mode, att_mtu, packet_size, packet_count, start_delay +): + ctx.ensure_object(dict) + ctx.obj['device_config'] = device_config + ctx.obj['role'] = role + ctx.obj['mode'] = mode + ctx.obj['att_mtu'] = att_mtu + ctx.obj['packet_size'] = packet_size + ctx.obj['packet_count'] = packet_count + ctx.obj['start_delay'] = start_delay + + ctx.obj['classic'] = mode in ('rfcomm-client', 'rfcomm-server') + + +@bench.command() +@click.argument('transport') +@click.option( + '--peripheral', + 'peripheral_address', + metavar='ADDRESS_OR_NAME', + default=DEFAULT_PERIPHERAL_ADDRESS, + help='Address or name to connect to', +) +@click.option( + '--connection-interval', + '--ci', + metavar='CONNECTION_INTERVAL', + type=int, + help='Connection interval (in ms)', +) +@click.option('--phy', type=click.Choice(['1m', '2m', 'coded']), help='PHY to use') +@click.pass_context +def central(ctx, transport, peripheral_address, connection_interval, phy): + """Run as a central (initiates the connection)""" + role_factory = create_role_factory(ctx, 'sender') + mode_factory = create_mode_factory(ctx, 'gatt-client') + classic = ctx.obj['classic'] + + asyncio.run( + Central( + transport, + peripheral_address, + classic, + role_factory, + mode_factory, + connection_interval, + phy, + ).run() + ) + + +@bench.command() +@click.argument('transport') +@click.pass_context +def peripheral(ctx, transport): + """Run as a peripheral (waits for a connection)""" + role_factory = create_role_factory(ctx, 'receiver') + mode_factory = create_mode_factory(ctx, 'gatt-server') + + asyncio.run( + Peripheral(transport, ctx.obj['classic'], role_factory, mode_factory).run() + ) + + +def main(): + logging.basicConfig(level=os.environ.get('BUMBLE_LOGLEVEL', 'INFO').upper()) + bench() + + +# ----------------------------------------------------------------------------- +if __name__ == "__main__": + main() # pylint: disable=no-value-for-parameter diff --git a/apps/console.py b/apps/console.py index b7c30c7..26223d7 100644 --- a/apps/console.py +++ b/apps/console.py @@ -24,6 +24,7 @@ import logging import os import random import re +from typing import Optional from collections import OrderedDict import click @@ -58,6 +59,7 @@ from bumble.device import ConnectionParametersPreferences, Device, Connection, P from bumble.utils import AsyncRunner from bumble.transport import open_transport_or_link from bumble.gatt import Characteristic, Service, CharacteristicDeclaration, Descriptor +from bumble.gatt_client import CharacteristicProxy from bumble.hci import ( HCI_Constant, HCI_LE_1M_PHY, @@ -119,6 +121,8 @@ def parse_phys(phys): # Console App # ----------------------------------------------------------------------------- class ConsoleApp: + connected_peer: Optional[Peer] + def __init__(self): self.known_addresses = set() self.known_attributes = [] @@ -218,7 +222,7 @@ class ConsoleApp: filter=Condition(lambda: self.top_tab == 'local-services'), ), ConditionalContainer( - Frame(Window(self.remote_services_text), title='Remove Services'), + Frame(Window(self.remote_services_text), title='Remote Services'), filter=Condition(lambda: self.top_tab == 'remote-services'), ), ConditionalContainer( @@ -490,7 +494,9 @@ class ConsoleApp: self.show_attributes(attributes) - def find_characteristic(self, param): + def find_characteristic(self, param) -> Optional[CharacteristicProxy]: + if not self.connected_peer: + return None parts = param.split('.') if len(parts) == 2: service_uuid = UUID(parts[0]) if parts[0] != '*' else None diff --git a/apps/controller_info.py b/apps/controller_info.py index 9c9345e..4707983 100644 --- a/apps/controller_info.py +++ b/apps/controller_info.py @@ -30,6 +30,8 @@ from bumble.hci import ( HCI_VERSION_NAMES, LMP_VERSION_NAMES, HCI_Command, + HCI_Command_Complete_Event, + HCI_Command_Status_Event, HCI_READ_BD_ADDR_COMMAND, HCI_Read_BD_ADDR_Command, HCI_READ_LOCAL_NAME_COMMAND, @@ -46,10 +48,19 @@ from bumble.transport import open_transport_or_link # ----------------------------------------------------------------------------- +def command_succeeded(response): + if isinstance(response, HCI_Command_Status_Event): + return response.status == HCI_SUCCESS + if isinstance(response, HCI_Command_Complete_Event): + return response.return_parameters.status == HCI_SUCCESS + return False + + +# ----------------------------------------------------------------------------- async def get_classic_info(host): if host.supports_command(HCI_READ_BD_ADDR_COMMAND): response = await host.send_command(HCI_Read_BD_ADDR_Command()) - if response.return_parameters.status == HCI_SUCCESS: + if command_succeeded(response): print() print( color('Classic Address:', 'yellow'), response.return_parameters.bd_addr @@ -57,7 +68,7 @@ async def get_classic_info(host): if host.supports_command(HCI_READ_LOCAL_NAME_COMMAND): response = await host.send_command(HCI_Read_Local_Name_Command()) - if response.return_parameters.status == HCI_SUCCESS: + if command_succeeded(response): print() print( color('Local Name:', 'yellow'), @@ -73,7 +84,7 @@ async def get_le_info(host): response = await host.send_command( HCI_LE_Read_Number_Of_Supported_Advertising_Sets_Command() ) - if response.return_parameters.status == HCI_SUCCESS: + if command_succeeded(response): print( color('LE Number Of Supported Advertising Sets:', 'yellow'), response.return_parameters.num_supported_advertising_sets, @@ -84,7 +95,7 @@ async def get_le_info(host): response = await host.send_command( HCI_LE_Read_Maximum_Advertising_Data_Length_Command() ) - if response.return_parameters.status == HCI_SUCCESS: + if command_succeeded(response): print( color('LE Maximum Advertising Data Length:', 'yellow'), response.return_parameters.max_advertising_data_length, @@ -93,7 +104,7 @@ async def get_le_info(host): if host.supports_command(HCI_LE_READ_MAXIMUM_DATA_LENGTH_COMMAND): response = await host.send_command(HCI_LE_Read_Maximum_Data_Length_Command()) - if response.return_parameters.status == HCI_SUCCESS: + if command_succeeded(response): print( color('Maximum Data Length:', 'yellow'), ( diff --git a/bumble/att.py b/bumble/att.py index 2a15f00..8311d18 100644 --- a/bumble/att.py +++ b/bumble/att.py @@ -23,12 +23,13 @@ # Imports # ----------------------------------------------------------------------------- from __future__ import annotations +import functools import struct from pyee import EventEmitter from typing import Dict, Type, TYPE_CHECKING -from bumble.core import UUID, name_or_number -from bumble.hci import HCI_Object, key_with_value +from bumble.core import UUID, name_or_number, get_dict_key_by_value, ProtocolError +from bumble.hci import HCI_Object, key_with_value, HCI_Constant from bumble.colors import color if TYPE_CHECKING: @@ -184,13 +185,18 @@ UUID_2_FIELD_SPEC = lambda x, y: UUID.parse_uuid_2(x, y) # noqa: E731 # ----------------------------------------------------------------------------- # Exceptions # ----------------------------------------------------------------------------- -class ATT_Error(Exception): - def __init__(self, error_code, att_handle=0x0000): - self.error_code = error_code +class ATT_Error(ProtocolError): + def __init__(self, error_code, att_handle=0x0000, message=''): + super().__init__( + error_code, + error_namespace='att', + error_name=ATT_PDU.error_name(error_code), + ) self.att_handle = att_handle + self.message = message def __str__(self): - return f'ATT_Error({ATT_PDU.error_name(self.error_code)})' + return f'ATT_Error(error={self.error_name}, handle={self.att_handle:04X}): {self.message}' # ----------------------------------------------------------------------------- @@ -725,11 +731,38 @@ class Attribute(EventEmitter): READ_REQUIRES_AUTHORIZATION = 0x40 WRITE_REQUIRES_AUTHORIZATION = 0x80 + PERMISSION_NAMES = { + READABLE: 'READABLE', + WRITEABLE: 'WRITEABLE', + READ_REQUIRES_ENCRYPTION: 'READ_REQUIRES_ENCRYPTION', + WRITE_REQUIRES_ENCRYPTION: 'WRITE_REQUIRES_ENCRYPTION', + READ_REQUIRES_AUTHENTICATION: 'READ_REQUIRES_AUTHENTICATION', + WRITE_REQUIRES_AUTHENTICATION: 'WRITE_REQUIRES_AUTHENTICATION', + READ_REQUIRES_AUTHORIZATION: 'READ_REQUIRES_AUTHORIZATION', + WRITE_REQUIRES_AUTHORIZATION: 'WRITE_REQUIRES_AUTHORIZATION', + } + + @staticmethod + def string_to_permissions(permissions_str: str): + try: + return functools.reduce( + lambda x, y: x | get_dict_key_by_value(Attribute.PERMISSION_NAMES, y), + permissions_str.split(","), + 0, + ) + except TypeError: + raise TypeError( + f"Attribute::permissions error:\nExpected a string containing any of the keys, seperated by commas: {','.join(Attribute.PERMISSION_NAMES.values())}\nGot: {permissions_str}" + ) + def __init__(self, attribute_type, permissions, value=b''): EventEmitter.__init__(self) self.handle = 0 self.end_group_handle = 0 - self.permissions = permissions + if isinstance(permissions, str): + self.permissions = self.string_to_permissions(permissions) + else: + self.permissions = permissions # Convert the type to a UUID object if it isn't already if isinstance(attribute_type, str): diff --git a/bumble/controller.py b/bumble/controller.py index da5d4cf..cd7de3d 100644 --- a/bumble/controller.py +++ b/bumble/controller.py @@ -21,7 +21,12 @@ import itertools import random import struct from bumble.colors import color -from bumble.core import BT_CENTRAL_ROLE, BT_PERIPHERAL_ROLE +from bumble.core import ( + BT_CENTRAL_ROLE, + BT_PERIPHERAL_ROLE, + BT_LE_TRANSPORT, + BT_BR_EDR_TRANSPORT, +) from bumble.hci import ( HCI_ACL_DATA_PACKET, @@ -29,17 +34,21 @@ from bumble.hci import ( HCI_COMMAND_PACKET, HCI_COMMAND_STATUS_PENDING, HCI_CONNECTION_TIMEOUT_ERROR, + HCI_CONTROLLER_BUSY_ERROR, HCI_EVENT_PACKET, HCI_INVALID_HCI_COMMAND_PARAMETERS_ERROR, HCI_LE_1M_PHY, HCI_SUCCESS, HCI_UNKNOWN_HCI_COMMAND_ERROR, + HCI_REMOTE_USER_TERMINATED_CONNECTION_ERROR, HCI_VERSION_BLUETOOTH_CORE_5_0, Address, HCI_AclDataPacket, HCI_AclDataPacketAssembler, HCI_Command_Complete_Event, HCI_Command_Status_Event, + HCI_Connection_Complete_Event, + HCI_Connection_Request_Event, HCI_Disconnection_Complete_Event, HCI_Encryption_Change_Event, HCI_LE_Advertising_Report_Event, @@ -47,7 +56,9 @@ from bumble.hci import ( HCI_LE_Read_Remote_Features_Complete_Event, HCI_Number_Of_Completed_Packets_Event, HCI_Packet, + HCI_Role_Change_Event, ) +from typing import Optional, Union, Dict # ----------------------------------------------------------------------------- @@ -65,13 +76,14 @@ class DataObject: # ----------------------------------------------------------------------------- class Connection: - def __init__(self, controller, handle, role, peer_address, link): + def __init__(self, controller, handle, role, peer_address, link, transport): self.controller = controller self.handle = handle self.role = role self.peer_address = peer_address self.link = link self.assembler = HCI_AclDataPacketAssembler(self.on_acl_pdu) + self.transport = transport def on_hci_acl_data_packet(self, packet): self.assembler.feed_packet(packet) @@ -82,23 +94,33 @@ class Connection: def on_acl_pdu(self, data): if self.link: self.link.send_acl_data( - self.controller.random_address, self.peer_address, data + self.controller, self.peer_address, self.transport, data ) # ----------------------------------------------------------------------------- class Controller: - def __init__(self, name, host_source=None, host_sink=None, link=None): + def __init__( + self, + name, + host_source=None, + host_sink=None, + link=None, + public_address: Optional[Union[bytes, str, Address]] = None, + ): self.name = name self.hci_sink = None self.link = link - self.central_connections = ( - {} - ) # Connections where this controller is the central - self.peripheral_connections = ( - {} - ) # Connections where this controller is the peripheral + self.central_connections: Dict[ + Address, Connection + ] = {} # Connections where this controller is the central + self.peripheral_connections: Dict[ + Address, Connection + ] = {} # Connections where this controller is the peripheral + self.classic_connections: Dict[ + Address, Connection + ] = {} # Connections in BR/EDR self.hci_version = HCI_VERSION_BLUETOOTH_CORE_5_0 self.hci_revision = 0 @@ -148,7 +170,14 @@ class Controller: self.advertising_timer_handle = None self._random_address = Address('00:00:00:00:00:00') - self._public_address = None + if isinstance(public_address, Address): + self._public_address = public_address + elif public_address is not None: + self._public_address = Address( + public_address, Address.PUBLIC_DEVICE_ADDRESS + ) + else: + self._public_address = Address('00:00:00:00:00:00') # Set the source and sink interfaces if host_source: @@ -271,7 +300,9 @@ class Controller: handle = 0 max_handle = 0 for connection in itertools.chain( - self.central_connections.values(), self.peripheral_connections.values() + self.central_connections.values(), + self.peripheral_connections.values(), + self.classic_connections.values(), ): max_handle = max(max_handle, connection.handle) if connection.handle == handle: @@ -279,14 +310,19 @@ class Controller: handle = max_handle + 1 return handle - def find_connection_by_address(self, address): + def find_le_connection_by_address(self, address): return self.central_connections.get(address) or self.peripheral_connections.get( address ) + def find_classic_connection_by_address(self, address): + return self.classic_connections.get(address) + def find_connection_by_handle(self, handle): for connection in itertools.chain( - self.central_connections.values(), self.peripheral_connections.values() + self.central_connections.values(), + self.peripheral_connections.values(), + self.classic_connections.values(), ): if connection.handle == handle: return connection @@ -298,6 +334,12 @@ class Controller: return connection return None + def find_classic_connection_by_handle(self, handle): + for connection in self.classic_connections.values(): + if connection.handle == handle: + return connection + return None + def on_link_central_connected(self, central_address): ''' Called when an incoming connection occurs from a central on the link @@ -310,7 +352,12 @@ class Controller: if connection is None: connection_handle = self.allocate_connection_handle() connection = Connection( - self, connection_handle, BT_PERIPHERAL_ROLE, peer_address, self.link + self, + connection_handle, + BT_PERIPHERAL_ROLE, + peer_address, + self.link, + BT_LE_TRANSPORT, ) self.peripheral_connections[peer_address] = connection logger.debug(f'New PERIPHERAL connection handle: 0x{connection_handle:04X}') @@ -364,7 +411,12 @@ class Controller: if connection is None: connection_handle = self.allocate_connection_handle() connection = Connection( - self, connection_handle, BT_CENTRAL_ROLE, peer_address, self.link + self, + connection_handle, + BT_CENTRAL_ROLE, + peer_address, + self.link, + BT_LE_TRANSPORT, ) self.central_connections[peer_address] = connection logger.debug( @@ -432,16 +484,19 @@ class Controller: def on_link_encrypted(self, peer_address, _rand, _ediv, _ltk): # For now, just setup the encryption without asking the host - if connection := self.find_connection_by_address(peer_address): + if connection := self.find_le_connection_by_address(peer_address): self.send_hci_packet( HCI_Encryption_Change_Event( status=0, connection_handle=connection.handle, encryption_enabled=1 ) ) - def on_link_acl_data(self, sender_address, data): + def on_link_acl_data(self, sender_address, transport, data): # Look for the connection to which this data belongs - connection = self.find_connection_by_address(sender_address) + if transport == BT_LE_TRANSPORT: + connection = self.find_le_connection_by_address(sender_address) + else: + connection = self.find_classic_connection_by_address(sender_address) if connection is None: logger.warning(f'!!! no connection for {sender_address}') return @@ -479,6 +534,87 @@ class Controller: self.send_hci_packet(HCI_LE_Advertising_Report_Event([report])) ############################################################ + # Classic link connections + ############################################################ + + def on_classic_connection_request(self, peer_address, link_type): + self.send_hci_packet( + HCI_Connection_Request_Event( + bd_addr=peer_address, + class_of_device=0, + link_type=link_type, + ) + ) + + def on_classic_connection_complete(self, peer_address, status): + if status == HCI_SUCCESS: + # Allocate (or reuse) a connection handle + peer_address = peer_address + connection = self.classic_connections.get(peer_address) + if connection is None: + connection_handle = self.allocate_connection_handle() + connection = Connection( + controller=self, + handle=connection_handle, + # Role doesn't matter in Classic because they are managed by HCI_Role_Change and HCI_Role_Discovery + role=BT_CENTRAL_ROLE, + peer_address=peer_address, + link=self.link, + transport=BT_BR_EDR_TRANSPORT, + ) + self.classic_connections[peer_address] = connection + logger.debug( + f'New CLASSIC connection handle: 0x{connection_handle:04X}' + ) + else: + connection_handle = connection.handle + self.send_hci_packet( + HCI_Connection_Complete_Event( + status=status, + connection_handle=connection_handle, + bd_addr=peer_address, + encryption_enabled=False, + link_type=HCI_Connection_Complete_Event.ACL_LINK_TYPE, + ) + ) + else: + connection = None + self.send_hci_packet( + HCI_Connection_Complete_Event( + status=status, + connection_handle=0, + bd_addr=peer_address, + encryption_enabled=False, + link_type=HCI_Connection_Complete_Event.ACL_LINK_TYPE, + ) + ) + + def on_classic_disconnected(self, peer_address, reason): + # Send a disconnection complete event + if connection := self.classic_connections.get(peer_address): + self.send_hci_packet( + HCI_Disconnection_Complete_Event( + status=HCI_SUCCESS, + connection_handle=connection.handle, + reason=reason, + ) + ) + + # Remove the connection + del self.classic_connections[peer_address] + else: + logger.warning(f'!!! No classic connection found for {peer_address}') + + def on_classic_role_change(self, peer_address, new_role): + self.send_hci_packet( + HCI_Role_Change_Event( + status=HCI_SUCCESS, + bd_addr=peer_address, + new_role=new_role, + ) + ) + + ############################################################ # Advertising support ############################################################ def on_advertising_timer_fired(self): @@ -521,7 +657,31 @@ class Controller: See Bluetooth spec Vol 2, Part E - 7.1.5 Create Connection command ''' - # TODO: classic mode not supported yet + if self.link is None: + return + logger.debug(f'Connection request to {command.bd_addr}') + + # Check that we don't already have a pending connection + if self.link.get_pending_connection(): + self.send_hci_packet( + HCI_Command_Status_Event( + status=HCI_CONTROLLER_BUSY_ERROR, + num_hci_command_packets=1, + command_opcode=command.op_code, + ) + ) + return + + self.link.classic_connect(self, command.bd_addr) + + # Say that the connection is pending + self.send_hci_packet( + HCI_Command_Status_Event( + status=HCI_COMMAND_STATUS_PENDING, + num_hci_command_packets=1, + command_opcode=command.op_code, + ) + ) def on_hci_disconnect_command(self, command): ''' @@ -537,19 +697,57 @@ class Controller: ) # Notify the link of the disconnection - if not ( - connection := self.find_central_connection_by_handle( - command.connection_handle - ) - ): - logger.warning('connection not found') + handle = command.connection_handle + if connection := self.find_central_connection_by_handle(handle): + if self.link: + self.link.disconnect( + self.random_address, connection.peer_address, command + ) + else: + # Remove the connection + del self.central_connections[connection.peer_address] + elif connection := self.find_classic_connection_by_handle(handle): + if self.link: + self.link.classic_disconnect( + self, + connection.peer_address, + HCI_REMOTE_USER_TERMINATED_CONNECTION_ERROR, + ) + else: + # Remove the connection + del self.classic_connections[connection.peer_address] + + def on_hci_accept_connection_request_command(self, command): + ''' + See Bluetooth spec Vol 2, Part E - 7.1.8 Accept Connection Request command + ''' + + if self.link is None: return + self.send_hci_packet( + HCI_Command_Status_Event( + status=HCI_SUCCESS, + num_hci_command_packets=1, + command_opcode=command.op_code, + ) + ) + self.link.classic_accept_connection(self, command.bd_addr, command.role) - if self.link: - self.link.disconnect(self.random_address, connection.peer_address, command) - else: - # Remove the connection - del self.central_connections[connection.peer_address] + def on_hci_switch_role_command(self, command): + ''' + See Bluetooth spec Vol 2, Part E - 7.2.8 Switch Role command + ''' + + if self.link is None: + return + self.send_hci_packet( + HCI_Command_Status_Event( + status=HCI_SUCCESS, + num_hci_command_packets=1, + command_opcode=command.op_code, + ) + ) + self.link.classic_switch_role(self, command.bd_addr, command.role) def on_hci_set_event_mask_command(self, command): ''' @@ -627,6 +825,12 @@ class Controller: ret = HCI_INVALID_HCI_COMMAND_PARAMETERS_ERROR return bytes([ret]) + def on_hci_write_extended_inquiry_response_command(self, _command): + ''' + See Bluetooth spec Vol 2, Part E - 7.3.59 Write Simple Pairing Mode Command + ''' + return bytes([HCI_SUCCESS]) + def on_hci_write_simple_pairing_mode_command(self, _command): ''' See Bluetooth spec Vol 2, Part E - 7.3.59 Write Simple Pairing Mode Command diff --git a/bumble/decoder.py b/bumble/decoder.py new file mode 100644 index 0000000..2eb70bc --- /dev/null +++ b/bumble/decoder.py @@ -0,0 +1,416 @@ +# Copyright 2023 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. + +# ----------------------------------------------------------------------------- +# Constants +# ----------------------------------------------------------------------------- +# fmt: off + +WL = [-60, -30, 58, 172, 334, 538, 1198, 3042] +RL42 = [0, 7, 6, 5, 4, 3, 2, 1, 7, 6, 5, 4, 3, 2, 1, 0] +ILB = [ + 2048, + 2093, + 2139, + 2186, + 2233, + 2282, + 2332, + 2383, + 2435, + 2489, + 2543, + 2599, + 2656, + 2714, + 2774, + 2834, + 2896, + 2960, + 3025, + 3091, + 3158, + 3228, + 3298, + 3371, + 3444, + 3520, + 3597, + 3676, + 3756, + 3838, + 3922, + 4008, +] +WH = [0, -214, 798] +RH2 = [2, 1, 2, 1] +# Values in QM2/QM4/QM6 left shift three bits than original g722 specification. +QM2 = [-7408, -1616, 7408, 1616] +QM4 = [ + 0, + -20456, + -12896, + -8968, + -6288, + -4240, + -2584, + -1200, + 20456, + 12896, + 8968, + 6288, + 4240, + 2584, + 1200, + 0, +] +QM6 = [ + -136, + -136, + -136, + -136, + -24808, + -21904, + -19008, + -16704, + -14984, + -13512, + -12280, + -11192, + -10232, + -9360, + -8576, + -7856, + -7192, + -6576, + -6000, + -5456, + -4944, + -4464, + -4008, + -3576, + -3168, + -2776, + -2400, + -2032, + -1688, + -1360, + -1040, + -728, + 24808, + 21904, + 19008, + 16704, + 14984, + 13512, + 12280, + 11192, + 10232, + 9360, + 8576, + 7856, + 7192, + 6576, + 6000, + 5456, + 4944, + 4464, + 4008, + 3576, + 3168, + 2776, + 2400, + 2032, + 1688, + 1360, + 1040, + 728, + 432, + 136, + -432, + -136, +] +QMF_COEFFS = [3, -11, 12, 32, -210, 951, 3876, -805, 362, -156, 53, -11] + +# fmt: on + + +# ----------------------------------------------------------------------------- +# Classes +# ----------------------------------------------------------------------------- +class G722Decoder(object): + """G.722 decoder with bitrate 64kbit/s. + + For the Blocks in the sub-band decoders, please refer to the G.722 + specification for the required information. G722 specification: + https://www.itu.int/rec/T-REC-G.722-201209-I + """ + + def __init__(self): + self._x = [0] * 24 + self._band = [Band(), Band()] + # The initial value in BLOCK 3L + self._band[0].det = 32 + # The initial value in BLOCK 3H + self._band[1].det = 8 + + def decode_frame(self, encoded_data) -> bytearray: + result_array = bytearray(len(encoded_data) * 4) + self.g722_decode(result_array, encoded_data) + return result_array + + def g722_decode(self, result_array, encoded_data) -> int: + """Decode the data frame using g722 decoder.""" + result_length = 0 + + for code in encoded_data: + higher_bits = (code >> 6) & 0x03 + lower_bits = code & 0x3F + + rlow = self.lower_sub_band_decoder(lower_bits) + rhigh = self.higher_sub_band_decoder(higher_bits) + + # Apply the receive QMF + self._x[:22] = self._x[2:] + self._x[22] = rlow + rhigh + self._x[23] = rlow - rhigh + + xout2 = sum(self._x[2 * i] * QMF_COEFFS[i] for i in range(12)) + xout1 = sum(self._x[2 * i + 1] * QMF_COEFFS[11 - i] for i in range(12)) + + result_length = self.update_decoded_result( + xout1, result_length, result_array + ) + result_length = self.update_decoded_result( + xout2, result_length, result_array + ) + + return result_length + + def update_decoded_result(self, xout, byte_length, byte_array) -> int: + result = (int)(xout >> 11) + bytes_result = result.to_bytes(2, 'little', signed=True) + byte_array[byte_length] = bytes_result[0] + byte_array[byte_length + 1] = bytes_result[1] + return byte_length + 2 + + def lower_sub_band_decoder(self, lower_bits) -> int: + """Lower sub-band decoder for last six bits.""" + + # Block 5L + # INVQBL + wd1 = lower_bits + wd2 = QM6[wd1] + wd1 >>= 2 + wd2 = (self._band[0].det * wd2) >> 15 + # RECONS + rlow = self._band[0].s + wd2 + + # Block 6L + # LIMIT + if rlow > 16383: + rlow = 16383 + elif rlow < -16384: + rlow = -16384 + + # Block 2L + # INVQAL + wd2 = QM4[wd1] + dlowt = (self._band[0].det * wd2) >> 15 + + # Block 3L + # LOGSCL + wd2 = RL42[wd1] + wd1 = (self._band[0].nb * 127) >> 7 + wd1 += WL[wd2] + + if wd1 < 0: + wd1 = 0 + elif wd1 > 18432: + wd1 = 18432 + + self._band[0].nb = wd1 + + # SCALEL + wd1 = (self._band[0].nb >> 6) & 31 + wd2 = 8 - (self._band[0].nb >> 11) + + if wd2 < 0: + wd3 = ILB[wd1] << -wd2 + else: + wd3 = ILB[wd1] >> wd2 + + self._band[0].det = wd3 << 2 + + # Block 4L + self._band[0].block4(dlowt) + + return rlow + + def higher_sub_band_decoder(self, higher_bits) -> int: + """Higher sub-band decoder for first two bits.""" + + # Block 2H + # INVQAH + wd2 = QM2[higher_bits] + dhigh = (self._band[1].det * wd2) >> 15 + + # Block 5H + # RECONS + rhigh = dhigh + self._band[1].s + + # Block 6H + # LIMIT + if rhigh > 16383: + rhigh = 16383 + elif rhigh < -16384: + rhigh = -16384 + + # Block 3H + # LOGSCH + wd2 = RH2[higher_bits] + wd1 = (self._band[1].nb * 127) >> 7 + wd1 += WH[wd2] + + if wd1 < 0: + wd1 = 0 + elif wd1 > 22528: + wd1 = 22528 + self._band[1].nb = wd1 + + # SCALEH + wd1 = (self._band[1].nb >> 6) & 31 + wd2 = 10 - (self._band[1].nb >> 11) + + if wd2 < 0: + wd3 = ILB[wd1] << -wd2 + else: + wd3 = ILB[wd1] >> wd2 + self._band[1].det = wd3 << 2 + + # Block 4H + self._band[1].block4(dhigh) + + return rhigh + + +# ----------------------------------------------------------------------------- +class Band(object): + """Structure for G722 decode proccessing.""" + + s: int = 0 + nb: int = 0 + det: int = 0 + + def __init__(self): + self._sp = 0 + self._sz = 0 + self._r = [0] * 3 + self._a = [0] * 3 + self._ap = [0] * 3 + self._p = [0] * 3 + self._d = [0] * 7 + self._b = [0] * 7 + self._bp = [0] * 7 + self._sg = [0] * 7 + + def saturate(self, amp: int) -> int: + if amp > 32767: + return 32767 + elif amp < -32768: + return -32768 + else: + return amp + + def block4(self, d: int) -> None: + """Block4 for both lower and higher sub-band decoder.""" + wd1 = 0 + wd2 = 0 + wd3 = 0 + + # RECONS + self._d[0] = d + self._r[0] = self.saturate(self.s + d) + + # PARREC + self._p[0] = self.saturate(self._sz + d) + + # UPPOL2 + for i in range(3): + self._sg[i] = (self._p[i]) >> 15 + wd1 = self.saturate((self._a[1]) << 2) + wd2 = -wd1 if self._sg[0] == self._sg[1] else wd1 + + if wd2 > 32767: + wd2 = 32767 + + wd3 = 128 if self._sg[0] == self._sg[2] else -128 + wd3 += wd2 >> 7 + wd3 += (self._a[2] * 32512) >> 15 + + if wd3 > 12288: + wd3 = 12288 + elif wd3 < -12288: + wd3 = -12288 + self._ap[2] = wd3 + + # UPPOL1 + self._sg[0] = (self._p[0]) >> 15 + self._sg[1] = (self._p[1]) >> 15 + wd1 = 192 if self._sg[0] == self._sg[1] else -192 + wd2 = (self._a[1] * 32640) >> 15 + + self._ap[1] = self.saturate(wd1 + wd2) + wd3 = self.saturate(15360 - self._ap[2]) + + if self._ap[1] > wd3: + self._ap[1] = wd3 + elif self._ap[1] < -wd3: + self._ap[1] = -wd3 + + # UPZERO + wd1 = 0 if d == 0 else 128 + self._sg[0] = d >> 15 + for i in range(1, 7): + self._sg[i] = (self._d[i]) >> 15 + wd2 = wd1 if self._sg[i] == self._sg[0] else -wd1 + wd3 = (self._b[i] * 32640) >> 15 + self._bp[i] = self.saturate(wd2 + wd3) + + # DELAYA + for i in range(6, 0, -1): + self._d[i] = self._d[i - 1] + self._b[i] = self._bp[i] + + for i in range(2, 0, -1): + self._r[i] = self._r[i - 1] + self._p[i] = self._p[i - 1] + self._a[i] = self._ap[i] + + # FILTEP + self._sp = 0 + for i in range(1, 3): + wd1 = self.saturate(self._r[i] + self._r[i]) + self._sp += (self._a[i] * wd1) >> 15 + self._sp = self.saturate(self._sp) + + # FILTEZ + self._sz = 0 + for i in range(6, 0, -1): + wd1 = self.saturate(self._d[i] + self._d[i]) + self._sz += (self._b[i] * wd1) >> 15 + self._sz = self.saturate(self._sz) + + # PREDIC + self.s = self.saturate(self._sp + self._sz) diff --git a/bumble/device.py b/bumble/device.py index 512bb1d..25fa099 100644 --- a/bumble/device.py +++ b/bumble/device.py @@ -50,6 +50,7 @@ from .hci import ( HCI_LE_EXTENDED_CREATE_CONNECTION_COMMAND, HCI_LE_RAND_COMMAND, HCI_LE_READ_PHY_COMMAND, + HCI_LE_SET_PHY_COMMAND, HCI_MITM_NOT_REQUIRED_GENERAL_BONDING_AUTHENTICATION_REQUIREMENTS, HCI_MITM_NOT_REQUIRED_NO_BONDING_AUTHENTICATION_REQUIREMENTS, HCI_MITM_REQUIRED_GENERAL_BONDING_AUTHENTICATION_REQUIREMENTS, @@ -94,10 +95,13 @@ from .hci import ( HCI_LE_Set_Scan_Enable_Command, HCI_LE_Set_Scan_Parameters_Command, HCI_LE_Set_Scan_Response_Data_Command, + HCI_PIN_Code_Request_Reply_Command, + HCI_PIN_Code_Request_Negative_Reply_Command, HCI_Read_BD_ADDR_Command, HCI_Read_RSSI_Command, HCI_Reject_Connection_Request_Command, HCI_Remote_Name_Request_Command, + HCI_Switch_Role_Command, HCI_Set_Connection_Encryption_Command, HCI_StatusError, HCI_User_Confirmation_Request_Negative_Reply_Command, @@ -310,6 +314,9 @@ class AdvertisementDataAccumulator: def update(self, report): advertisement = Advertisement.from_advertising_report(report) + if advertisement is None: + return None + result = None if advertisement.is_scan_response: @@ -615,7 +622,9 @@ class Connection(CompositeEventEmitter): assert self.transport == BT_BR_EDR_TRANSPORT self.handle = handle self.peer_resolvable_address = peer_resolvable_address - self.role = role + # Quirk: role might be known before complete + if self.role is None: + self.role = role self.parameters = parameters @property @@ -663,6 +672,9 @@ class Connection(CompositeEventEmitter): async def encrypt(self, enable: bool = True) -> None: return await self.device.encrypt(self, enable) + async def switch_role(self, role: int) -> None: + return await self.device.switch_role(self, role) + async def sustain(self, timeout=None): """Idles the current task waiting for a disconnect or timeout""" @@ -739,6 +751,7 @@ class DeviceConfiguration: self.le_enabled = True # LE host enable 2nd parameter self.le_simultaneous_enabled = True + self.classic_enabled = False self.classic_sc_enabled = True self.classic_ssp_enabled = True self.classic_accept_any = True @@ -768,6 +781,7 @@ class DeviceConfiguration: self.le_simultaneous_enabled = config.get( 'le_simultaneous_enabled', self.le_simultaneous_enabled ) + self.classic_enabled = config.get('classic_enabled', self.classic_enabled) self.classic_sc_enabled = config.get( 'classic_sc_enabled', self.classic_sc_enabled ) @@ -979,6 +993,7 @@ class Device(CompositeEventEmitter): self.keystore = KeyStore.create_for_device(config) self.irk = config.irk self.le_enabled = config.le_enabled + self.classic_enabled = config.classic_enabled self.le_simultaneous_enabled = config.le_simultaneous_enabled self.classic_ssp_enabled = config.classic_ssp_enabled self.classic_sc_enabled = config.classic_sc_enabled @@ -991,15 +1006,20 @@ class Device(CompositeEventEmitter): for characteristic in service.get("characteristics", []): descriptors = [] for descriptor in characteristic.get("descriptors", []): + # Leave this check until 5/25/2023 + if descriptor.get("permission", False): + raise Exception( + "Error parsing Device Config's GATT Services. The key 'permission' must be renamed to 'permissions'" + ) new_descriptor = Descriptor( attribute_type=descriptor["descriptor_type"], - permissions=descriptor["permission"], + permissions=descriptor["permissions"], ) descriptors.append(new_descriptor) new_characteristic = Characteristic( uuid=characteristic["uuid"], properties=characteristic["properties"], - permissions=int(characteristic["permissions"], 0), + permissions=characteristic["permissions"], descriptors=descriptors, ) characteristics.append(new_characteristic) @@ -1242,6 +1262,11 @@ class Device(CompositeEventEmitter): # Done self.powered_on = True + async def power_off(self) -> None: + if self.powered_on: + await self.host.flush() + self.powered_on = False + def supports_le_feature(self, feature): return self.host.supports_le_feature(feature) @@ -1666,7 +1691,7 @@ class Device(CompositeEventEmitter): ) ) if not phys: - raise ValueError('least one supported PHY needed') + raise ValueError('at least one supported PHY needed') phy_count = len(phys) initiating_phys = phy_list_to_bits(phys) @@ -1807,7 +1832,7 @@ class Device(CompositeEventEmitter): try: return await self.abort_on('flush', pending_connection) - except ConnectionError as error: + except core.ConnectionError as error: raise core.TimeoutError() from error finally: self.remove_listener('connection', on_connection) @@ -2041,21 +2066,31 @@ class Device(CompositeEventEmitter): async def set_connection_phy( self, connection, tx_phys=None, rx_phys=None, phy_options=None ): + if not self.host.supports_command(HCI_LE_SET_PHY_COMMAND): + logger.warning('ignoring request, command not supported') + return + all_phys_bits = (1 if tx_phys is None else 0) | ( (1 if rx_phys is None else 0) << 1 ) - return await self.send_command( + result = await self.send_command( HCI_LE_Set_PHY_Command( connection_handle=connection.handle, all_phys=all_phys_bits, tx_phys=phy_list_to_bits(tx_phys), rx_phys=phy_list_to_bits(rx_phys), phy_options=0 if phy_options is None else int(phy_options), - ), - check_result=True, + ) ) + if result.status != HCI_COMMAND_STATUS_PENDING: + logger.warning( + 'HCI_LE_Set_PHY_Command failed: ' + f'{HCI_Constant.error_name(result.status)}' + ) + raise HCI_StatusError(result) + async def set_default_phy(self, tx_phys=None, rx_phys=None): all_phys_bits = (1 if tx_phys is None else 0) | ( (1 if rx_phys is None else 0) << 1 @@ -2290,6 +2325,34 @@ class Device(CompositeEventEmitter): ) # [Classic only] + async def switch_role(self, connection: Connection, role: int): + pending_role_change = asyncio.get_running_loop().create_future() + + def on_role_change(new_role): + pending_role_change.set_result(new_role) + + def on_role_change_failure(error_code): + pending_role_change.set_exception(HCI_Error(error_code)) + + connection.on('role_change', on_role_change) + connection.on('role_change_failure', on_role_change_failure) + + try: + result = await self.send_command( + HCI_Switch_Role_Command(bd_addr=connection.peer_address, role=role) # type: ignore[call-arg] + ) + if result.status != HCI_COMMAND_STATUS_PENDING: + logger.warning( + 'HCI_Switch_Role_Command failed: ' + f'{HCI_Constant.error_name(result.status)}' + ) + raise HCI_StatusError(result) + await connection.abort_on('disconnection', pending_role_change) + finally: + connection.remove_listener('role_change', on_role_change) + connection.remove_listener('role_change_failure', on_role_change_failure) + + # [Classic only] async def request_remote_name(self, remote: Union[Address, Connection]) -> str: # Set up event handlers pending_name = asyncio.get_running_loop().create_future() @@ -2494,7 +2557,7 @@ class Device(CompositeEventEmitter): self.advertising = False # Notify listeners - error = ConnectionError( + error = core.ConnectionError( error_code, transport, peer_address, @@ -2567,7 +2630,7 @@ class Device(CompositeEventEmitter): @with_connection_from_handle def on_disconnection_failure(self, connection, error_code): logger.debug(f'*** Disconnection failed: {error_code}') - error = ConnectionError( + error = core.ConnectionError( error_code, connection.transport, connection.peer_address, @@ -2765,6 +2828,54 @@ class Device(CompositeEventEmitter): # [Classic only] @host_event_handler @with_connection_from_address + def on_pin_code_request(self, connection): + # classic legacy pairing + # Ask what the pairing config should be for this connection + pairing_config = self.pairing_config_factory(connection) + + can_input = pairing_config.delegate.io_capability in ( + smp.SMP_KEYBOARD_ONLY_IO_CAPABILITY, + smp.SMP_KEYBOARD_DISPLAY_IO_CAPABILITY, + ) + + # respond the pin code + if can_input: + + async def get_pin_code(): + pin_code = await connection.abort_on( + 'disconnection', pairing_config.delegate.get_string(16) + ) + + if pin_code is not None: + pin_code = bytes(pin_code, encoding='utf-8') + pin_code_len = len(pin_code) + assert 0 < pin_code_len <= 16, "pin_code should be 1-16 bytes" + await self.host.send_command( + HCI_PIN_Code_Request_Reply_Command( + bd_addr=connection.peer_address, + pin_code_length=pin_code_len, + pin_code=pin_code, + ) + ) + else: + logger.debug("delegate.get_string() returned None") + await self.host.send_command( + HCI_PIN_Code_Request_Negative_Reply_Command( + bd_addr=connection.peer_address + ) + ) + + asyncio.create_task(get_pin_code()) + else: + self.host.send_command_sync( + HCI_PIN_Code_Request_Negative_Reply_Command( + bd_addr=connection.peer_address + ) + ) + + # [Classic only] + @host_event_handler + @with_connection_from_address def on_authentication_user_passkey_notification(self, connection, passkey): # Ask what the pairing config should be for this connection pairing_config = self.pairing_config_factory(connection) @@ -2776,7 +2887,7 @@ class Device(CompositeEventEmitter): # [Classic only] @host_event_handler @try_with_connection_from_address - def on_remote_name(self, connection, address, remote_name): + def on_remote_name(self, connection: Connection, address, remote_name): # Try to decode the name try: remote_name = remote_name.decode('utf-8') @@ -2794,7 +2905,7 @@ class Device(CompositeEventEmitter): # [Classic only] @host_event_handler @try_with_connection_from_address - def on_remote_name_failure(self, connection, address, error): + def on_remote_name_failure(self, connection: Connection, address, error): if connection: connection.emit('remote_name_failure', error) self.emit('remote_name_failure', address, error) @@ -2905,6 +3016,21 @@ class Device(CompositeEventEmitter): ) connection.emit('connection_data_length_change') + # [Classic only] + @host_event_handler + @with_connection_from_address + def on_role_change(self, connection, new_role): + connection.role = new_role + connection.emit('role_change', new_role) + + # [Classic only] + @host_event_handler + @try_with_connection_from_address + def on_role_change_failure(self, connection, address, error): + if connection: + connection.emit('role_change_failure', error) + self.emit('role_change_failure', address, error) + @with_connection_from_handle def on_pairing_start(self, connection): connection.emit('pairing_start') diff --git a/bumble/gatt_client.py b/bumble/gatt_client.py index 2fd7573..25add18 100644 --- a/bumble/gatt_client.py +++ b/bumble/gatt_client.py @@ -23,9 +23,11 @@ # ----------------------------------------------------------------------------- # Imports # ----------------------------------------------------------------------------- +from __future__ import annotations import asyncio import logging import struct +from typing import List, Optional from pyee import EventEmitter @@ -50,6 +52,7 @@ from .att import ( ATT_Read_Request, ATT_Write_Command, ATT_Write_Request, + ATT_Error, ) from . import core from .core import UUID, InvalidStateError, ProtocolError @@ -59,6 +62,7 @@ from .gatt import ( GATT_PRIMARY_SERVICE_ATTRIBUTE_TYPE, GATT_REQUEST_TIMEOUT, GATT_SECONDARY_SERVICE_ATTRIBUTE_TYPE, + Service, Characteristic, ClientCharacteristicConfigurationBits, ) @@ -73,6 +77,8 @@ logger = logging.getLogger(__name__) # Proxies # ----------------------------------------------------------------------------- class AttributeProxy(EventEmitter): + client: Client + def __init__(self, client, handle, end_group_handle, attribute_type): EventEmitter.__init__(self) self.client = client @@ -101,6 +107,9 @@ class AttributeProxy(EventEmitter): class ServiceProxy(AttributeProxy): + uuid: UUID + characteristics: List[CharacteristicProxy] + @staticmethod def from_client(service_class, client, service_uuid): # The service and its characteristics are considered to have already been @@ -130,6 +139,8 @@ class ServiceProxy(AttributeProxy): class CharacteristicProxy(AttributeProxy): + descriptors: List[DescriptorProxy] + def __init__(self, client, handle, end_group_handle, uuid, properties): super().__init__(client, handle, end_group_handle, uuid) self.uuid = uuid @@ -201,6 +212,8 @@ class ProfileServiceProxy: # GATT Client # ----------------------------------------------------------------------------- class Client: + services: List[ServiceProxy] + def __init__(self, connection): self.connection = connection self.mtu_exchange_done = False @@ -306,7 +319,7 @@ class Client: if not already_known: self.services.append(service) - async def discover_services(self, uuids=None): + async def discover_services(self, uuids=None) -> List[ServiceProxy]: ''' See Vol 3, Part G - 4.4.1 Discover All Primary Services ''' @@ -332,8 +345,10 @@ class Client: '!!! unexpected error while discovering services: ' f'{HCI_Constant.error_name(response.error_code)}' ) - # TODO raise appropriate exception - return + raise ATT_Error( + error_code=response.error_code, + message='Unexpected error while discovering services', + ) break for ( @@ -349,7 +364,7 @@ class Client: logger.warning( f'bogus handle values: {attribute_handle} {end_group_handle}' ) - return + return [] # Create a service proxy for this service service = ServiceProxy( @@ -452,7 +467,9 @@ class Client: # TODO return [] - async def discover_characteristics(self, uuids, service): + async def discover_characteristics( + self, uuids, service: Optional[ServiceProxy] + ) -> List[CharacteristicProxy]: ''' See Vol 3, Part G - 4.6.1 Discover All Characteristics of a Service and 4.6.2 Discover Characteristics by UUID @@ -465,12 +482,12 @@ class Client: services = [service] if service else self.services # Perform characteristic discovery for each service - discovered_characteristics = [] + discovered_characteristics: List[CharacteristicProxy] = [] for service in services: starting_handle = service.handle ending_handle = service.end_group_handle - characteristics = [] + characteristics: List[CharacteristicProxy] = [] while starting_handle <= ending_handle: response = await self.send_request( ATT_Read_By_Type_Request( @@ -491,8 +508,10 @@ class Client: '!!! unexpected error while discovering characteristics: ' f'{HCI_Constant.error_name(response.error_code)}' ) - # TODO raise appropriate exception - return + raise ATT_Error( + error_code=response.error_code, + message='Unexpected error while discovering characteristics', + ) break # Stop if for some reason the list was empty @@ -535,8 +554,11 @@ class Client: return discovered_characteristics async def discover_descriptors( - self, characteristic=None, start_handle=None, end_handle=None - ): + self, + characteristic: Optional[CharacteristicProxy] = None, + start_handle=None, + end_handle=None, + ) -> List[DescriptorProxy]: ''' See Vol 3, Part G - 4.7.1 Discover All Characteristic Descriptors ''' @@ -549,7 +571,7 @@ class Client: else: return [] - descriptors = [] + descriptors: List[DescriptorProxy] = [] while starting_handle <= ending_handle: response = await self.send_request( ATT_Find_Information_Request( diff --git a/bumble/gatt_server.py b/bumble/gatt_server.py index d82f273..3a5953a 100644 --- a/bumble/gatt_server.py +++ b/bumble/gatt_server.py @@ -691,7 +691,7 @@ class Server(EventEmitter): length=entry_size, attribute_data_list=b''.join(attribute_data_list) ) else: - logging.warning(f"not found {request}") + logging.debug(f"not found {request}") self.send_response(connection, response) diff --git a/bumble/hci.py b/bumble/hci.py index bcab7a7..9b5793d 100644 --- a/bumble/hci.py +++ b/bumble/hci.py @@ -1491,7 +1491,7 @@ class HCI_Object: elif field_type == -2: # 16-bit signed field_value = struct.unpack_from('<h', data, offset)[0] - offset += 1 + offset += 2 elif field_type == 3: # 24-bit unsigned padded = data[offset : offset + 3] + bytes([0]) @@ -2099,6 +2099,24 @@ class HCI_Link_Key_Request_Negative_Reply_Command(HCI_Command): # ----------------------------------------------------------------------------- @HCI_Command.command( + fields=[ + ('bd_addr', Address.parse_address), + ('pin_code_length', 1), + ('pin_code', 16), + ], + return_parameters_fields=[ + ('status', STATUS_SPEC), + ('bd_addr', Address.parse_address), + ], +) +class HCI_PIN_Code_Request_Reply_Command(HCI_Command): + ''' + See Bluetooth spec @ 7.1.12 PIN Code Request Reply Command + ''' + + +# ----------------------------------------------------------------------------- +@HCI_Command.command( fields=[('bd_addr', Address.parse_address)], return_parameters_fields=[ ('status', STATUS_SPEC), diff --git a/bumble/host.py b/bumble/host.py index 9f667a1..9e05c8c 100644 --- a/bumble/host.py +++ b/bumble/host.py @@ -24,7 +24,10 @@ from bumble.colors import color from bumble.l2cap import L2CAP_PDU from bumble.snoop import Snooper +from typing import Optional + from .hci import ( + Address, HCI_ACL_DATA_PACKET, HCI_COMMAND_COMPLETE_EVENT, HCI_COMMAND_PACKET, @@ -53,7 +56,6 @@ from .hci import ( HCI_LE_Write_Suggested_Default_Data_Length_Command, HCI_Link_Key_Request_Negative_Reply_Command, HCI_Link_Key_Request_Reply_Command, - HCI_PIN_Code_Request_Negative_Reply_Command, HCI_Packet, HCI_Read_Buffer_Size_Command, HCI_Read_Local_Supported_Commands_Command, @@ -142,6 +144,24 @@ class Host(AbortableEventEmitter): if controller_sink: self.set_packet_sink(controller_sink) + def find_connection_by_bd_addr( + self, + bd_addr: Address, + transport: Optional[int] = None, + check_address_type: bool = False, + ) -> Optional[Connection]: + for connection in self.connections.values(): + if connection.peer_address.to_bytes() == bd_addr.to_bytes(): + if ( + check_address_type + and connection.peer_address.address_type != bd_addr.address_type + ): + continue + if transport is None or connection.transport == transport: + return connection + + return None + async def flush(self) -> None: # Make sure no command is pending await self.command_semaphore.acquire() @@ -719,12 +739,17 @@ class Host(AbortableEventEmitter): f'role change for {event.bd_addr}: ' f'{HCI_Constant.role_name(event.new_role)}' ) - # TODO: lookup the connection and update the role + if connection := self.find_connection_by_bd_addr( + event.bd_addr, BT_BR_EDR_TRANSPORT + ): + connection.role = event.new_role + self.emit('role_change', event.bd_addr, event.new_role) else: logger.debug( f'role change for {event.bd_addr} failed: ' f'{HCI_Constant.error_name(event.status)}' ) + self.emit('role_change_failure', event.bd_addr, event.status) def on_hci_le_data_length_change_event(self, event): self.emit( @@ -794,11 +819,7 @@ class Host(AbortableEventEmitter): ) def on_hci_pin_code_request_event(self, event): - # For now, just refuse all requests - # TODO: delegate the decision - self.send_command_sync( - HCI_PIN_Code_Request_Negative_Reply_Command(bd_addr=event.bd_addr) - ) + self.emit('pin_code_request', event.bd_addr) def on_hci_link_key_request_event(self, event): async def send_link_key(): diff --git a/bumble/l2cap.py b/bumble/l2cap.py index 2610adc..ef7fdab 100644 --- a/bumble/l2cap.py +++ b/bumble/l2cap.py @@ -796,6 +796,11 @@ class Channel(EventEmitter): self.disconnection_result = asyncio.get_running_loop().create_future() return await self.disconnection_result + def abort(self): + if self.state == self.OPEN: + self.change_state(self.CLOSED) + self.emit('close') + def send_configure_request(self): options = L2CAP_Control_Frame.encode_configuration_options( [ @@ -1105,6 +1110,10 @@ class LeConnectionOrientedChannel(EventEmitter): self.disconnection_result = asyncio.get_running_loop().create_future() return await self.disconnection_result + def abort(self): + if self.state == self.CONNECTED: + self.change_state(self.DISCONNECTED) + def on_pdu(self, pdu): if self.sink is None: logger.warning('received pdu without a sink') @@ -1492,8 +1501,12 @@ class ChannelManager: def on_disconnection(self, connection_handle, _reason): logger.debug(f'disconnection from {connection_handle}, cleaning up channels') if connection_handle in self.channels: + for _, channel in self.channels[connection_handle].items(): + channel.abort() del self.channels[connection_handle] if connection_handle in self.le_coc_channels: + for _, channel in self.le_coc_channels[connection_handle].items(): + channel.abort() del self.le_coc_channels[connection_handle] if connection_handle in self.identifiers: del self.identifiers[connection_handle] diff --git a/bumble/link.py b/bumble/link.py index 82dd9db..85ad96e 100644 --- a/bumble/link.py +++ b/bumble/link.py @@ -19,12 +19,15 @@ import logging import asyncio from functools import partial +from bumble.core import BT_PERIPHERAL_ROLE, BT_BR_EDR_TRANSPORT, BT_LE_TRANSPORT from bumble.colors import color from bumble.hci import ( Address, HCI_SUCCESS, HCI_CONNECTION_ACCEPT_TIMEOUT_ERROR, HCI_CONNECTION_TIMEOUT_ERROR, + HCI_PAGE_TIMEOUT_ERROR, + HCI_Connection_Complete_Event, ) # ----------------------------------------------------------------------------- @@ -57,6 +60,11 @@ class LocalLink: def __init__(self): self.controllers = set() self.pending_connection = None + self.pending_classic_connection = None + + ############################################################ + # Common utils + ############################################################ def add_controller(self, controller): logger.debug(f'new controller: {controller}') @@ -71,22 +79,39 @@ class LocalLink: return controller return None - def on_address_changed(self, controller): - pass + def find_classic_controller(self, address): + for controller in self.controllers: + if controller.public_address == address: + return controller + return None def get_pending_connection(self): return self.pending_connection + ############################################################ + # LE handlers + ############################################################ + + def on_address_changed(self, controller): + pass + def send_advertising_data(self, sender_address, data): # Send the advertising data to all controllers, except the sender for controller in self.controllers: if controller.random_address != sender_address: controller.on_link_advertising_data(sender_address, data) - def send_acl_data(self, sender_address, destination_address, data): + def send_acl_data(self, sender_controller, destination_address, transport, data): # Send the data to the first controller with a matching address - if controller := self.find_controller(destination_address): - controller.on_link_acl_data(sender_address, data) + if transport == BT_LE_TRANSPORT: + destination_controller = self.find_controller(destination_address) + source_address = sender_controller.random_address + elif transport == BT_BR_EDR_TRANSPORT: + destination_controller = self.find_classic_controller(destination_address) + source_address = sender_controller.public_address + + if destination_controller is not None: + destination_controller.on_link_acl_data(source_address, transport, data) def on_connection_complete(self): # Check that we expect this call @@ -163,6 +188,89 @@ class LocalLink: if peripheral_controller := self.find_controller(peripheral_address): peripheral_controller.on_link_encrypted(central_address, rand, ediv, ltk) + ############################################################ + # Classic handlers + ############################################################ + + def classic_connect(self, initiator_controller, responder_address): + logger.debug( + f'[Classic] {initiator_controller.public_address} connects to {responder_address}' + ) + responder_controller = self.find_classic_controller(responder_address) + if responder_controller is None: + initiator_controller.on_classic_connection_complete( + responder_address, HCI_PAGE_TIMEOUT_ERROR + ) + return + self.pending_classic_connection = (initiator_controller, responder_controller) + + responder_controller.on_classic_connection_request( + initiator_controller.public_address, + HCI_Connection_Complete_Event.ACL_LINK_TYPE, + ) + + def classic_accept_connection( + self, responder_controller, initiator_address, responder_role + ): + logger.debug( + f'[Classic] {responder_controller.public_address} accepts to connect {initiator_address}' + ) + initiator_controller = self.find_classic_controller(initiator_address) + if initiator_controller is None: + responder_controller.on_classic_connection_complete( + responder_controller.public_address, HCI_PAGE_TIMEOUT_ERROR + ) + return + + async def task(): + if responder_role != BT_PERIPHERAL_ROLE: + initiator_controller.on_classic_role_change( + responder_controller.public_address, int(not (responder_role)) + ) + initiator_controller.on_classic_connection_complete( + responder_controller.public_address, HCI_SUCCESS + ) + + asyncio.create_task(task()) + responder_controller.on_classic_role_change( + initiator_controller.public_address, responder_role + ) + responder_controller.on_classic_connection_complete( + initiator_controller.public_address, HCI_SUCCESS + ) + self.pending_classic_connection = None + + def classic_disconnect(self, initiator_controller, responder_address, reason): + logger.debug( + f'[Classic] {initiator_controller.public_address} disconnects {responder_address}' + ) + responder_controller = self.find_classic_controller(responder_address) + + async def task(): + initiator_controller.on_classic_disconnected(responder_address, reason) + + asyncio.create_task(task()) + responder_controller.on_classic_disconnected( + initiator_controller.public_address, reason + ) + + def classic_switch_role( + self, initiator_controller, responder_address, initiator_new_role + ): + responder_controller = self.find_classic_controller(responder_address) + if responder_controller is None: + return + + async def task(): + initiator_controller.on_classic_role_change( + responder_address, initiator_new_role + ) + + asyncio.create_task(task()) + responder_controller.on_classic_role_change( + initiator_controller.public_address, int(not (initiator_new_role)) + ) + # ----------------------------------------------------------------------------- class RemoteLink: @@ -200,6 +308,9 @@ class RemoteLink: def get_pending_connection(self): return self.pending_connection + def get_pending_classic_connection(self): + return self.pending_classic_connection + async def wait_until_connected(self): await self.websocket @@ -366,7 +477,8 @@ class RemoteLink: async def send_acl_data_to_relay(self, peer_address, data): await self.send_targeted_message(peer_address, f'acl:{data.hex()}') - def send_acl_data(self, _, peer_address, data): + def send_acl_data(self, _, peer_address, _transport, data): + # TODO: handle different transport self.execute(partial(self.send_acl_data_to_relay, peer_address, data)) async def send_connection_request_to_relay(self, peer_address): diff --git a/bumble/profiles/asha_service.py b/bumble/profiles/asha_service.py index fabaa28..1b1e93a 100644 --- a/bumble/profiles/asha_service.py +++ b/bumble/profiles/asha_service.py @@ -20,6 +20,7 @@ import struct import logging from typing import List from ..core import AdvertisingData +from ..device import Device, Connection from ..gatt import ( GATT_ASHA_SERVICE, GATT_ASHA_READ_ONLY_PROPERTIES_CHARACTERISTIC, @@ -31,7 +32,7 @@ from ..gatt import ( Characteristic, CharacteristicValue, ) -from ..device import Device +from ..utils import AsyncRunner # ----------------------------------------------------------------------------- # Logging @@ -55,16 +56,16 @@ class AshaService(TemplateService): self.hisyncid = hisyncid self.capability = capability # Device Capabilities [Left, Monaural] self.device = device - self.emitted_data_name = 'ASHA_data_' + str(self.capability) self.audio_out_data = b'' self.psm = psm # a non-zero psm is mainly for testing purpose # Handler for volume control - def on_volume_write(_connection, value): + def on_volume_write(connection, value): logger.info(f'--- VOLUME Write:{value[0]}') + self.emit('volume', connection, value[0]) # Handler for audio control commands - def on_audio_control_point_write(_connection, value): + def on_audio_control_point_write(connection: Connection, value): logger.info(f'--- AUDIO CONTROL POINT Write:{value.hex()}') opcode = value[0] if opcode == AshaService.OPCODE_START: @@ -76,14 +77,29 @@ class AshaService(TemplateService): f'volume={value[3]}, ' f'otherstate={value[4]}' ) + self.emit( + 'start', + connection, + { + 'codec': value[1], + 'audiotype': value[2], + 'volume': value[3], + 'otherstate': value[4], + }, + ) elif opcode == AshaService.OPCODE_STOP: logger.info('### STOP') + self.emit('stop', connection) elif opcode == AshaService.OPCODE_STATUS: logger.info(f'### STATUS: connected={value[1]}') - # TODO Respond with a status - # asyncio.create_task(device.notify_subscribers(audio_status_characteristic, - # force=True)) + # OPCODE_STATUS does not need audio status point update + if opcode != AshaService.OPCODE_STATUS: + AsyncRunner.spawn( + device.notify_subscribers( + self.audio_status_characteristic, force=True + ) + ) self.read_only_properties_characteristic = Characteristic( GATT_ASHA_READ_ONLY_PROPERTIES_CHARACTERISTIC, @@ -126,7 +142,7 @@ class AshaService(TemplateService): def on_data(data): logging.debug(f'<<< data received:{data}') - self.emit(self.emitted_data_name, data) + self.emit('data', channel.connection, data) self.audio_out_data += data channel.sink = on_data diff --git a/bumble/rfcomm.py b/bumble/rfcomm.py index a6b02ba..dbb2795 100644 --- a/bumble/rfcomm.py +++ b/bumble/rfcomm.py @@ -852,17 +852,27 @@ class Server(EventEmitter): # Register ourselves with the L2CAP channel manager device.register_l2cap_server(RFCOMM_PSM, self.on_connection) - def listen(self, acceptor): - # Find a free channel number - for channel in range( - RFCOMM_DYNAMIC_CHANNEL_NUMBER_START, RFCOMM_DYNAMIC_CHANNEL_NUMBER_END + 1 - ): - if channel not in self.acceptors: - self.acceptors[channel] = acceptor - return channel - - # All channels used... - return 0 + def listen(self, acceptor, channel=0): + if channel: + if channel in self.acceptors: + # Busy + return 0 + else: + # Find a free channel number + for candidate in range( + RFCOMM_DYNAMIC_CHANNEL_NUMBER_START, + RFCOMM_DYNAMIC_CHANNEL_NUMBER_END + 1, + ): + if candidate not in self.acceptors: + channel = candidate + break + + if channel == 0: + # All channels used... + return 0 + + self.acceptors[channel] = acceptor + return channel def on_connection(self, l2cap_channel): logger.debug(f'+++ new L2CAP connection: {l2cap_channel}') diff --git a/bumble/smp.py b/bumble/smp.py index 9a72cb0..1714743 100644 --- a/bumble/smp.py +++ b/bumble/smp.py @@ -522,9 +522,19 @@ class PairingDelegate: async def compare_numbers(self, number: int, digits: int) -> bool: return True - async def get_number(self) -> int: + async def get_number(self) -> Optional[int]: + ''' + Returns an optional number as an answer to a passkey request. + Returning `None` will result in a negative reply. + ''' return 0 + async def get_string(self, max_length) -> Optional[str]: + ''' + Returns a string whose utf-8 encoding is up to max_length bytes. + ''' + return None + # pylint: disable-next=unused-argument async def display_number(self, number: int, digits: int) -> None: pass diff --git a/bumble/utils.py b/bumble/utils.py index 474fff2..8a55684 100644 --- a/bumble/utils.py +++ b/bumble/utils.py @@ -20,7 +20,7 @@ import logging import traceback import collections import sys -from typing import Awaitable, TypeVar +from typing import Awaitable, Set, TypeVar from functools import wraps from pyee import EventEmitter @@ -157,6 +157,9 @@ class AsyncRunner: # Shared default queue default_queue = WorkQueue() + # Shared set of running tasks + running_tasks: Set[Awaitable] = set() + @staticmethod def run_in_task(queue=None): """ @@ -187,6 +190,19 @@ class AsyncRunner: return decorator + @staticmethod + def spawn(coroutine): + """ + Spawn a task to run a coroutine in a "fire and forget" mode. + + Using this method instead of just calling `asyncio.create_task(coroutine)` + is necessary when you don't keep a reference to the task, because `asyncio` + only keeps weak references to alive tasks. + """ + task = asyncio.create_task(coroutine) + AsyncRunner.running_tasks.add(task) + task.add_done_callback(AsyncRunner.running_tasks.remove) + # ----------------------------------------------------------------------------- class FlowControlAsyncPipe: diff --git a/docs/mkdocs/mkdocs.yml b/docs/mkdocs/mkdocs.yml index 8cabd0c..0ddc982 100644 --- a/docs/mkdocs/mkdocs.yml +++ b/docs/mkdocs/mkdocs.yml @@ -43,7 +43,7 @@ nav: - Apps & Tools: - Overview: apps_and_tools/index.md - Console: apps_and_tools/console.md - - Link Relay: apps_and_tools/link_relay.md + - Bench: apps_and_tools/bench.md - HCI Bridge: apps_and_tools/hci_bridge.md - Golden Gate Bridge: apps_and_tools/gg_bridge.md - Show: apps_and_tools/show.md @@ -51,6 +51,7 @@ nav: - Pair: apps_and_tools/pair.md - Unbond: apps_and_tools/unbond.md - USB Probe: apps_and_tools/usb_probe.md + - Link Relay: apps_and_tools/link_relay.md - Hardware: - Overview: hardware/index.md - Platforms: @@ -62,7 +63,7 @@ nav: - Examples: - Overview: examples/index.md -copyright: Copyright 2021-2022 Google LLC +copyright: Copyright 2021-2023 Google LLC theme: name: 'material' diff --git a/docs/mkdocs/src/apps_and_tools/bench.md b/docs/mkdocs/src/apps_and_tools/bench.md new file mode 100644 index 0000000..db785d6 --- /dev/null +++ b/docs/mkdocs/src/apps_and_tools/bench.md @@ -0,0 +1,158 @@ +BENCH TOOL +========== + +The "bench" tool implements a number of different ways of measuring the +throughput and/or latency between two devices. + +# General Usage + +``` +Usage: bench.py [OPTIONS] COMMAND [ARGS]... + +Options: + --device-config FILENAME Device configuration file + --role [sender|receiver|ping|pong] + --mode [gatt-client|gatt-server|l2cap-client|l2cap-server|rfcomm-client|rfcomm-server] + --att-mtu MTU GATT MTU (gatt-client mode) [23<=x<=517] + -s, --packet-size SIZE Packet size (server role) [8<=x<=4096] + -c, --packet-count COUNT Packet count (server role) + -sd, --start-delay SECONDS Start delay (server role) + --help Show this message and exit. + +Commands: + central Run as a central (initiates the connection) + peripheral Run as a peripheral (waits for a connection) +``` + +## Options for the ``central`` Command +``` +Usage: bumble-bench central [OPTIONS] TRANSPORT + + Run as a central (initiates the connection) + +Options: + --peripheral ADDRESS_OR_NAME Address or name to connect to + --connection-interval, --ci CONNECTION_INTERVAL + Connection interval (in ms) + --phy [1m|2m|coded] PHY to use + --help Show this message and exit. +``` + + +To test once device against another, one of the two devices must be running +the ``peripheral`` command and the other the ``central`` command. The device +running the ``peripheral`` command will accept connections from the device +running the ``central`` command. +When using Bluetooth LE (all modes except for ``rfcomm-server`` and ``rfcomm-client``utils), +the default addresses configured in the tool should be sufficient. But when using +Bluetooth Classic, the address of the Peripheral must be specified on the Central +using the ``--peripheral`` option. The address will be printed by the Peripheral when +it starts. + +Independently of whether the device is the Central or Peripheral, each device selects a +``mode`` and and ``role`` to run as. The ``mode`` and ``role`` of the Central and Peripheral +must be compatible. + +Device 1 mode | Device 2 mode +------------------|------------------ +``gatt-client`` | ``gatt-server`` +``l2cap-client`` | ``l2cap-server`` +``rfcomm-client`` | ``rfcomm-server`` + +Device 1 role | Device 2 role +--------------|-------------- +``sender`` | ``receiver`` +``ping`` | ``pong`` + + +# Examples + +In the following examples, we have two USB Bluetooth controllers, one on `usb:0` and +the other on `usb:1`, and two consoles/terminals. We will run a command in each. + +!!! example "GATT Throughput" + Using the default mode and role for the Central and Peripheral. + + In the first console/terminal: + ``` + $ bumble-bench peripheral usb:0 + ``` + + In the second console/terminal: + ``` + $ bumble-bench central usb:1 + ``` + + In this default configuration, the Central runs a Sender, as a GATT client, + connecting to the Peripheral running a Receiver, as a GATT server. + +!!! example "L2CAP Throughput" + In the first console/terminal: + ``` + $ bumble-bench --mode l2cap-server peripheral usb:0 + ``` + + In the second console/terminal: + ``` + $ bumble-bench --mode l2cap-client central usb:1 + ``` + +!!! example "RFComm Throughput" + In the first console/terminal: + ``` + $ bumble-bench --mode rfcomm-server peripheral usb:0 + ``` + + NOTE: the BT address of the Peripheral will be printed out, use it with the + ``--peripheral`` option for the Central. + + In this example, we use a larger packet size and packet count than the default. + + In the second console/terminal: + ``` + $ bumble-bench --mode rfcomm-client --packet-size 2000 --packet-count 100 central --peripheral 00:16:A4:5A:40:F2 usb:1 + ``` + +!!! example "Ping/Pong Latency" + In the first console/terminal: + ``` + $ bumble-bench --role pong peripheral usb:0 + ``` + + In the second console/terminal: + ``` + $ bumble-bench --role ping central usb:1 + ``` + +!!! example "Reversed modes with GATT and custom connection interval" + In the first console/terminal: + ``` + $ bumble-bench --mode gatt-client peripheral usb:0 + ``` + + In the second console/terminal: + ``` + $ bumble-bench --mode gatt-server central --ci 10 usb:1 + ``` + +!!! example "Reversed modes with L2CAP and custom PHY" + In the first console/terminal: + ``` + $ bumble-bench --mode l2cap-client peripheral usb:0 + ``` + + In the second console/terminal: + ``` + $ bumble-bench --mode l2cap-server central --phy 2m usb:1 + ``` + +!!! example "Reversed roles with L2CAP" + In the first console/terminal: + ``` + $ bumble-bench --mode l2cap-client --role sender peripheral usb:0 + ``` + + In the second console/terminal: + ``` + $ bumble-bench --mode l2cap-server --role receiver central usb:1 + ``` diff --git a/docs/mkdocs/src/apps_and_tools/index.md b/docs/mkdocs/src/apps_and_tools/index.md index f588738..fe7af56 100644 --- a/docs/mkdocs/src/apps_and_tools/index.md +++ b/docs/mkdocs/src/apps_and_tools/index.md @@ -5,6 +5,7 @@ Included in the project are a few apps and tools, built on top of the core libra These include: * [Console](console.md) - an interactive text-based console + * [Bench](bench.md) - Speed and Latency benchmarking between two devices (LE and Classic) * [Pair](pair.md) - Pair/bond two devices (LE and Classic) * [Unbond](unbond.md) - Remove a previously established bond * [HCI Bridge](hci_bridge.md) - a HCI transport bridge to connect two HCI transports and filter/snoop the HCI packets diff --git a/docs/mkdocs/src/index.md b/docs/mkdocs/src/index.md index fb1e155..c81f7ff 100644 --- a/docs/mkdocs/src/index.md +++ b/docs/mkdocs/src/index.md @@ -8,8 +8,7 @@ The project initially only supported BLE (Bluetooth Low Energy), but support for eventually added. Support for BLE is therefore currently somewhat more advanced than for Classic. !!! warning - This project is still very much experimental and in an alpha state where a lot of things are still missing or broken, and what's there changes frequently. - Also, there are still a few hardcoded values/parameters in some of the examples and apps which need to be changed (those will eventually be command line arguments, as appropriate) + This project is still in an early state of development where some things are still missing or broken, and what's implemented may change and evolve frequently. Overview -------- @@ -57,6 +57,7 @@ console_scripts = 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-bench = bumble.apps.bench:main [options.package_data] * = py.typed, *.pyi diff --git a/tests/a2dp_test.py b/tests/a2dp_test.py index e499531..92f7915 100644 --- a/tests/a2dp_test.py +++ b/tests/a2dp_test.py @@ -21,6 +21,7 @@ import os import pytest from bumble.controller import Controller +from bumble.core import BT_BR_EDR_TRANSPORT from bumble.link import LocalLink from bumble.device import Device from bumble.host import Host @@ -58,18 +59,19 @@ class TwoDevices: def __init__(self): self.connections = [None, None] + addresses = ['F0:F1:F2:F3:F4:F5', 'F5:F4:F3:F2:F1:F0'] self.link = LocalLink() self.controllers = [ - Controller('C1', link=self.link), - Controller('C2', link=self.link), + Controller('C1', link=self.link, public_address=addresses[0]), + Controller('C2', link=self.link, public_address=addresses[1]), ] self.devices = [ Device( - address='F0:F1:F2:F3:F4:F5', + address=addresses[0], host=Host(self.controllers[0], AsyncPipeSink(self.controllers[0])), ), Device( - address='F5:F4:F3:F2:F1:F0', + address=addresses[1], host=Host(self.controllers[1], AsyncPipeSink(self.controllers[1])), ), ] @@ -79,6 +81,9 @@ class TwoDevices: def on_connection(self, which, connection): self.connections[which] = connection + def on_paired(self, which, keys): + self.paired[which] = keys + # ----------------------------------------------------------------------------- @pytest.mark.asyncio @@ -94,12 +99,21 @@ async def test_self_connection(): 'connection', lambda connection: two_devices.on_connection(1, connection) ) + # Enable Classic connections + two_devices.devices[0].classic_enabled = True + two_devices.devices[1].classic_enabled = True + # Start await two_devices.devices[0].power_on() await two_devices.devices[1].power_on() # Connect the two devices - await two_devices.devices[0].connect(two_devices.devices[1].random_address) + await asyncio.gather( + two_devices.devices[0].connect( + two_devices.devices[1].public_address, transport=BT_BR_EDR_TRANSPORT + ), + two_devices.devices[1].accept(two_devices.devices[0].public_address), + ) # Check the post conditions assert two_devices.connections[0] is not None @@ -152,6 +166,9 @@ def sink_codec_capabilities(): @pytest.mark.asyncio async def test_source_sink_1(): two_devices = TwoDevices() + # Enable Classic connections + two_devices.devices[0].classic_enabled = True + two_devices.devices[1].classic_enabled = True await two_devices.devices[0].power_on() await two_devices.devices[1].power_on() @@ -171,9 +188,16 @@ async def test_source_sink_1(): listener = Listener(Listener.create_registrar(two_devices.devices[1])) listener.on('connection', on_avdtp_connection) - connection = await two_devices.devices[0].connect( - two_devices.devices[1].random_address - ) + async def make_connection(): + connections = await asyncio.gather( + two_devices.devices[0].connect( + two_devices.devices[1].public_address, BT_BR_EDR_TRANSPORT + ), + two_devices.devices[1].accept(two_devices.devices[0].public_address), + ) + return connections[0] + + connection = await make_connection() client = await Protocol.connect(connection) endpoints = await client.discover_remote_endpoints() assert len(endpoints) == 1 diff --git a/tests/decoder_test.py b/tests/decoder_test.py new file mode 100644 index 0000000..3be2c63 --- /dev/null +++ b/tests/decoder_test.py @@ -0,0 +1,47 @@ +# Copyright 2023 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +# ----------------------------------------------------------------------------- +# Imports +# ----------------------------------------------------------------------------- +import hashlib +import os +from bumble.decoder import G722Decoder + + +# ----------------------------------------------------------------------------- +def test_decode_file(): + decoder = G722Decoder() + output_bytes = bytearray() + + with open( + os.path.join(os.path.dirname(__file__), 'g722_sample.g722'), 'rb' + ) as file: + file_content = file.read() + frame_length = 80 + data_length = int(len(file_content) / frame_length) + + for i in range(0, data_length): + decoded_data = decoder.decode_frame( + file_content[i * frame_length : i * frame_length + frame_length] + ) + output_bytes.extend(decoded_data) + + result = hashlib.md5(output_bytes).hexdigest() + assert result == 'b58e0cdd012d12f5633fc796c3b0fbd4' + + +# ----------------------------------------------------------------------------- +if __name__ == '__main__': + test_decode_file() diff --git a/tests/g722_sample.g722 b/tests/g722_sample.g722 new file mode 100644 index 0000000..432cd6c --- /dev/null +++ b/tests/g722_sample.g722 @@ -0,0 +1 @@ + , /1"9)1 ,/Y_~|~x|Y^VZX|_Wx:Tpz۳{}^~zo}n}n?x]oZ_:08Λ\upN߽VzY\w]qS{:Y\zYޗx^~~~s.jw]xyuoxqz0{6ny:{X^YkTx^~V_ڙޮO0|[0\2sW[vuuxp;l|pܻZ>>XՎ_YUUR>ޖpRxZ_tu2.Ymr~^_^uPqox=mzx}Z}Zk8Zivvuu_sX~ۓUyvw{s_xrpx{ty?YjYXtL^Ԑss[.n5ם־
zQߚT|syqmust^w;s[zX{RmW_z\rUtp^s\Y]~~su{/Yo؟w_Utzq|v^8u5;UZ]~TX5[kt{{vk,otp]<U{]<6}w^_Zq?o[qP^]͓9]X~r4|_pqun_x{:1^Q]^~Q[U|_]\UxW;V_ܖvv;]1o4zqr\+Wst/uٷWsy]5[zW~Tp۲_<w]U|>^s}WU}Yx[tzߺ_oo]4v_vv|}+s?{zwVz}Q{t}sr}uTxu[ڻ[{\S=s9whtw2Wrytqts\\yMPݟӜ[_2|[][s_ر9]o[q^.<sororr3V-|>|yyx5zYw˵[vϞֹsTz4row6tyso}ܝ>ySwW/][zY?TVZsYwx\~_ې}ܘ{Xg\_xq/][n~~x]rm\jpmzl}ZW9]^{ܞ{Qtל}vqwY6/q[.}[PҲ__83ۼ~1_[[voxn?NZМT֗Swww}^tyw|Xxi?lsu<nzq^n}typz}[2?oyW}ߕ~WxUXX]|__X[؟Vt^8~;__VV2tStr,l-Wtsr_x<=/ڱSrzxl}RQ_wuT؝TVVqW}h\kjҾmr,r[t8rWzwzXݵluwwQ^ә]wז^5Yr~ixS_X|q}YrQ+;{QԺ8UZsY^6^kvr.ܼZtZx,~_w~m>_8w՜\RVWUZZ]u]Y\ОYZVu?\s9{ϵ]ow~t|zjnzݞ2qooW__(w_snzڿX}ݺy]mُ
oys|rtR_y۱ux_ySzui_z|ږ6u]yr}jYW]y}].6UWp'wp{uZ}X_ҟ[P[
x^R\ؗ<[~XUݴQ6NpwYo[t7qܝ{rpn^غozT^rU-jwu]oXY>\pq\ޟ|2x~o{}zݳk{|k2\ZrֶV{vsvݾY_XVWWrSS\UיҰ]\Smw7_T~}U~}rrzstu?plpv|=nx2rk|p3n^ry|s:z7u]6xVۗUy^X؛XwSٱz|6_WTW9ݸiu-sUwX>vZ_Xؒ_T/zzsTܼYRݖ^_ޙ\U.].Yy[--kx1q}xwwprY?[7}T{Z:oTRY}]Wx.^{Olt>|{yֿvxlWwoXlۺwY}uxQs6mXt}}STY8}SRRXUVzP\>xZ|T}R[xZZX]0똱5yyr?x79[zުnn\^s}w6Y_WZ]^8ywQsVْq[=TSԘu{\UМYoYY^Xs|]TTy|\q]r/]3qlݷuw_:xt]\{r
zڐwZ~[R1TtXR־[7SmuYuuvv43vuֶ_rܼpqݭjrӽZWxXX]ZJ^}S[~T|XxtZX\wq_3^xZ]Z[xu[T+]U}XZyui\W~Z*X~\^7:]XXv}].^ZL_qm:qpアmsx2_xwxvnvVVuKt:_q]٘SJ=v::pzsN]yVpxwv[v^|Ҙ˜]tym<}pnXVߜW7y9+zWsXSӜ~w6v]5jzɜ}xUz_9[SMS<՛,y:}0zwR39\_|uuVuyؓVlxPݐo<tZ{upx6N{Zq4}rxr7Tk\Vќ{XwY.|S2}^Oٙھ_-ڬuqtt\0Wuy\TzUv+vt^zS[܊zu{-Z>R[\R;OUZ>|zr^\[_]VYXPV[s{rt/ݐ^V3[U~Xru۲{YV}Ֆ{vW}<\љ4XZP}Zxvr_utڽz2z[Z|yq~5W<__7M_m<qw~yuZR0XSޛ{ovvyrtTS_WrV=ޭrorur\T{y|vx}v{ynYq[~Q>{T۸;ws|qظv]tuؐM~|rZX8TP[srv^6:Z~w7]ZSWsqnYמvS|tXLVmsx{tWP|QZԛԚ]vׯupѸY_^vn}uv|1z^sXSZz{0v1~ޞqyqw<|{ryW\w{TSR_vnpqi_vS6}VXyҚv|uwrW_\z֛>qT_r:v^|t}0uw]8yRYνwotzr6u9UQ](Vn/Z}__W=Ҙpyxf|]xWSz]Nz[v|nuUQTܙ~RX}5mys;\ܾVsu{\uolr:ZݛV~T^S}\Vluot{Wx~ڻߟ{Vn-Yp{xW2t\yxmXx]9q-Zu8^xnvW|6Tro_=mZ]vzkԹڶ4WmzY}\]~uX6tw&ݱq<9pp:n~tY9]s|U:\^xTۛY{z}V)2l~{Nݼw1_p|l5V|p3*v=rpmZRU8Z۹z[>~V}y/SVЙ7\}_Y\xwx^.ҫv_j18]wz.sqZl+5[2uqٛkڶ<t=PZ~?_PzXZXs~}^z81rz2z-u9o6yznR[u5|u]O]U_g|kX]Z{\kqvPSPTzlmp2x1v^xmv|iNRS^t2s1^.jxِ^MϿzw.uskQLwonwSw^{w\UTT=mv<Zo]rVZwm~?_[xӰru?^ԋ[t+[M|q2\ژxm3\y8loOXWW|1y^V|vZ1ZTT+]vV.k:r\Q]۵yzZqfsm][ޝmy~YPX0&pr^ߘ|pqW>}rW؟n{:=~gZmvNXZ8pr^tث\fݿo\uv۴{kޜ(h}t~MUyYg}R[җ^93:-~kQxN}y{pZ9)=euyQr<|1r(RיГ~Xv~wؖWtSQR^:sWw:xc[|>Xwlvxxk}0YZپ}<7u~~:/u/vUZ\Y<6u.Pؾ{w>4nէsR~lu~2o}~-W\\Q;{sz]vd2kZ\җ{o3u_]8|~^ЯWLQvqrzpv<QxՕ]vy<||~t^3W֚y>ruݙzv6fXt4xtqtz~qp֤/{uۚZ\YZs>Y0|1M|RٔPV^twsorh{SXwv5nu|67pߔV<qt\ׯ|{sjv|}ٸZ}m6БwX2vw\{vn=sS\RS1}p.uQWOT<vmv^\wYso{UZ5~prpy2^ܳ2ZSYԞps:8ywu8rW6X6q62<vmٖlqqڑ~-o;rvq|z^ovsw]SտS|0qp՝(]ڸzSR>ڛz/x^t{Wpw>LU=Z2z3xt{X؏yj&sxwWWR_n4uySVT}ܛ\\75sv?rqyпYUx~}rvuW7toۙ^_W__K<з7y5pz_~X8|VZW6n-0}|PsU~xlz\w^{X\v~n|]k^y_YTz]qor߿z]\9r0wxy{6\,v]3L{pӸW:R[7uZ=U~]=WntZf?wwXX;U֏<ZVp~\Vz*4us^[t]wVPTsnYwqvZ\PY\R{ޛusxw{=tۑXm߮r~m]ZUm]TSX1zqssVwUw֖U~_w*r8]yQ^xSޟt|nvuZ-tnY\ܷiur\3u4t>[ܗ>Tuk|:ޜ\Tv^VvҞixqu|~OT{q][pY~\[
Xfr4/n[x{uKm.ߞ\pqssڽw[WXW~_un}46Z|O{}nXNVqtwvtؖ4xo_\YWxvWm{\{s6Yt2vnt[ZnO{V<YzT1nP؞Rkx[{8{{sSЏrysu]0^v_S<Xڷ.Yrrv;ژO3^yYQQ?}__ur~^6U2MZWo},zkyVyU\r[y[U\0wryz6m0v\/ߙSQWWxxXpu3rZ}nvuQԚR]\>ܟq_w9,mTVqsڹ0/m]uzؔxSYvn0q:}{WxUy]+UW~]ry{7XSmj~T^}x|norwּ7]gP_֜\z|sq>qn9V|-~=Zvr}XؙߙlYҟvZtVڼliUיչ6wo_vw7w_sս\zq:pxui\Rn_z\W~\sSVT9jz/?}\]o~ѿ9~[y\Ti_νZV0oYUp3+s.Uv/zw{u־ڻjv_TY~zU[^j=Z=-N=MO_[xrt|-ݟvu}Zؘxxm9psy<]pSV[\{up1yZ9e\XZr^6;RzmחuwnX6u>s6Unpz[wܒyrxkܰwWn:UVִyvuXXpz?RUuvY8kWZsxКzl3{z/~vUXQVqR0Wn{^z.Z[ROݒ.]|mrw]5poSԞvTtp]0ӿQ^52z:Vvou_TZws-]q]4yYRqp-__ѽtvXړXp{p[4t3W[[Rٚ_^^qrtXZtthot{6\]~_Toy:~usTVYnq|w~z8PПP\]VY}ګx]tX\2vy7^uVswx_ݲi\^8NR|lrxuܚo6VqW>[ѓۖq}ujnt[}q>O=WMYҔz=qtwZ\~XrvXT|9;v־Y\{w\mU[yJ{~-.\~~\~=sR[4zr;SZ}3xXі[YYvrtw]URtוS{~m9֖6xwp]tkߪ8{\~Zxۍuo_ny]XvZtTtyW><svٴR^l/t|zرZ{y][7tkn7:p?ԟsLv{wq|<rӮ?|_PxsTZ{VYq35st~x-vuX~rm=W]Yu؛S{y3\_s}yl;~MnP~Tlww6^ns?[\U}14sprs\_zOTlq}xo]_8^|_t4X=vۚܬTu|Pr,ywZrrXs>^yq{vn\}\VQ\x1*s{]5T_]<l6ptپۖzYXruuؗ2SP[tT5qp]W\N}Ow;}x~2T7]ڵZ^Um{fyv54Xگ{WUZ^xv{<w<OsUUy|v8ulXxyzZZ؈~ywp]Y^uSUVt>n7v\[^Z~_ښ\m|_xҾ\_tϱU|_ov:2-{PuPW߲Uxp-}ZQX:jytzڝQrZ|[Y.mz~pTLRW{[4wvZWYOSz{2sp,VwrXvuZ_Z2qqotXճZ[\^t7\yrӱQ|YOSy4sUsR5^x:|u6{uv̕Wx[owsvTVxxtVO8X.w|=|{TYuY_WV6_p\pTOR78:j^|U^XO|4yjvӞozWw\]4l7]{ڏRqY]r4XmUW^zwxutoXTun/z=qwom|_ڷXrUyq1^RuMv}\Z~r6뻼|vZ<^v0R^pj{_y{\qSkywSkz;7{ԚwSVTXqr\:6VvL^]?kX[p\?M;ztvX_Wrxk}zVYmX|t;1S\txyL{Pu[5\}7}_X|l~]K[^ݽ0ry_vZsvSz1{uvvZ]\_X~w/4_VyYwPYYm4س8^=S]XXu[97zuVU̐ݙozxwXo[P{Uwy:^yu}m[TWי|tnq]-xzMSpV}p/u|]+x[ySvo2uwt|ַܑ|4W=tV~4Trqu߱]Qo{֕վou;_~[7]UV]n^Zz<rѫy8߳w\ooZ]Rsuy\oV:][tߙ_]=omZszUS[8~_;^z_~yٷ{8~r53~zvx\u}slw:Zy]_}|7=_pm<xwWݟ^t[_Z+pzֺVV:vz7x^nyt՞sR3~\=~uyZp9qx84oNvYzY^~Y1/{^T<]9|yt>,~Qtty{\TXܟښRZ_tqtv+].tp6z)y^]knU<[r~_ZϸwZq^pٲ1q_wu{\^XظQ^zq]yQy^[wvx^.\qwsu07Txk2{Z~yؽsX_ۛ_UޑT{gyט[[]o20zuo\R}korڝPWr85v{QUl\\_r[Xtqo6\wyu9QUS~ou<voSuo.T{o76xM[YUwspԒQ}rp,sy4|^7]Tܾ})x}Z՟dwzy|rr[^Vx^]|Y=&3WZP_{XVy|Ք_~vWy0Xeܕ|{WT[wڛqYfl~=>^VUpWx']5RZ_z\2}duWsypYyxh{[Q0u]w{_p]_V<v^6uhT]T]r~xP_sX{UWeZ]rq;wiuTߛzy;zh\۽Аzvs56utY{rmYrUX|x]9Y"~~ܗQֻt}<Xw%nY\Rt_qwڝqUZP{;R:՜ZYlZ{ڻ8]l]Y]]yZvVeQvTZR֝}zxV8ٻf^ZPQ75}ӷ^|9U}yڲp/ZtsjX\[~o=ZsoV\~|utgx\TXZQxQzrߴ(N=}xwwufNּzW]p48t]x}}{yy7PYٖ\y^r|[vZ_1ltݍY><y߱zyjXjלVW{ZzvqyYvsry8rrq.qzWUXߵz^4qquwOVTYWvpy3YxMv985^iR^ZRۺ{3+^jZ<xt{x^iXYO}m=st[wZkΞR||tWr|[80Yn{X|8xP^ޙ\Yzg{iv|Y6؞]]t7Zl*WwVTRܺ]^1Wu-5^0OxummYsvoSwZxmt}~4ձp[\v^]Xיܰ=\]OV_z]ҙӒqZ~rsqV^Y۽rj^>9xStRu[4w8tn_v^wZlYw[~v8ڕ~Rpwu9zqUpy>]v۔;]wX\}}_<tVZvgo_rn4}\rpNyh}mtl_zlږZlYvUXv|zߕRy_wl}/xWv<SYڶN[ޙ|:toupYmrwvwm8~rѽqx^[vO=YMp|~|U[כ}ٜӘ{X|R~|W(l~x8y}ojo_lytt<tY|W\1rV;wv\}?V|wߓXn}xs:tpo}vlrv[~6XLYw{olxx[TV|xn|vtn<v]Џt\uW{k:7uU[wRytwpSQzzv0rVX:Xk^t=wZXTnyrpTgViNT9xu]o}tuw8{kӓԘtw~0ҾN<{xz+zoRyvywvyY3|Yj/r_|{_4SuhV:{xt{<]!Z\Xtr{5w_h[QZ_rqr}ۯe0Vڻrsw]%UX_[30;ؼ^Wxy7Y|mԐ|xyqU^nkΘTwx\pYۚg[X{|{2|XӕR|}\s.TW|\vUfWT]WwsTޟ(^[Ցzvwә]hޔXYؚ{vw9f]W]9YpY[iU\vtlt[ԕ<[{_mtoXܿzslZlYxҖ__T^m_uSgzUQu[6Z]q*k\Z~x4n}Q;[zܷrtnw&ӺVx9\Tt%/Y[7|w:r{\qVY[Zy3u.|fuTYy|nWqWx'y8\,ZV^Y^~oTڰ~њqnS]ћ_xv,g>}V3{߭wnռ?믖PQ;]q_M]pzl_y67wlYwhS[XTq>u%Y?=~5|s|QWUsZvvX]TV^os[vjnYVytsZu'7[ߛzvw{<u\TVVzotY{ZҞVV[_x6rX[mNޘYr}pm[sjtQڑzq3np<xT\}}_m5^\|ntp?]y5:UXְ|{okZ[]ݔlyt6i\Z<xz5{ywm}4ے|V:r-{>&vyRzuq^{Yj3\|Yttv]~n4URoUU9{[ZЛu0]qv[wrwR~7kn7uvlt[~T^_u3s_[t.\zғ~|pmzTwVqn~U~,7z1x>QX\ڵm|Vs~pە_Yx|w8u9ZUWOqt9_Y[ָ3|RRj\~Yvxݙo|m\ؿtu7jszw|XXy*^8<i]VѺRyXwRsUٸ~uuUھ]Y9/x-{JszXRT1[28Xtwn]06pٝz?s~ݭ|86y}O^9xwx5q[zRZ1U}]oXLUVm|SW9{ھ7~۰o(uq0pyo]9_|WOYyopzy~J{zؾS9؎US\Ovt\ٓ[[Y\2;r{7262Tr0h|l904>Y^7n/uZ]ZuuWMV\n=ܽZ{OVטݲ0u6YX\tOR}lo8{9kVМrtt՚]vq]Uxs/v\uTZTtl1Xu83]}r1oqZZS_wovQ];Q\]9q[y[sqxPݿ/w~>yj\PZZv_<_+vwNXsp0Z|7Vq~<[ؓr<]lYT{\ZxTVѾV_Xܞv$OwTWvz^{<|W6rv{ܟu[^v\ٸyxqxݽv}xޟ{WZ[6x^d^Sy}|]1<d]Q~ںww|ݞu0d^TvWXvvxxޜ{wZ|SZRu|6^|gٟ[yxx6Ym[^^0?|ڝ\]ur{t"{Zٺ_s\w|\fkUؒ\ӛY[\z\Q\OQ_ws:zdq\\_6=xYws%Xݘu{}]~\~i8YҜ[}ײx0>wtw|s\\SP\؏_Y{ZepWOSZXܷ_Wxkg{ڜq}vu2_gԗy_[qsoxvv[k>TOzWZ\{qZR{}[XiUZ\_q_w_{Y~dTS|uzuܷ~P{[}-z}2?qkSXz01;LRߜ_tvZ^[jKTX^3|[>(jL[Y؝o]vt~ߴtpdRԗ~ۛ3xuZ߳\umNQ_[<svu0ЏZun]]v<ej|90osiKX\Yضv۲tm۾ؘ5]vxt^;hzL^wwr:~{iP~nZr^v|}-ZڔUp~]7Wv2S[m~\{yxTNSәX>w[X}lj;ӻz}~v22֕zpq\u4n۬y[U[6z7_.nx؏Y]ۭsyOTpy;wVnzYO>W|Pyz{^}X,XԴ:W]vUyrqqzwp_P}WZv{}%:~Pzߞy_oqn_ӞXxtuZo7us]Uڙ^5}طtzZz_qq5ޱ<y_pZ}ZxR(v8X~~g/v3{q~1_y۟xqyOV3VZY[]_RZ3VY_xTr]qz]tvipv+pywt9{|24z^uqRV}tWޚ[|Z|W3WmPؒm\ۿrZ[wwqlnjo[w~{[z^m}ږZzQ}tRTP|YV^]US/Y_vV^yViZ{8~~}|ߕ0]y{TxZݹnYQ\sՐw.9Vsqm}q+sty+zwn:]UTWZSxY\YvpUTս>lOo]\wztXޝz10<8kz<]~7Wv~suoT2xٞ|9z[|T8=ɘWL}2UYtt^[wYm/^tp/\zzvtpqTz]_^9wtP^YV]݊T:Z];_;Y_^tR7~;[t,*wpm|r3imwWmw_}<T[_^r؎VSzVY][w5qs;}Wp9ho>m;~v^ww~ܬy]qֿtq:ҶR_0xո{q^S8y}|xgx3]~_ۻTUߕ^U9O^^P]WVtUtyyr|tunrvpxn{swoߵyۭtU]v2X{_֛Uy]3O:Ү[;J&U[y}=x8|p?[W~Ѯp}7Z_~syV4[ZnZ4vv6s42_z^_/[[[r;zWVܻWYtXS|X^ZrZx9N75oךP=ԓO~\[o]\6>h6^_p*}o-q|q^]{^{QQTӜ<WԔYYxy{\-|rqp~tqt^Y}}5]su^yusxZwn_2T}]|VZUVޝYY;sq~]8\0vvXo|:mZ$}v59}|0uo_wTo\RٴݝOq]~~}߾,{<|ڒ*TSWԗyWTw^zSmz5<uk<8nzvjn,4]}z^ۺ0]\;ڗ{zVW4y6xQs|-4VsظMvݭ1z<_o^nt1_{o][V4q[|]{\V~ޒv{X[>Wu1WymoZ4/t}vo_puXv[vԟ9ӽͿ\URM~\~X_Rݷs^qּq|/8pW,~1qڹnwYx6tpwnR2;\p>]TpN292\s^Y2q}28v_zXn[qZ}=pپR[?xZXSxڑ{zTZϿ}3tzT\?kQT^6wUR^s3zqrxh2sz*t7Xlmpp27Xo~}>uZ[sؕZ}ݿQTQzZ_VtU9Λ;3|{w۶vW<Vwxw<pZ=o9ZwzZ|Z6yn6pX^ܻ2^XZn6;]vUmԶW~ۘxTUU\m9tt_\m{<[zum^4Y_[?~\[WU{ޔ_ZvMZY^}ܴ}{ߵ-o~?zl^.z}rqnqtVtzmqQ7UnR}_6s|{v\ڟXں\W]X.Kw[WZU}\rؚ^znW}\r5~Y_r|u\YZU~۰8ݑk\;}zy={[PӼr~}8.rYNݽn]<~6NJZ_-6u_v|PRRY{:qw1?qlxUWRؾtY1~wӕԚv6{^VwmTrvUVc۾ַ^uYz_7w.lyٟv.p*W};t|s2d_[[1w~yڴz%ܗ_zvy9w]sUy\z~ڨcZYZx]v<]u|ܿ
Y1u{7W0#~~\Wzy[ݿ[(R{}59xSygZ|T߭{_x^|dؙrzW_=czߜߴ{^~&~y~Z=y_=~[p;qҜZ|{43Yyuy|:~R\]6{^ܝ1>~QdP[^y4UQ~&[ܾ>NΖۻ[s[ַ?]ޔdlOVZw|2^|X$O\w;4x5_^UҕY}<^ܘz[R4fWPMZzz<{twV}{z>٫exPX<uYXi\X]M]]:ڷ8~fh{ry<4ty\c{U}yywh<vϑ~6r_~\{c_R}x]}~]X\}Uܚ|{^߽|Vyݽ]_ty4kdVі^ٙ6xZܙS^]{}yZ^5xrmy~TS3~_zYfyxNZZZ]ܙ_wVS]5~UhvtPNXRޞ]]Yvx{Q]ڙxfvu\;^ywxqzV\ZWWg߹ߜMUW^t|v]Z|UWv_qXQ~םo$YVo^\ضq5vחVv:[7{xr~ۖZyn8s]}.*N֘\v?}|Z5iuSR;\q|sݖ[z+omSzqvZ}j7o_Pپx__TwWrnqqOXz>_v~;wWWђV4~2+mmx|r7oxz땼pW]ם^|ܼzqTqԎYQ^^zwxTNvוWTџؙ7~*y]zՎXWvvyu.p6g{?trZ]2_qnڻ>{s[p||sVn߱uSnݾ]2WVSP[\X[[;ޛz<T5~W2|ֶwwUUsn.Ry_~0pV_:w}xyټU[}Vݏx^R_5Ts]_XݒVښ{StVwS5Z9{\=4ne:ٞ{Uo{}_T|;qsٮոZu[Z3:[ֽ|xxT\T~YP^_zY0o|7p5p]OW=TN{T\R{3xط0nw>Y?Y;v4t:.vpzX|7ԔixW[9ou?{x\ZvqRn2llsvuz3|q/{sޞ8<s9]\}7U>]YՕWWЮؾRVs6Ovxp<\wYߔun֘Y|jZz}p>tnPW۷m2hԖk5v|u[hp-8sy8Zp:oZ^o]y1ZZܓSUҿSR^~ZR:fZ8?x9qrtpv{ptt]xYy;z\[o:Tښ[WYOR^p|X;X_UP_\_^.XZ\5|2Tslh8r~xu=^2:Z6\^5UR^4_sjt߳5nzx\zT~u5sٹrݙМvMXѐXX3}}yܗHӒVp7ZZ9ܐΒR]kvWu7sUQqmlw^v5stwVғuo6֟{|\QX|o5zܛqWOovkt]uտtXQҞwywWwsk\Q{nuٗplؚؓZ{m|tzZWRҝywv9tNxjS?Sy귖>^tTګo^\w\-*X>vmV_j|~rz4Y3}t{O9SUҿ1{ܔr<_prlTu[mlp[R?߮wVX;В{qT/{NpnW2}P6{WnY~\P~kyvϜX^W\zR(uvZ6_u|]$p}WqnS}O|j6_T8ӻ}mf\^Ԍ^2[SՏ4:uVhxU|uzmV&k}ߟt{-|X>*k?zWO~z\Гgu[wQU۱8mzZ\xؘ5zw]7knR~۰]֩pQZw7y8Wt$l]uQ_8}\O]f?]_Ov^X#ׯӵwN{q]sWW~vt_&y;Qھr{z>%r~y\Ps~[L_UyӒ^}٬\]{Tz|{\SezySV>uq2W|e]Y>Yp;yp^\[r&_;RWi{W_yR]}=n[ҟyywڙxRdyT}:8u;U9v[|[4{ttzgڹZV<y{ZykpYX9]Tv|y9]n?z٘9[=\O4(i_ܾ{[TLY:^|>[Qp]SVt]ٳWj.|zPR:[{^[Qj4_QUw؛Oi;p_yzowpzTxrSܔ\_km{rZ[w:YJk_~NV~7||
:+nPXpV}yqgt~tVjtr;V|61ryYRzVy~T,orxҕXYoqyטUl{|qvzSؕZX\x_ϸ.+[+YWSW^q챺wm[{}xsPXN]~ה29r{X|poWsr/U^>pzs+2yW[U?p:_sқXwUxX|v\wswV~S_ynTQޗvyntїUoU.tu>zoٞ-Lu?wXRYZ<:umQ\Txpul,kWzV[_w5vvTxQܿ[t]Yq5^-QYV{:{ZPP|^[^wTҙZQ뛴X>~t?y{^R?:Qy_mX|~zy]r6pXk\ږRv;uWLPYݙui/_u{zzYv֫s9ZYzpu_||wp5vskZ~Xxt|ӭ]r>NY{Vl:Y1/s2YTսxrspW|yp{߮OPunޘ9^7[WZY\s2,ZX~z[PNڴ{3YzvzΖ|4xnpU|oZQQo,VyvVTع{.7UuyOԚ]t.\;Ty{oIt6ޜ=ZpZyО^sq:.{}kUtOzR[{/:~y+PzP^t28{xpzrٙR^OsY~o~:z]5]oKuY^2Z4t5z]]vr]vtsm4>߳X-[R[]yok~]qoXNXVuu.1q|mzsYNXظ_5w}tʛRнZoy81Z?|Z[W|zwx1_~3|\pZi^=ro28\r_T<U5zz6Y|xXlԖS4m]Yt~ҕM5<y]Yzv1Szя}v+r^sZZr_\u}pwRn]VX~o/sVx[xܙ}|кz0Y^y[U_;X|o7|Z]Y^X/43v]uoXq8X6zWvS][?Xv}:\~Pp\p\R;W9t;z|ytQytSr/r74uXy{uVU3sx<O{V4my7Xtڻ_pVt\q8tU{U[yr<_uZ3K\ۿu[zZ~ZUuo+n>^]?t73~~>UuuYNYyx2[Svzwݔ|ֹ;~nl<}|~LxZ*lz}m{ZVZݝ\h~5V]xpTվ9XzW]5\l^Қӝ~tu<oul~R~szyw]mZzQsu>:XغY}p{~VtYv[Spr5{Xv\SVX~)R<ssvYW^kx>{u6l>/wXYp^ڐq|=wڴWTtu3ֱ{ѯY|s~W}{1:\Vyw8~Zq_t~}yz[MY|:ܽݿWs{\<nyw^vؒP|Rp,s5out]ZnY]4,t2XZVZ~\vp9rz~VSw\xt7o[[u{\p14>pt}ZՒ}qvW<xrԎSYvsow<p_XzZUڿu4wqy}5unܮ:?uX>X}wv}~VZzsuyOVYxs520]XyYwTk87wq]mtNZTRuuVqtu3W6^zVӕw~=yt-|W~?w>vthqrORxp\4vnV=SVP8tx2YݗPZklxWvU]{u\^Z{l[R[|it-Rwwxzr\Q^~k=o=ּvv2uWV3p0TUOq52ZZ92{YT{qm2UuuwrvRpjq}:lߴs\ё|t==ֲpyuRT|onqW|;{Mzmly^vzstU{zܜlvTPL16_o{yUYӺԯxֽ5O<\[ӷq2UuRZ{zSs^nZV<zЙYrs|8T7Yojx]|rRڜ}o](^yzZ
>nyt{Z{ROrlvZ^x;|Z6?]s]v<yZ(wZ}uS~\uY6ULq]ZА\YYeZWT3tuԗXqx[r^Ν+)uZܼX[9}P:\S}\v{l]|wv=hrTzzru{t6iY_ztvյ9UgvxR|߱^=\rx_^WWy}pwg߽NTwZYR(sXOSy}|}59}|XxhW}is_~Pxy;\?Tsjn^u}_5~|tTw_\wyu[ֻsyv>WxcwwVw^_>)jz|S||wWٞ_7km]|TL_twX}Rq_Tv=:V|xy;8xtRWgۜxZxX%\SOxv{<^~|Tz|ߙMhz}]N\4yZ}}~x)k{LMywr\d:zUԘ5V(s7X{{tZL5|^U\~PtOvzxsxXQ8wQ}{R~<]T۪tyyΒ^}yMQvLxy3nrt5wxzURu84s|y<zryO:.~y\ъTt_S-|q:2zp^Yz?W_u~M֚xsz\x4Q}_m>:KQ~YwvzZv{oۜoR;;kR?,|46YS<svQذlY-rYSyqݘt\nWX˞WV{x=tsu9Towxw,.X\Su_p3W_VoX^rq.W߱kq|[V|w9qVݖvs}[x,[|Zظz9nk{ZM[\pXLM\t_ڬy8sVJ~]p.yqoZW־o2^vp<yԙ~Pyytq_oYzT[X~0y{rwxT>xY;kq__1p?o?{U}YrS[uzmvՑs<nS^|_\s:Yv:rt7L\^~r}|T_߲wor}{WN};k|nZzm|z=rqRS\ܓWow}X̚zWs|_{[6o||w[;^{XP^xzjypm3X}/U3\[OZYq_rWRyz7nn߷Xpw2{svqVڝtu\ozSQ^8]~tZmUy{]^l7WߺVtTҬr7ztYtszxr_WѺz1/zYMs\tZRRٟZ3W^tk|Yؕ_wX,*^v:Q_Syl^joԗRZ|\3|=TSsє۸yzvU;z2~PYZ~4>]gwk[_O\/V^lon[Yw~}qspy~8P^9-nyY}5
sq>WYZ}2yvZ[Иy<[Zq{lT5ZqsnVxvwq=s^vsoqy}WU|y0v^>Sٔ~\={ӷu5R?w|T֓Zw]nutTְ.yy_sx]R2[ur^Zt]RryoQ~~ZU-2xwt6R[^UYշu{Yy0ڶuP\R+X/u߰^yw[}U[|qo\UXUXSW3tVw:]?Թ8uu^[Y[vL6^ZW^{vu״X~1n}Uv:tp{Uݽ=xxs>pWPxm]\ps;Ot8X{xqQxz7ݕmov~}OZwwY}xtwNP>\3'|{oX2=nvpm.[z<}\\0^vL}^tݘqu߶XYՋ4Y}]y_{XђU^]x6}pxv}p3T6}y0p~n^[tw^{8vWZїz7Xn{_Sٰ|TwXZN~x}n_>pUtR8y7wr[qx{su9^pu_pqU>yRtpstwvryvU1w4pm|^V|7p1^_40^qrnܙ~]W?nZ[5ZQ>Nؽ1Wtwv߸V_Qvyh:ټs:uXRo}ywUԖ|4t4Q^yvXR:o,pr;zN܍zxT]q]s{}Z֯w<ul\UQ]6_3t_w[Po/1}vnu|7VTzﺴyzul0~pYq2jz}әnV/wnPZ\:[ozSvy>V^SwnssXY^yo=ٕVzt>]uuq]xMMZN{q8rҩSY[\-4luR]TwWUo5Ws{tn{v^[N^S_W:;\XY{y]x|]>^xZ[Xw%xvZWXu^6կr\YY9^sΚNVWZtl}n=mwwn<_nurԑ[װw8tqV]TSܭ*\t]TUv5Ww{yKQS{{48[UՒu|>n\ٽukدsqv>\\qqs5[t~y<ӟxtt}6sz_˜[t]zpYSWx;qzO]=pksuus_L~oUvZ?ӛx3ps[j~|STԷxR\t]_Ov~Yq0wZxݰ~un|t6VT1ؐhWOswtW]_ZPTroquNvԝtPz~|h4:xQ~Q_qYMl^O֙xtVљrY{ߝy}۶6nhm:VyYqptoѳ^Y_{w^߶VSu~X?\|9MX]R|_w9M)g;\Ք[ٶ~;kzV^z~_ޑe?]Ք]_zX\fuyږ{zwks;SY}|tx;ڲ|W|x{ysuz>|]{RZYTZ]wyϖji[Uw|~+*^]ury[s_}~~]\^=|tOqgy|Zzv]uۓs'\^|9OR(_=ys?jk8U}W|vz2yNu~WT0ywYf:Zxsth,]]ZTxt4d_zV|v/TXؚ}~w{[4}xQrZ_x{\2x>inܚӚ}zuzw,^??^]T}?tnWV_tMWjn]Z:}rPp\|Tm<xUh\ZRw]^ZԒrT3tXS\~73wzUY3)XWvt<uPqWZZSw8?WqV{T~Pnk{VwwrQvl꼞[]T}5u|5]SSuڸOr<tҕٸXvr<=Ux]Ym*x9^Zܒqut^nrsSuzWu9o4_On~Z<T4hv\OtoUrvޜڕ\Sr[^ӻzPk^_{xw82nZySr~u2p}u=tZ\tv[QuO8pxSOzў+}UuTtW?oy5/vZ|.nz}w|MvݻORҞ]n6}؍V5Wnq_wO}n\r|[S\0]r5zO;m^XUϚTq9qlXy8}[SpT
unv<PՋ]rv~rnzܾՏRm\mwUq^ӕ06yqڒR;rYqyXZ|Tʓ:^s^2>S[>MM~t\x?rZY۷sx}t_QU{|r_N+s{߷vpQ|1]lqߝyUMJZ3w:]^yxZSX|q][x[O4t7o[z^[N^q9YyY~[;qV:yؓ\4z[spZ\OT<kyv|vԌykquwZ=^Mعny/w>|ypj[4:Srіvouo{؟ܻptoxX
^wusTr{z1UmuXtXK^{5rKN_p5ִnxsYuW~)t{Zv0y|_lߘr~xY
Ԗ:v~;Z]NQZxr;xZJ^wmZ46\tTϼZuoly^u]s1oZ^|Mʖ|vr6x~XRk|Y/}N>^p5~Nwrs}w[K9qt-?_v̏vqWp45}W8swӱrxݺ،tz1\W}p}R\q6sptPY6X.s5xWN|q}qru]}͋~nS3srWqOغwn{ֶnoux]?96o{Nz1t|}yzך62YxV~yN]o}QLX{ڎZtX}y;l}wpls?.VWxslvv8PQ3x2][lrt}y6V~;vX[Rx{2^wڼYuvqPNmxTXvRtw^uuTq>~9?LR.Rx_3twhz}{nS[o:kz_x8ZӕixVWXxRnq~P|xULTnUZy}uL9.=xyylٻl긹^Ustu[n?\x~МlxV5SWߚYmZYSwXNipԾv֓:rztV:rZ^wV]vl\{-yvQgV2i/_4R߰lxTxU]xZkޚTp\yXX;n?Yj:[YwV\i-|ەw]y[0{Pxҗml+|S}{]יio[z]VԔ|ٟzNsjv}S^Uרl\[~xtz)]5^Y6Zjxv{՜}[;hU>Q\[՜Z9r[_\OYpu]u)jv9__q\d\ٽY[Y{\v6_җQWw^~ivۖYRW]]zr]^ؓgyZ_srr|*μ؛rX[i~rx߯uپUQUSY_^nQ]W_|}/uگ~xSq^z1\5r[|5wxXw\vzq[y.oZzrq>{p[Xtyv|;S_{ٔqޔZ~|ט1XsY\ytnr}0n~{^|Z=rx_sݒpt{ZUu[kXW^[RםUPZY}TnٷTV^,ޗ|ymڪx^|xU_5ߛs}twXڵXzYtyQ?U{Իz|86qq{mTt]4߿ܞgplwtW*kR}w]\RZ7,[}~z|[NmY9nyx]XRVԜ]Q[\[]PTP_\|יl5:r{{zssoq:?m]t1[w[>=Vk\Ӿ>}X~[|ztt^Lr9tt:_t11p~ZZ<~|җ=]Z۟[7^ەӼp~3UQZ_єWPx͞67zsWnrYڞy~zmxn+mvt_x7~8Wpr{ذw^sM;YϹ2W;Z|ڞ~>]\]{zY]}ٲ{^
\ No newline at end of file diff --git a/tests/gatt_test.py b/tests/gatt_test.py index 2473623..70bbdb8 100644 --- a/tests/gatt_test.py +++ b/tests/gatt_test.py @@ -37,10 +37,12 @@ from bumble.gatt import ( Service, Characteristic, CharacteristicValue, + Descriptor, ) from bumble.transport import AsyncPipeSink from bumble.core import UUID from bumble.att import ( + Attribute, ATT_EXCHANGE_MTU_REQUEST, ATT_ATTRIBUTE_NOT_FOUND_ERROR, ATT_PDU, @@ -862,6 +864,29 @@ async def async_main(): # ----------------------------------------------------------------------------- +def test_attribute_string_to_permissions(): + assert Attribute.string_to_permissions('READABLE') == 1 + assert Attribute.string_to_permissions('WRITEABLE') == 2 + assert Attribute.string_to_permissions('READABLE,WRITEABLE') == 3 + + +# ----------------------------------------------------------------------------- +def test_charracteristic_permissions(): + characteristic = Characteristic( + 'FDB159DB-036C-49E3-B3DB-6325AC750806', + Characteristic.READ | Characteristic.WRITE | Characteristic.NOTIFY, + 'READABLE,WRITEABLE', + ) + assert characteristic.permissions == 3 + + +# ----------------------------------------------------------------------------- +def test_descriptor_permissions(): + descriptor = Descriptor('2902', 'READABLE,WRITEABLE') + assert descriptor.permissions == 3 + + +# ----------------------------------------------------------------------------- if __name__ == '__main__': logging.basicConfig(level=os.environ.get('BUMBLE_LOGLEVEL', 'INFO').upper()) test_UUID() diff --git a/tests/hci_test.py b/tests/hci_test.py index 14e5182..af68e86 100644 --- a/tests/hci_test.py +++ b/tests/hci_test.py @@ -52,6 +52,7 @@ from bumble.hci import ( HCI_LE_Set_Scan_Parameters_Command, HCI_Number_Of_Completed_Packets_Event, HCI_Packet, + HCI_PIN_Code_Request_Reply_Command, HCI_Read_Local_Supported_Commands_Command, HCI_Read_Local_Supported_Features_Command, HCI_Read_Local_Version_Information_Command, @@ -213,6 +214,23 @@ def test_HCI_Command(): # ----------------------------------------------------------------------------- +def test_HCI_PIN_Code_Request_Reply_Command(): + pin_code = b'1234' + pin_code_length = len(pin_code) + # here to make the test pass, we need to + # pad pin_code, as HCI_Object.format_fields + # does not do it for us + padded_pin_code = pin_code + bytes(16 - pin_code_length) + command = HCI_PIN_Code_Request_Reply_Command( + bd_addr=Address( + '00:11:22:33:44:55', address_type=Address.PUBLIC_DEVICE_ADDRESS + ), + pin_code_length=pin_code_length, + pin_code=padded_pin_code, + ) + basic_check(command) + + def test_HCI_Reset_Command(): command = HCI_Reset_Command() basic_check(command) @@ -440,6 +458,7 @@ def run_test_events(): def run_test_commands(): test_HCI_Command() test_HCI_Reset_Command() + test_HCI_PIN_Code_Request_Reply_Command() test_HCI_Read_Local_Version_Information_Command() test_HCI_Read_Local_Supported_Commands_Command() test_HCI_Read_Local_Supported_Features_Command() diff --git a/tests/self_test.py b/tests/self_test.py index 751825f..d6b16ec 100644 --- a/tests/self_test.py +++ b/tests/self_test.py @@ -22,6 +22,7 @@ import os import pytest from bumble.controller import Controller +from bumble.core import BT_BR_EDR_TRANSPORT, BT_PERIPHERAL_ROLE, BT_CENTRAL_ROLE from bumble.link import LocalLink from bumble.device import Device, Peer from bumble.host import Host @@ -47,18 +48,19 @@ class TwoDevices: def __init__(self): self.connections = [None, None] + addresses = ['F0:F1:F2:F3:F4:F5', 'F5:F4:F3:F2:F1:F0'] self.link = LocalLink() self.controllers = [ - Controller('C1', link=self.link), - Controller('C2', link=self.link), + Controller('C1', link=self.link, public_address=addresses[0]), + Controller('C2', link=self.link, public_address=addresses[1]), ] self.devices = [ Device( - address='F0:F1:F2:F3:F4:F5', + address=addresses[0], host=Host(self.controllers[0], AsyncPipeSink(self.controllers[0])), ), Device( - address='F5:F4:F3:F2:F1:F0', + address=addresses[1], host=Host(self.controllers[1], AsyncPipeSink(self.controllers[1])), ), ] @@ -100,6 +102,60 @@ async def test_self_connection(): # ----------------------------------------------------------------------------- @pytest.mark.asyncio +@pytest.mark.parametrize( + 'responder_role,', + (BT_CENTRAL_ROLE, BT_PERIPHERAL_ROLE), +) +async def test_self_classic_connection(responder_role): + # Create two devices, each with a controller, attached to the same link + two_devices = TwoDevices() + + # Attach listeners + two_devices.devices[0].on( + 'connection', lambda connection: two_devices.on_connection(0, connection) + ) + two_devices.devices[1].on( + 'connection', lambda connection: two_devices.on_connection(1, connection) + ) + + # Enable Classic connections + two_devices.devices[0].classic_enabled = True + two_devices.devices[1].classic_enabled = True + + # Start + await two_devices.devices[0].power_on() + await two_devices.devices[1].power_on() + + # Connect the two devices + await asyncio.gather( + two_devices.devices[0].connect( + two_devices.devices[1].public_address, transport=BT_BR_EDR_TRANSPORT + ), + two_devices.devices[1].accept( + two_devices.devices[0].public_address, responder_role + ), + ) + + # Check the post conditions + assert two_devices.connections[0] is not None + assert two_devices.connections[1] is not None + + # Check the role + assert two_devices.connections[0].role != responder_role + assert two_devices.connections[1].role == responder_role + + # Role switch + await two_devices.connections[0].switch_role(responder_role) + + # Check the role + assert two_devices.connections[0].role == responder_role + assert two_devices.connections[1].role != responder_role + + await two_devices.connections[0].disconnect() + + +# ----------------------------------------------------------------------------- +@pytest.mark.asyncio async def test_self_gatt(): # Create two devices, each with a controller, attached to the same link two_devices = TwoDevices() |