diff options
author | Android Build Coastguard Worker <android-build-coastguard-worker@google.com> | 2023-07-07 05:19:07 +0000 |
---|---|---|
committer | Android Build Coastguard Worker <android-build-coastguard-worker@google.com> | 2023-07-07 05:19:07 +0000 |
commit | 3d4b7d286f24ca2ffc83028066628e0c7162836c (patch) | |
tree | 9de2e37332c9056aacfebc24339302109cf2c64a | |
parent | d0bcb79dafd1e15afb2a75556cd9849194a677bf (diff) | |
parent | 763d76ecdc285e49c0535f256b0df4a932e1f145 (diff) | |
download | bumble-3d4b7d286f24ca2ffc83028066628e0c7162836c.tar.gz |
Snap for 10453563 from 763d76ecdc285e49c0535f256b0df4a932e1f145 to mainline-tzdata5-release
Change-Id: I5d596eec8997171387cf2fff6d7dea2115ee951a
87 files changed, 5158 insertions, 1208 deletions
@@ -26,3 +26,38 @@ python_library_host { "pyee", ] } + +python_library_host { + name: "bumble-pandora", + srcs: [ + "bumble/pandora/*.py", + ], + libs: [ + "bumble", + "pandora-python", + "libprotobuf-python", + ], + data: [ + "bumble/pandora/py.typed" + ] +} + +python_test_host { + name: "bumble_pandora_server", + main: "apps/pandora_server.py", + srcs: [ + "apps/pandora_server.py", + ], + libs: [ + "bumble-pandora", + "pandora-python", + ], + + test_options: { + unit_test: false, + }, + + test_suites: [ + "general-tests" + ] +} @@ -200,3 +200,22 @@ 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. + + +--- + + +Files: bumble/colors.py +Copyright (c) 2012 Giorgos Verigakis <verigak@gmail.com> + +Permission to use, copy, modify, and distribute this software for any +purpose with or without fee is hereby granted, provided that the above +copyright notice and this permission notice appear in all copies. + +THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES +WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF +MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR +ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES +WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN +ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF +OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. diff --git a/apps/bench.py b/apps/bench.py index 19cdcfa..4708403 100644 --- a/apps/bench.py +++ b/apps/bench.py @@ -558,11 +558,13 @@ class GattServer: # Setup the GATT service self.speed_tx = Characteristic( SPEED_TX_UUID, - Characteristic.WRITE, + Characteristic.Properties.WRITE, Characteristic.WRITEABLE, CharacteristicValue(write=self.on_tx_write), ) - self.speed_rx = Characteristic(SPEED_RX_UUID, Characteristic.NOTIFY, 0) + self.speed_rx = Characteristic( + SPEED_RX_UUID, Characteristic.Properties.NOTIFY, 0 + ) speed_service = Service( SPEED_SERVICE_UUID, diff --git a/apps/console.py b/apps/console.py index 26223d7..0ea9e5b 100644 --- a/apps/console.py +++ b/apps/console.py @@ -24,10 +24,12 @@ import logging import os import random import re -from typing import Optional +import humanize +from typing import Optional, Union from collections import OrderedDict import click +from prettytable import PrettyTable from prompt_toolkit import Application from prompt_toolkit.history import FileHistory @@ -125,7 +127,8 @@ class ConsoleApp: def __init__(self): self.known_addresses = set() - self.known_attributes = [] + self.known_remote_attributes = [] + self.known_local_attributes = [] self.device = None self.connected_peer = None self.top_tab = 'device' @@ -162,6 +165,8 @@ class ConsoleApp: 'device': None, 'local-services': None, 'remote-services': None, + 'local-values': None, + 'remote-values': None, }, 'filter': { 'address': None, @@ -172,10 +177,11 @@ class ConsoleApp: 'disconnect': None, 'discover': {'services': None, 'attributes': None}, 'request-mtu': None, - 'read': LiveCompleter(self.known_attributes), - 'write': LiveCompleter(self.known_attributes), - 'subscribe': LiveCompleter(self.known_attributes), - 'unsubscribe': LiveCompleter(self.known_attributes), + 'read': LiveCompleter(self.known_remote_attributes), + 'write': LiveCompleter(self.known_remote_attributes), + 'local-write': LiveCompleter(self.known_local_attributes), + 'subscribe': LiveCompleter(self.known_remote_attributes), + 'unsubscribe': LiveCompleter(self.known_remote_attributes), 'set-phy': {'1m': None, '2m': None, 'coded': None}, 'set-default-phy': None, 'quit': None, @@ -207,6 +213,8 @@ class ConsoleApp: self.log_text = FormattedTextControl( get_cursor_position=lambda: Point(0, max(0, len(self.log_lines) - 1)) ) + self.local_values_text = FormattedTextControl() + self.remote_values_text = FormattedTextControl() self.log_height = Dimension(min=7, weight=4) self.log_max_lines = 100 self.log_lines = [] @@ -222,10 +230,18 @@ class ConsoleApp: filter=Condition(lambda: self.top_tab == 'local-services'), ), ConditionalContainer( + Frame(Window(self.local_values_text), title='Local Values'), + filter=Condition(lambda: self.top_tab == 'local-values'), + ), + ConditionalContainer( Frame(Window(self.remote_services_text), title='Remote Services'), filter=Condition(lambda: self.top_tab == 'remote-services'), ), ConditionalContainer( + Frame(Window(self.remote_values_text), title='Remote Values'), + filter=Condition(lambda: self.top_tab == 'remote-values'), + ), + ConditionalContainer( Frame(Window(self.log_text, height=self.log_height), title='Log'), filter=Condition(lambda: self.top_tab == 'log'), ), @@ -366,17 +382,19 @@ class ConsoleApp: def show_remote_services(self, services): lines = [] - del self.known_attributes[:] + del self.known_remote_attributes[:] for service in services: lines.append(("ansicyan", f"{service}\n")) for characteristic in service.characteristics: lines.append(('ansimagenta', f' {characteristic} + \n')) - self.known_attributes.append( + self.known_remote_attributes.append( f'{service.uuid.to_hex_str()}.{characteristic.uuid.to_hex_str()}' ) - self.known_attributes.append(f'*.{characteristic.uuid.to_hex_str()}') - self.known_attributes.append(f'#{characteristic.handle:X}') + self.known_remote_attributes.append( + f'*.{characteristic.uuid.to_hex_str()}' + ) + self.known_remote_attributes.append(f'#{characteristic.handle:X}') for descriptor in characteristic.descriptors: lines.append(("ansigreen", f" {descriptor}\n")) @@ -385,12 +403,31 @@ class ConsoleApp: def show_local_services(self, attributes): lines = [] + del self.known_local_attributes[:] for attribute in attributes: if isinstance(attribute, Service): + # Save the most recent service for use later + service = attribute lines.append(("ansicyan", f"{attribute}\n")) - elif isinstance(attribute, (Characteristic, CharacteristicDeclaration)): + elif isinstance(attribute, Characteristic): + # CharacteristicDeclaration includes all info from Characteristic + # no need to print it twice + continue + elif isinstance(attribute, CharacteristicDeclaration): + # Save the most recent characteristic declaration for use later + characteristic_declaration = attribute + self.known_local_attributes.append( + f'{service.uuid.to_hex_str()}.{attribute.characteristic.uuid.to_hex_str()}' + ) + self.known_local_attributes.append( + f'#{attribute.characteristic.handle:X}' + ) lines.append(("ansimagenta", f" {attribute}\n")) elif isinstance(attribute, Descriptor): + self.known_local_attributes.append( + f'{service.uuid.to_hex_str()}.{characteristic_declaration.characteristic.uuid.to_hex_str()}.{attribute.type.to_hex_str()}' + ) + self.known_local_attributes.append(f'#{attribute.handle:X}') lines.append(("ansigreen", f" {attribute}\n")) else: lines.append(("ansiyellow", f"{attribute}\n")) @@ -494,7 +531,7 @@ class ConsoleApp: self.show_attributes(attributes) - def find_characteristic(self, param) -> Optional[CharacteristicProxy]: + def find_remote_characteristic(self, param) -> Optional[CharacteristicProxy]: if not self.connected_peer: return None parts = param.split('.') @@ -516,6 +553,38 @@ class ConsoleApp: return None + def find_local_attribute( + self, param + ) -> Optional[Union[Characteristic, Descriptor]]: + parts = param.split('.') + if len(parts) == 3: + service_uuid = UUID(parts[0]) + characteristic_uuid = UUID(parts[1]) + descriptor_uuid = UUID(parts[2]) + return self.device.gatt_server.get_descriptor_attribute( + service_uuid, characteristic_uuid, descriptor_uuid + ) + if len(parts) == 2: + service_uuid = UUID(parts[0]) + characteristic_uuid = UUID(parts[1]) + characteristic_attributes = ( + self.device.gatt_server.get_characteristic_attributes( + service_uuid, characteristic_uuid + ) + ) + if characteristic_attributes: + return characteristic_attributes[1] + return None + elif len(parts) == 1: + if parts[0].startswith('#'): + attribute_handle = int(f'{parts[0][1:]}', 16) + attribute = self.device.gatt_server.get_attribute(attribute_handle) + if isinstance(attribute, (Characteristic, Descriptor)): + return attribute + return None + + return None + async def rssi_monitor_loop(self): while True: if self.monitor_rssi and self.connected_peer: @@ -674,10 +743,109 @@ class ConsoleApp: 'device', 'local-services', 'remote-services', + 'local-values', + 'remote-values', }: self.top_tab = params[0] self.ui.invalidate() + while self.top_tab == 'local-values': + await self.do_show_local_values() + await asyncio.sleep(1) + + while self.top_tab == 'remote-values': + await self.do_show_remote_values() + await asyncio.sleep(1) + + async def do_show_local_values(self): + prettytable = PrettyTable() + field_names = ["Service", "Characteristic", "Descriptor"] + + # if there's no connections, add a column just for value + if not self.device.connections: + field_names.append("Value") + + # if there are connections, add a column for each connection's value + for connection in self.device.connections.values(): + field_names.append(f"Connection {connection.handle}") + + for attribute in self.device.gatt_server.attributes: + if isinstance(attribute, Characteristic): + service = self.device.gatt_server.get_attribute_group( + attribute.handle, Service + ) + if not service: + continue + values = [ + attribute.read_value(connection) + for connection in self.device.connections.values() + ] + if not values: + values = [attribute.read_value(None)] + prettytable.add_row([f"{service.uuid}", attribute.uuid, ""] + values) + + elif isinstance(attribute, Descriptor): + service = self.device.gatt_server.get_attribute_group( + attribute.handle, Service + ) + if not service: + continue + characteristic = self.device.gatt_server.get_attribute_group( + attribute.handle, Characteristic + ) + if not characteristic: + continue + values = [ + attribute.read_value(connection) + for connection in self.device.connections.values() + ] + if not values: + values = [attribute.read_value(None)] + + # TODO: future optimization: convert CCCD value to human readable string + + prettytable.add_row( + [service.uuid, characteristic.uuid, attribute.type] + values + ) + + prettytable.field_names = field_names + self.local_values_text.text = prettytable.get_string() + self.ui.invalidate() + + async def do_show_remote_values(self): + prettytable = PrettyTable( + field_names=[ + "Connection", + "Service", + "Characteristic", + "Descriptor", + "Time", + "Value", + ] + ) + for connection in self.device.connections.values(): + for handle, (time, value) in connection.gatt_client.cached_values.items(): + row = [connection.handle] + attribute = connection.gatt_client.get_attributes(handle) + if not attribute: + continue + if len(attribute) == 3: + row.extend( + [attribute[0].uuid, attribute[1].uuid, attribute[2].type] + ) + elif len(attribute) == 2: + row.extend([attribute[0].uuid, attribute[1].uuid, ""]) + elif len(attribute) == 1: + row.extend([attribute[0].uuid, "", ""]) + else: + continue + + row.extend([humanize.naturaltime(time), value]) + prettytable.add_row(row) + + self.remote_values_text.text = prettytable.get_string() + self.ui.invalidate() + async def do_get_phy(self, _): if not self.connected_peer: self.show_error('not connected') @@ -720,7 +888,7 @@ class ConsoleApp: self.show_error('not connected') return - characteristic = self.find_characteristic(params[0]) + characteristic = self.find_remote_characteristic(params[0]) if characteristic is None: self.show_error('no such characteristic') return @@ -745,15 +913,43 @@ class ConsoleApp: except ValueError: value = str.encode(params[1]) # must be a string - characteristic = self.find_characteristic(params[0]) + characteristic = self.find_remote_characteristic(params[0]) if characteristic is None: self.show_error('no such characteristic') return # use write with response if supported - with_response = characteristic.properties & Characteristic.WRITE + with_response = characteristic.properties & Characteristic.Properties.WRITE await characteristic.write_value(value, with_response=with_response) + async def do_local_write(self, params): + if len(params) != 2: + self.show_error( + 'invalid syntax', 'expected local-write <attribute> <value>' + ) + return + + if params[1].upper().startswith("0X"): + value = bytes.fromhex(params[1][2:]) # parse as hex string + else: + try: + value = int(params[1]).to_bytes(2, "little") # try as 2 byte integer + except ValueError: + value = str.encode(params[1]) # must be a string + + attribute = self.find_local_attribute(params[0]) + if not attribute: + self.show_error('invalid syntax', 'unable to find attribute') + return + + # send data to any subscribers + if isinstance(attribute, Characteristic): + attribute.write_value(None, value) + if attribute.has_properties(Characteristic.NOTIFY): + await self.device.gatt_server.notify_subscribers(attribute) + if attribute.has_properties(Characteristic.INDICATE): + await self.device.gatt_server.indicate_subscribers(attribute) + async def do_subscribe(self, params): if not self.connected_peer: self.show_error('not connected') @@ -763,7 +959,7 @@ class ConsoleApp: self.show_error('invalid syntax', 'expected subscribe <attribute>') return - characteristic = self.find_characteristic(params[0]) + characteristic = self.find_remote_characteristic(params[0]) if characteristic is None: self.show_error('no such characteristic') return @@ -783,7 +979,7 @@ class ConsoleApp: self.show_error('invalid syntax', 'expected subscribe <attribute>') return - characteristic = self.find_characteristic(params[0]) + characteristic = self.find_remote_characteristic(params[0]) if characteristic is None: self.show_error('no such characteristic') return diff --git a/apps/gg_bridge.py b/apps/gg_bridge.py index 17c1662..88ebdc5 100644 --- a/apps/gg_bridge.py +++ b/apps/gg_bridge.py @@ -230,13 +230,13 @@ class GattlinkNodeBridge(GattlinkL2capEndpoint, Device.Listener): ) self.tx_characteristic = Characteristic( GG_GATTLINK_TX_CHARACTERISTIC_UUID, - Characteristic.NOTIFY, + Characteristic.Properties.NOTIFY, Characteristic.READABLE, ) self.tx_characteristic.on('subscription', self.on_tx_subscription) self.psm_characteristic = Characteristic( GG_GATTLINK_L2CAP_CHANNEL_PSM_CHARACTERISTIC_UUID, - Characteristic.READ | Characteristic.NOTIFY, + Characteristic.Properties.READ | Characteristic.Properties.NOTIFY, Characteristic.READABLE, bytes([psm, 0]), ) @@ -339,8 +339,7 @@ async def run( # Create a UDP to TX bridge (receive from TX, send to UDP) bridge.tx_socket, _ = await loop.create_datagram_endpoint( - # pylint: disable-next=unnecessary-lambda - lambda: asyncio.DatagramProtocol(), + asyncio.DatagramProtocol, remote_addr=(send_host, send_port), ) diff --git a/apps/pair.py b/apps/pair.py index 3729143..a7844fe 100644 --- a/apps/pair.py +++ b/apps/pair.py @@ -24,7 +24,7 @@ from prompt_toolkit.shortcuts import PromptSession from bumble.colors import color from bumble.device import Device, Peer from bumble.transport import open_transport_or_link -from bumble.smp import PairingDelegate, PairingConfig +from bumble.pairing import PairingDelegate, PairingConfig from bumble.smp import error_name as smp_error_name from bumble.keys import JsonKeyStore from bumble.core import ProtocolError @@ -264,6 +264,7 @@ async def pair( sc, mitm, bond, + ctkd, io, prompt, request, @@ -302,7 +303,8 @@ async def pair( [ Characteristic( '552957FB-CF1F-4A31-9535-E78847E1A714', - Characteristic.READ | Characteristic.WRITE, + Characteristic.Properties.READ + | Characteristic.Properties.WRITE, Characteristic.READABLE | Characteristic.WRITEABLE, CharacteristicValue( read=read_with_error, write=write_with_error @@ -316,6 +318,7 @@ async def pair( if mode == 'classic': device.classic_enabled = True device.le_enabled = False + device.classic_smp_enabled = ctkd # Get things going await device.power_on() @@ -342,8 +345,13 @@ async def pair( print(color(f'Pairing failed: {error}', 'red')) return else: - # Advertise so that peers can find us and connect - await device.start_advertising(auto_restart=True) + if mode == 'le': + # Advertise so that peers can find us and connect + await device.start_advertising(auto_restart=True) + else: + # Become discoverable and connectable + await device.set_discoverable(True) + await device.set_connectable(True) # Run until the user asks to exit await Waiter.instance.wait_until_terminated() @@ -379,6 +387,13 @@ class LogHandler(logging.Handler): '--bond', type=bool, default=True, help='Enable bonding', show_default=True ) @click.option( + '--ctkd', + type=bool, + default=True, + help='Enable CTKD', + show_default=True, +) +@click.option( '--io', type=click.Choice( ['keyboard', 'display', 'display+keyboard', 'display+yes/no', 'none'] @@ -404,6 +419,7 @@ def main( sc, mitm, bond, + ctkd, io, prompt, request, @@ -426,6 +442,7 @@ def main( sc, mitm, bond, + ctkd, io, prompt, request, diff --git a/apps/pandora_server.py b/apps/pandora_server.py new file mode 100644 index 0000000..5f92309 --- /dev/null +++ b/apps/pandora_server.py @@ -0,0 +1,30 @@ +import asyncio +import click +import logging + +from bumble.pandora import PandoraDevice, serve + +BUMBLE_SERVER_GRPC_PORT = 7999 +ROOTCANAL_PORT_CUTTLEFISH = 7300 + + +@click.command() +@click.option('--grpc-port', help='gRPC port to serve', default=BUMBLE_SERVER_GRPC_PORT) +@click.option( + '--rootcanal-port', help='Rootcanal TCP port', default=ROOTCANAL_PORT_CUTTLEFISH +) +@click.option( + '--transport', + help='HCI transport', + default=f'tcp-client:127.0.0.1:<rootcanal-port>', +) +def main(grpc_port: int, rootcanal_port: int, transport: str) -> None: + if '<rootcanal-port>' in transport: + transport = transport.replace('<rootcanal-port>', str(rootcanal_port)) + device = PandoraDevice({'transport': transport}) + logging.basicConfig(level=logging.DEBUG) + asyncio.run(serve(device, port=grpc_port)) + + +if __name__ == '__main__': + main() # pylint: disable=no-value-for-parameter diff --git a/bumble/att.py b/bumble/att.py index 8311d18..55ae8a5 100644 --- a/bumble/att.py +++ b/bumble/att.py @@ -750,10 +750,10 @@ class Attribute(EventEmitter): permissions_str.split(","), 0, ) - except TypeError: + except TypeError as exc: 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}" - ) + f"Attribute::permissions error:\nExpected a string containing any of the keys, separated by commas: {','.join(Attribute.PERMISSION_NAMES.values())}\nGot: {permissions_str}" + ) from exc def __init__(self, attribute_type, permissions, value=b''): EventEmitter.__init__(self) diff --git a/bumble/core.py b/bumble/core.py index 34412c7..1cc10ec 100644 --- a/bumble/core.py +++ b/bumble/core.py @@ -152,7 +152,12 @@ class UUID: BASE_UUID = bytes.fromhex('00001000800000805F9B34FB')[::-1] # little-endian UUIDS: List[UUID] = [] # Registry of all instances created - def __init__(self, uuid_str_or_int, name=None): + uuid_bytes: bytes + name: Optional[str] + + def __init__( + self, uuid_str_or_int: Union[str, int], name: Optional[str] = None + ) -> None: if isinstance(uuid_str_or_int, int): self.uuid_bytes = struct.pack('<H', uuid_str_or_int) else: @@ -172,7 +177,7 @@ class UUID: self.uuid_bytes = bytes(reversed(bytes.fromhex(uuid_str))) self.name = name - def register(self): + def register(self) -> UUID: # Register this object in the class registry, and update the entry's name if # it wasn't set already for uuid in self.UUIDS: @@ -196,22 +201,22 @@ class UUID: raise ValueError('only 2, 4 and 16 bytes are allowed') @classmethod - def from_16_bits(cls, uuid_16, name=None): + def from_16_bits(cls, uuid_16: int, name: Optional[str] = None) -> UUID: return cls.from_bytes(struct.pack('<H', uuid_16), name) @classmethod - def from_32_bits(cls, uuid_32, name=None): + def from_32_bits(cls, uuid_32: int, name: Optional[str] = None) -> UUID: return cls.from_bytes(struct.pack('<I', uuid_32), name) @classmethod - def parse_uuid(cls, uuid_as_bytes, offset): + def parse_uuid(cls, uuid_as_bytes: bytes, offset: int) -> Tuple[int, UUID]: return len(uuid_as_bytes), cls.from_bytes(uuid_as_bytes[offset:]) @classmethod - def parse_uuid_2(cls, uuid_as_bytes, offset): + def parse_uuid_2(cls, uuid_as_bytes: bytes, offset: int) -> Tuple[int, UUID]: return offset + 2, cls.from_bytes(uuid_as_bytes[offset : offset + 2]) - def to_bytes(self, force_128=False): + def to_bytes(self, force_128: bool = False) -> bytes: ''' Serialize UUID in little-endian byte-order ''' @@ -227,7 +232,7 @@ class UUID: else: assert False, "unreachable" - def to_pdu_bytes(self): + def to_pdu_bytes(self) -> bytes: ''' Convert to bytes for use in an ATT PDU. According to Vol 3, Part F - 3.2.1 Attribute Type: @@ -236,11 +241,11 @@ class UUID: ''' return self.to_bytes(force_128=(len(self.uuid_bytes) == 4)) - def to_hex_str(self) -> str: + def to_hex_str(self, separator: str = '') -> str: if len(self.uuid_bytes) == 2 or len(self.uuid_bytes) == 4: return bytes(reversed(self.uuid_bytes)).hex().upper() - return ''.join( + return separator.join( [ bytes(reversed(self.uuid_bytes[12:16])).hex(), bytes(reversed(self.uuid_bytes[10:12])).hex(), @@ -250,10 +255,10 @@ class UUID: ] ).upper() - def __bytes__(self): + def __bytes__(self) -> bytes: return self.to_bytes() - def __eq__(self, other): + def __eq__(self, other: object) -> bool: if isinstance(other, UUID): return self.to_bytes(force_128=True) == other.to_bytes(force_128=True) @@ -262,35 +267,19 @@ class UUID: return False - def __hash__(self): + def __hash__(self) -> int: return hash(self.uuid_bytes) - def __str__(self): + def __str__(self) -> str: + result = self.to_hex_str(separator='-') if len(self.uuid_bytes) == 2: - uuid = struct.unpack('<H', self.uuid_bytes)[0] - result = f'UUID-16:{uuid:04X}' + result = 'UUID-16:' + result elif len(self.uuid_bytes) == 4: - uuid = struct.unpack('<I', self.uuid_bytes)[0] - result = f'UUID-32:{uuid:08X}' - else: - result = '-'.join( - [ - bytes(reversed(self.uuid_bytes[12:16])).hex(), - bytes(reversed(self.uuid_bytes[10:12])).hex(), - bytes(reversed(self.uuid_bytes[8:10])).hex(), - bytes(reversed(self.uuid_bytes[6:8])).hex(), - bytes(reversed(self.uuid_bytes[0:6])).hex(), - ] - ).upper() - + result = 'UUID-32:' + result if self.name is not None: - return result + f' ({self.name})' - + result += f' ({self.name})' return result - def __repr__(self): - return str(self) - # ----------------------------------------------------------------------------- # Common UUID constants @@ -773,7 +762,7 @@ class AdvertisingData: def uuid_list_to_objects(ad_data: bytes, uuid_size: int) -> List[UUID]: uuids = [] offset = 0 - while (uuid_size * (offset + 1)) <= len(ad_data): + while (offset + uuid_size) <= len(ad_data): uuids.append(UUID.from_bytes(ad_data[offset : offset + uuid_size])) offset += uuid_size return uuids diff --git a/bumble/device.py b/bumble/device.py index 25fa099..258a43d 100644 --- a/bumble/device.py +++ b/bumble/device.py @@ -23,17 +23,19 @@ import asyncio import logging from contextlib import asynccontextmanager, AsyncExitStack from dataclasses import dataclass -from typing import Any, ClassVar, Dict, List, Optional, Tuple, Union +from typing import Any, Callable, ClassVar, Dict, List, Optional, Tuple, Type, Union from .colors import color from .att import ATT_CID, ATT_DEFAULT_MTU, ATT_PDU from .gatt import Characteristic, Descriptor, Service from .hci import ( + HCI_AUTHENTICATED_COMBINATION_KEY_GENERATED_FROM_P_192_TYPE, + HCI_AUTHENTICATED_COMBINATION_KEY_GENERATED_FROM_P_256_TYPE, HCI_CENTRAL_ROLE, HCI_COMMAND_STATUS_PENDING, HCI_CONNECTION_REJECTED_DUE_TO_LIMITED_RESOURCES_ERROR, - HCI_DISPLAY_ONLY_IO_CAPABILITY, HCI_DISPLAY_YES_NO_IO_CAPABILITY, + HCI_DISPLAY_ONLY_IO_CAPABILITY, HCI_EXTENDED_INQUIRY_MODE, HCI_GENERAL_INQUIRY_LAP, HCI_INVALID_HCI_COMMAND_PARAMETERS_ERROR, @@ -141,6 +143,7 @@ from .keys import ( KeyStore, PairingKeys, ) +from .pairing import PairingConfig from . import gatt_client from . import gatt_server from . import smp @@ -198,6 +201,7 @@ DEVICE_DEFAULT_L2CAP_COC_MAX_CREDITS = l2cap.L2CAP_LE_CREDIT_BASED_CONN # Classes # ----------------------------------------------------------------------------- + # ----------------------------------------------------------------------------- class Advertisement: address: Address @@ -524,11 +528,15 @@ class Connection(CompositeEventEmitter): transport: int self_address: Address peer_address: Address + peer_resolvable_address: Optional[Address] role: int encryption: int authenticated: bool sc: bool link_key_type: int + gatt_client: gatt_client.Client + pairing_peer_io_capability: Optional[int] + pairing_peer_authentication_requirements: Optional[int] @composite_listener class Listener: @@ -592,10 +600,12 @@ class Connection(CompositeEventEmitter): self.gatt_server = ( device.gatt_server ) # By default, use the device's shared server + self.pairing_peer_io_capability = None + self.pairing_peer_authentication_requirements = None # [Classic only] @classmethod - def incomplete(cls, device, peer_address): + def incomplete(cls, device, peer_address, role): """ Instantiate an incomplete connection (ie. one waiting for a HCI Connection Complete event). @@ -608,28 +618,30 @@ class Connection(CompositeEventEmitter): device.public_address, peer_address, None, - None, + role, None, None, ) # [Classic only] - def complete(self, handle, peer_resolvable_address, role, parameters): + def complete(self, handle, parameters): """ Finish an incomplete connection upon completion. """ assert self.handle is None assert self.transport == BT_BR_EDR_TRANSPORT self.handle = handle - self.peer_resolvable_address = peer_resolvable_address - # Quirk: role might be known before complete - if self.role is None: - self.role = role self.parameters = parameters @property def role_name(self): - return 'CENTRAL' if self.role == BT_CENTRAL_ROLE else 'PERIPHERAL' + if self.role is None: + return 'NOT-SET' + if self.role == BT_CENTRAL_ROLE: + return 'CENTRAL' + if self.role == BT_PERIPHERAL_ROLE: + return 'PERIPHERAL' + return f'UNKNOWN[{self.role}]' @property def is_encrypted(self): @@ -637,7 +649,7 @@ class Connection(CompositeEventEmitter): @property def is_incomplete(self) -> bool: - return self.handle == None + return self.handle is None def send_l2cap_pdu(self, cid, pdu): self.device.send_l2cap_pdu(self.handle, cid, pdu) @@ -750,10 +762,11 @@ class DeviceConfiguration: self.advertising_interval_max = DEVICE_DEFAULT_ADVERTISING_INTERVAL self.le_enabled = True # LE host enable 2nd parameter - self.le_simultaneous_enabled = True + self.le_simultaneous_enabled = False self.classic_enabled = False self.classic_sc_enabled = True self.classic_ssp_enabled = True + self.classic_smp_enabled = True self.classic_accept_any = True self.connectable = True self.discoverable = True @@ -788,6 +801,9 @@ class DeviceConfiguration: self.classic_ssp_enabled = config.get( 'classic_ssp_enabled', self.classic_ssp_enabled ) + self.classic_smp_enabled = config.get( + 'classic_smp_enabled', self.classic_smp_enabled + ) self.classic_accept_any = config.get( 'classic_accept_any', self.classic_accept_any ) @@ -873,12 +889,12 @@ def host_event_handler(function): # List of host event handlers for the Device class. # (we define this list outside the class, because referencing a class in method # decorators is not straightforward) -device_host_event_handlers: list[str] = [] +device_host_event_handlers: List[str] = [] # ----------------------------------------------------------------------------- class Device(CompositeEventEmitter): - # incomplete list of fields. + # Incomplete list of fields. random_address: Address public_address: Address classic_enabled: bool @@ -893,6 +909,7 @@ class Device(CompositeEventEmitter): Address, List[asyncio.Future[Union[Connection, Tuple[Address, int, int]]]] ] advertisement_accumulators: Dict[Address, AdvertisementDataAccumulator] + config: DeviceConfiguration @composite_listener class Listener: @@ -980,9 +997,10 @@ class Device(CompositeEventEmitter): self.connect_own_address_type = None # Use the initial config or a default + config = config or DeviceConfiguration() + self.config = config + self.public_address = Address('00:00:00:00:00:00') - if config is None: - config = DeviceConfiguration() self.name = config.name self.random_address = config.address self.class_of_device = config.class_of_device @@ -990,13 +1008,14 @@ class Device(CompositeEventEmitter): self.advertising_data = config.advertising_data self.advertising_interval_min = config.advertising_interval_min self.advertising_interval_max = config.advertising_interval_max - self.keystore = KeyStore.create_for_device(config) + self.keystore = None 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 + self.classic_ssp_enabled = config.classic_ssp_enabled + self.classic_smp_enabled = config.classic_smp_enabled self.discoverable = config.discoverable self.connectable = config.connectable self.classic_accept_any = config.classic_accept_any @@ -1018,7 +1037,9 @@ class Device(CompositeEventEmitter): descriptors.append(new_descriptor) new_characteristic = Characteristic( uuid=characteristic["uuid"], - properties=characteristic["properties"], + properties=Characteristic.Properties.from_string( + characteristic["properties"] + ), permissions=characteristic["permissions"], descriptors=descriptors, ) @@ -1037,12 +1058,12 @@ class Device(CompositeEventEmitter): self.random_address = address # Setup SMP - self.smp_manager = smp.Manager(self) - self.l2cap_channel_manager.register_fixed_channel(smp.SMP_CID, self.on_smp_pdu) - self.l2cap_channel_manager.register_fixed_channel( - smp.SMP_BR_CID, self.on_smp_pdu + self.smp_manager = smp.Manager( + self, pairing_config_factory=lambda connection: PairingConfig() ) + self.l2cap_channel_manager.register_fixed_channel(smp.SMP_CID, self.on_smp_pdu) + # Register the SDP server with the L2CAP Channel Manager self.sdp_server.register(self.l2cap_channel_manager) @@ -1165,6 +1186,7 @@ class Device(CompositeEventEmitter): # Reset the controller await self.host.reset() + # Try to get the public address from the controller response = await self.send_command(HCI_Read_BD_ADDR_Command()) # type: ignore[call-arg] if response.return_parameters.status == HCI_SUCCESS: logger.debug( @@ -1172,6 +1194,17 @@ class Device(CompositeEventEmitter): ) self.public_address = response.return_parameters.bd_addr + # Instantiate the Key Store (we do this here rather than at __init__ time + # because some Key Store implementations use the public address as a namespace) + if self.keystore is None: + self.keystore = KeyStore.create_for_device(self) + + # Finish setting up SMP based on post-init configurable options + if self.classic_smp_enabled: + self.l2cap_channel_manager.register_fixed_channel( + smp.SMP_BR_CID, self.on_smp_pdu + ) + if self.host.supports_command(HCI_WRITE_LE_HOST_SUPPORT_COMMAND): await self.send_command( HCI_Write_LE_Host_Support_Command( @@ -1219,7 +1252,7 @@ class Device(CompositeEventEmitter): await self.send_command(HCI_LE_Clear_Resolving_List_Command()) # type: ignore[call-arg] resolving_keys = await self.keystore.get_resolving_keys() - for (irk, address) in resolving_keys: + for irk, address in resolving_keys: await self.send_command( HCI_LE_Add_Device_To_Resolving_List_Command( peer_identity_address_type=address.address_type, @@ -1600,7 +1633,7 @@ class Device(CompositeEventEmitter): pending connection. connection_parameters_preferences: (BLE only, ignored for BR/EDR) - * None: use all PHYs with default parameters + * None: use the 1M PHY with default parameters * map: each entry has a PHY as key and a ConnectionParametersPreferences object as value @@ -1669,9 +1702,7 @@ class Device(CompositeEventEmitter): if connection_parameters_preferences is None: if connection_parameters_preferences is None: connection_parameters_preferences = { - HCI_LE_1M_PHY: ConnectionParametersPreferences.default, - HCI_LE_2M_PHY: ConnectionParametersPreferences.default, - HCI_LE_CODED_PHY: ConnectionParametersPreferences.default, + HCI_LE_1M_PHY: ConnectionParametersPreferences.default } self.connect_own_address_type = own_address_type @@ -1793,7 +1824,7 @@ class Device(CompositeEventEmitter): else: # Save pending connection self.pending_connections[peer_address] = Connection.incomplete( - self, peer_address + self, peer_address, BT_CENTRAL_ROLE ) # TODO: allow passing other settings @@ -1930,9 +1961,12 @@ class Device(CompositeEventEmitter): self.on('connection', on_connection) self.on('connection_failure', on_connection_failure) - # Save pending connection + # Save pending connection, with the Peripheral role. + # Even if we requested a role switch in the HCI_Accept_Connection_Request + # command, this connection is still considered Peripheral until an eventual + # role change event. self.pending_connections[peer_address] = Connection.incomplete( - self, peer_address + self, peer_address, BT_PERIPHERAL_ROLE ) try: @@ -2163,13 +2197,23 @@ class Device(CompositeEventEmitter): await self.stop_discovery() @property - def pairing_config_factory(self): + def pairing_config_factory(self) -> Callable[[Connection], PairingConfig]: return self.smp_manager.pairing_config_factory @pairing_config_factory.setter - def pairing_config_factory(self, pairing_config_factory): + def pairing_config_factory( + self, pairing_config_factory: Callable[[Connection], PairingConfig] + ) -> None: self.smp_manager.pairing_config_factory = pairing_config_factory + @property + def smp_session_proxy(self) -> Type[smp.Session]: + return self.smp_manager.session_proxy + + @smp_session_proxy.setter + def smp_session_proxy(self, session_proxy: Type[smp.Session]) -> None: + self.smp_manager.session_proxy = session_proxy + async def pair(self, connection): return await self.smp_manager.pair(connection) @@ -2199,13 +2243,18 @@ class Device(CompositeEventEmitter): if connection.role == BT_PERIPHERAL_ROLE and keys.ltk_peripheral: return keys.ltk_peripheral.value - async def get_link_key(self, address): + async def get_link_key(self, address: Address) -> Optional[bytes]: # Look for the key in the keystore if self.keystore is not None: keys = await self.keystore.get(str(address)) if keys is not None: logger.debug('found keys in the key store') + if keys.link_key is None: + logger.warning('no link key') + return None + return keys.link_key.value + return None # [Classic only] async def authenticate(self, connection): @@ -2409,8 +2458,14 @@ class Device(CompositeEventEmitter): def on_link_key(self, bd_addr, link_key, key_type): # Store the keys in the key store if self.keystore: + authenticated = key_type in ( + HCI_AUTHENTICATED_COMBINATION_KEY_GENERATED_FROM_P_192_TYPE, + HCI_AUTHENTICATED_COMBINATION_KEY_GENERATED_FROM_P_256_TYPE, + ) pairing_keys = PairingKeys() - pairing_keys.link_key = PairingKeys.Key(value=link_key) + pairing_keys.link_key = PairingKeys.Key( + value=link_key, authenticated=authenticated + ) async def store_keys(): try: @@ -2454,25 +2509,24 @@ class Device(CompositeEventEmitter): connection_handle, transport, peer_address, - peer_resolvable_address, role, connection_parameters, ): logger.debug( f'*** Connection: [0x{connection_handle:04X}] ' - f'{peer_address} as {HCI_Constant.role_name(role)}' + f'{peer_address} {"" if role is None else HCI_Constant.role_name(role)}' ) if connection_handle in self.connections: logger.warning( 'new connection reuses the same handle as a previous connection' ) + peer_resolvable_address = None + if transport == BT_BR_EDR_TRANSPORT: # Create a new connection connection = self.pending_connections.pop(peer_address) - connection.complete( - connection_handle, peer_resolvable_address, role, connection_parameters - ) + connection.complete(connection_handle, connection_parameters) self.connections[connection_handle] = connection # Emit an event to notify listeners of the new connection @@ -2584,7 +2638,9 @@ class Device(CompositeEventEmitter): # device configuration is set to accept any incoming connection elif self.classic_accept_any: # Save pending connection - self.pending_connections[bd_addr] = Connection.incomplete(self, bd_addr) + self.pending_connections[bd_addr] = Connection.incomplete( + self, bd_addr, BT_PERIPHERAL_ROLE + ) self.host.send_command_sync( HCI_Accept_Connection_Request_Command( @@ -2675,7 +2731,7 @@ class Device(CompositeEventEmitter): # On Secure Simple Pairing complete, in case: # - Connection isn't already authenticated # - AND we are not the initiator of the authentication - # We must trigger authentication to known if we are truly authenticated + # We must trigger authentication to know if we are truly authenticated if not connection.authenticating and not connection.authenticated: logger.debug( f'*** Trigger Connection Authentication: [0x{connection.handle:04X}] ' @@ -2690,22 +2746,6 @@ class Device(CompositeEventEmitter): # Ask what the pairing config should be for this connection pairing_config = self.pairing_config_factory(connection) - # Map the SMP IO capability to a Classic IO capability - # pylint: disable=line-too-long - io_capability = { - smp.SMP_DISPLAY_ONLY_IO_CAPABILITY: HCI_DISPLAY_ONLY_IO_CAPABILITY, - smp.SMP_DISPLAY_YES_NO_IO_CAPABILITY: HCI_DISPLAY_YES_NO_IO_CAPABILITY, - smp.SMP_KEYBOARD_ONLY_IO_CAPABILITY: HCI_KEYBOARD_ONLY_IO_CAPABILITY, - smp.SMP_NO_INPUT_NO_OUTPUT_IO_CAPABILITY: HCI_NO_INPUT_NO_OUTPUT_IO_CAPABILITY, - smp.SMP_KEYBOARD_DISPLAY_IO_CAPABILITY: HCI_DISPLAY_YES_NO_IO_CAPABILITY, - }.get(pairing_config.delegate.io_capability) - - if io_capability is None: - logger.warning( - f'cannot map IO capability ({pairing_config.delegate.io_capability}' - ) - io_capability = HCI_NO_INPUT_NO_OUTPUT_IO_CAPABILITY - # Compute the authentication requirements authentication_requirements = ( # No Bonding @@ -2724,7 +2764,7 @@ class Device(CompositeEventEmitter): self.host.send_command_sync( HCI_IO_Capability_Request_Reply_Command( bd_addr=connection.peer_address, - io_capability=io_capability, + io_capability=pairing_config.delegate.classic_io_capability, oob_data_present=0x00, # Not present authentication_requirements=authentication_requirements, ) @@ -2733,114 +2773,127 @@ class Device(CompositeEventEmitter): # [Classic only] @host_event_handler @with_connection_from_address - def on_authentication_user_confirmation_request(self, connection, code): + def on_authentication_io_capability_response( + self, connection, io_capability, authentication_requirements + ): + connection.peer_pairing_io_capability = io_capability + connection.peer_pairing_authentication_requirements = ( + authentication_requirements + ) + + # [Classic only] + @host_event_handler + @with_connection_from_address + def on_authentication_user_confirmation_request(self, connection, code) -> None: # Ask what the pairing config should be for this connection pairing_config = self.pairing_config_factory(connection) + io_capability = pairing_config.delegate.classic_io_capability + peer_io_capability = connection.peer_pairing_io_capability + + async def confirm() -> bool: + # Ask the user to confirm the pairing, without display + return await pairing_config.delegate.confirm() + + async def auto_confirm() -> bool: + # Ask the user to auto-confirm the pairing, without display + return await pairing_config.delegate.confirm(auto=True) + + async def display_confirm() -> bool: + # Display the code and ask the user to compare + return await pairing_config.delegate.compare_numbers(code, digits=6) + + async def display_auto_confirm() -> bool: + # Display the code to the user and ask the delegate to auto-confirm + await pairing_config.delegate.display_number(code, digits=6) + return await pairing_config.delegate.confirm(auto=True) + + async def na() -> bool: + assert False, "N/A: unreachable" + + # See Bluetooth spec @ Vol 3, Part C 5.2.2.6 + methods = { + HCI_DISPLAY_ONLY_IO_CAPABILITY: { + HCI_DISPLAY_ONLY_IO_CAPABILITY: display_auto_confirm, + HCI_DISPLAY_YES_NO_IO_CAPABILITY: display_confirm, + HCI_KEYBOARD_ONLY_IO_CAPABILITY: na, + HCI_NO_INPUT_NO_OUTPUT_IO_CAPABILITY: auto_confirm, + }, + HCI_DISPLAY_YES_NO_IO_CAPABILITY: { + HCI_DISPLAY_ONLY_IO_CAPABILITY: display_auto_confirm, + HCI_DISPLAY_YES_NO_IO_CAPABILITY: display_confirm, + HCI_KEYBOARD_ONLY_IO_CAPABILITY: na, + HCI_NO_INPUT_NO_OUTPUT_IO_CAPABILITY: auto_confirm, + }, + HCI_KEYBOARD_ONLY_IO_CAPABILITY: { + HCI_DISPLAY_ONLY_IO_CAPABILITY: na, + HCI_DISPLAY_YES_NO_IO_CAPABILITY: na, + HCI_KEYBOARD_ONLY_IO_CAPABILITY: na, + HCI_NO_INPUT_NO_OUTPUT_IO_CAPABILITY: auto_confirm, + }, + HCI_NO_INPUT_NO_OUTPUT_IO_CAPABILITY: { + HCI_DISPLAY_ONLY_IO_CAPABILITY: confirm, + HCI_DISPLAY_YES_NO_IO_CAPABILITY: confirm, + HCI_KEYBOARD_ONLY_IO_CAPABILITY: auto_confirm, + HCI_NO_INPUT_NO_OUTPUT_IO_CAPABILITY: auto_confirm, + }, + } - can_compare = pairing_config.delegate.io_capability not in ( - smp.SMP_NO_INPUT_NO_OUTPUT_IO_CAPABILITY, - smp.SMP_DISPLAY_ONLY_IO_CAPABILITY, - ) - - # Respond - if can_compare: + method = methods[peer_io_capability][io_capability] - async def compare_numbers(): - numbers_match = await connection.abort_on( - 'disconnection', - pairing_config.delegate.compare_numbers(code, digits=6), - ) - if numbers_match: - await self.host.send_command( - HCI_User_Confirmation_Request_Reply_Command( - bd_addr=connection.peer_address - ) - ) - else: - await self.host.send_command( - HCI_User_Confirmation_Request_Negative_Reply_Command( - bd_addr=connection.peer_address - ) + async def reply() -> None: + if await connection.abort_on('disconnection', method()): + await self.host.send_command( + HCI_User_Confirmation_Request_Reply_Command( # type: ignore[call-arg] + bd_addr=connection.peer_address ) - - asyncio.create_task(compare_numbers()) - else: - - async def confirm(): - confirm = await connection.abort_on( - 'disconnection', pairing_config.delegate.confirm() ) - if confirm: - await self.host.send_command( - HCI_User_Confirmation_Request_Reply_Command( - bd_addr=connection.peer_address - ) - ) - else: - await self.host.send_command( - HCI_User_Confirmation_Request_Negative_Reply_Command( - bd_addr=connection.peer_address - ) + else: + await self.host.send_command( + HCI_User_Confirmation_Request_Negative_Reply_Command( # type: ignore[call-arg] + bd_addr=connection.peer_address ) + ) - asyncio.create_task(confirm()) + AsyncRunner.spawn(reply()) # [Classic only] @host_event_handler @with_connection_from_address - def on_authentication_user_passkey_request(self, connection): + def on_authentication_user_passkey_request(self, connection) -> None: # 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 - if can_input: - - async def get_number(): - number = await connection.abort_on( - 'disconnection', pairing_config.delegate.get_number() - ) - if number is not None: - await self.host.send_command( - HCI_User_Passkey_Request_Reply_Command( - bd_addr=connection.peer_address, numeric_value=number - ) + async def reply() -> None: + number = await connection.abort_on( + 'disconnection', pairing_config.delegate.get_number() + ) + if number is not None: + await self.host.send_command( + HCI_User_Passkey_Request_Reply_Command( # type: ignore[call-arg] + bd_addr=connection.peer_address, numeric_value=number ) - else: - await self.host.send_command( - HCI_User_Passkey_Request_Negative_Reply_Command( - bd_addr=connection.peer_address - ) + ) + else: + await self.host.send_command( + HCI_User_Passkey_Request_Negative_Reply_Command( # type: ignore[call-arg] + bd_addr=connection.peer_address ) - - asyncio.create_task(get_number()) - else: - self.host.send_command_sync( - HCI_User_Passkey_Request_Negative_Reply_Command( - bd_addr=connection.peer_address ) - ) + + AsyncRunner.spawn(reply()) # [Classic only] @host_event_handler @with_connection_from_address def on_pin_code_request(self, connection): - # classic legacy pairing + # Classic legacy pairing # Ask what the pairing config should be for this connection pairing_config = self.pairing_config_factory(connection) + io_capability = pairing_config.delegate.classic_io_capability - 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: - + # Respond + if io_capability == HCI_KEYBOARD_ONLY_IO_CAPABILITY: + # Ask the user to enter a string async def get_pin_code(): pin_code = await connection.abort_on( 'disconnection', pairing_config.delegate.get_string(16) @@ -2880,6 +2933,7 @@ class Device(CompositeEventEmitter): # Ask what the pairing config should be for this connection pairing_config = self.pairing_config_factory(connection) + # Show the passkey to the user connection.abort_on( 'disconnection', pairing_config.delegate.display_number(passkey) ) @@ -3031,18 +3085,15 @@ class Device(CompositeEventEmitter): connection.emit('role_change_failure', error) self.emit('role_change_failure', address, error) - @with_connection_from_handle - def on_pairing_start(self, connection): + def on_pairing_start(self, connection: Connection) -> None: connection.emit('pairing_start') - @with_connection_from_handle - def on_pairing(self, connection, keys, sc): + def on_pairing(self, connection: Connection, keys: PairingKeys, sc: bool) -> None: connection.sc = sc connection.authenticated = True connection.emit('pairing', keys) - @with_connection_from_handle - def on_pairing_failure(self, connection, reason): + def on_pairing_failure(self, connection: Connection, reason: int) -> None: connection.emit('pairing_failure', reason) @with_connection_from_handle diff --git a/bumble/gap.py b/bumble/gap.py index a4d5077..29df89f 100644 --- a/bumble/gap.py +++ b/bumble/gap.py @@ -41,14 +41,14 @@ class GenericAccessService(Service): def __init__(self, device_name, appearance=(0, 0)): device_name_characteristic = Characteristic( GATT_DEVICE_NAME_CHARACTERISTIC, - Characteristic.READ, + Characteristic.Properties.READ, Characteristic.READABLE, device_name.encode('utf-8')[:248], ) appearance_characteristic = Characteristic( GATT_APPEARANCE_CHARACTERISTIC, - Characteristic.READ, + Characteristic.Properties.READ, Characteristic.READABLE, struct.pack('<H', (appearance[0] << 6) | appearance[1]), ) diff --git a/bumble/gatt.py b/bumble/gatt.py index 7aa065c..ea2b690 100644 --- a/bumble/gatt.py +++ b/bumble/gatt.py @@ -28,7 +28,7 @@ import enum import functools import logging import struct -from typing import Optional, Sequence +from typing import Optional, Sequence, List from .colors import color from .core import UUID, get_dict_key_by_value @@ -205,8 +205,16 @@ class Service(Attribute): ''' uuid: UUID + characteristics: List[Characteristic] + included_services: List[Service] - def __init__(self, uuid, characteristics: list[Characteristic], primary=True): + def __init__( + self, + uuid, + characteristics: List[Characteristic], + primary=True, + included_services: List[Service] = [], + ): # Convert the uuid to a UUID object if it isn't already if isinstance(uuid, str): uuid = UUID(uuid) @@ -219,7 +227,7 @@ class Service(Attribute): uuid.to_pdu_bytes(), ) self.uuid = uuid - # self.included_services = [] + self.included_services = included_services[:] self.characteristics = characteristics[:] self.primary = primary @@ -247,75 +255,107 @@ class TemplateService(Service): to expose their UUID as a class property ''' - UUID = None + UUID: Optional[UUID] = None def __init__(self, characteristics, primary=True): super().__init__(self.UUID, characteristics, primary) # ----------------------------------------------------------------------------- -class Characteristic(Attribute): +class IncludedServiceDeclaration(Attribute): ''' - See Vol 3, Part G - 3.3 CHARACTERISTIC DEFINITION + See Vol 3, Part G - 3.2 INCLUDE DEFINITION ''' - # Property flags - BROADCAST = 0x01 - READ = 0x02 - WRITE_WITHOUT_RESPONSE = 0x04 - WRITE = 0x08 - NOTIFY = 0x10 - INDICATE = 0x20 - AUTHENTICATED_SIGNED_WRITES = 0x40 - EXTENDED_PROPERTIES = 0x80 - - PROPERTY_NAMES = { - BROADCAST: 'BROADCAST', - READ: 'READ', - WRITE_WITHOUT_RESPONSE: 'WRITE_WITHOUT_RESPONSE', - WRITE: 'WRITE', - NOTIFY: 'NOTIFY', - INDICATE: 'INDICATE', - AUTHENTICATED_SIGNED_WRITES: 'AUTHENTICATED_SIGNED_WRITES', - EXTENDED_PROPERTIES: 'EXTENDED_PROPERTIES', - } - - @staticmethod - def property_name(property_int): - return Characteristic.PROPERTY_NAMES.get(property_int, '') - - @staticmethod - def properties_as_string(properties): - return ','.join( - [ - Characteristic.property_name(p) - for p in Characteristic.PROPERTY_NAMES - if properties & p - ] + service: Service + + def __init__(self, service): + declaration_bytes = struct.pack( + '<HH2s', service.handle, service.end_group_handle, service.uuid.to_bytes() + ) + super().__init__( + GATT_INCLUDE_ATTRIBUTE_TYPE, Attribute.READABLE, declaration_bytes ) + self.service = service - @staticmethod - def string_to_properties(properties_str: str): - return functools.reduce( - lambda x, y: x | get_dict_key_by_value(Characteristic.PROPERTY_NAMES, y), - properties_str.split(","), - 0, + def __str__(self): + return ( + f'IncludedServiceDefinition(handle=0x{self.handle:04X}, ' + f'group_starting_handle=0x{self.service.handle:04X}, ' + f'group_ending_handle=0x{self.service.end_group_handle:04X}, ' + f'uuid={self.service.uuid}, ' + f'{self.service.properties!s})' ) + +# ----------------------------------------------------------------------------- +class Characteristic(Attribute): + ''' + See Vol 3, Part G - 3.3 CHARACTERISTIC DEFINITION + ''' + + uuid: UUID + properties: Characteristic.Properties + + class Properties(enum.IntFlag): + """Property flags""" + + BROADCAST = 0x01 + READ = 0x02 + WRITE_WITHOUT_RESPONSE = 0x04 + WRITE = 0x08 + NOTIFY = 0x10 + INDICATE = 0x20 + AUTHENTICATED_SIGNED_WRITES = 0x40 + EXTENDED_PROPERTIES = 0x80 + + @staticmethod + def from_string(properties_str: str) -> Characteristic.Properties: + property_names: List[str] = [] + for property in Characteristic.Properties: + if property.name is None: + raise TypeError() + property_names.append(property.name) + + def string_to_property(property_string) -> Characteristic.Properties: + for property in zip(Characteristic.Properties, property_names): + if property_string == property[1]: + return property[0] + raise TypeError(f"Unable to convert {property_string} to Property") + + try: + return functools.reduce( + lambda x, y: x | string_to_property(y), + properties_str.split(","), + Characteristic.Properties(0), + ) + except TypeError: + raise TypeError( + f"Characteristic.Properties::from_string() error:\nExpected a string containing any of the keys, separated by commas: {','.join(property_names)}\nGot: {properties_str}" + ) + + # For backwards compatibility these are defined here + # For new code, please use Characteristic.Properties.X + BROADCAST = Properties.BROADCAST + READ = Properties.READ + WRITE_WITHOUT_RESPONSE = Properties.WRITE_WITHOUT_RESPONSE + WRITE = Properties.WRITE + NOTIFY = Properties.NOTIFY + INDICATE = Properties.INDICATE + AUTHENTICATED_SIGNED_WRITES = Properties.AUTHENTICATED_SIGNED_WRITES + EXTENDED_PROPERTIES = Properties.EXTENDED_PROPERTIES + def __init__( self, uuid, - properties, + properties: Characteristic.Properties, permissions, value=b'', descriptors: Sequence[Descriptor] = (), ): super().__init__(uuid, permissions, value) self.uuid = self.type - if isinstance(properties, str): - self.properties = Characteristic.string_to_properties(properties) - else: - self.properties = properties + self.properties = properties self.descriptors = descriptors def get_descriptor(self, descriptor_type): @@ -325,12 +365,15 @@ class Characteristic(Attribute): return None + def has_properties(self, properties: Characteristic.Properties) -> bool: + return self.properties & properties == properties + def __str__(self): return ( f'Characteristic(handle=0x{self.handle:04X}, ' f'end=0x{self.end_group_handle:04X}, ' f'uuid={self.uuid}, ' - f'properties={Characteristic.properties_as_string(self.properties)})' + f'{self.properties!s})' ) @@ -340,6 +383,8 @@ class CharacteristicDeclaration(Attribute): See Vol 3, Part G - 3.3.1 CHARACTERISTIC DECLARATION ''' + characteristic: Characteristic + def __init__(self, characteristic, value_handle): declaration_bytes = ( struct.pack('<BH', characteristic.properties, value_handle) @@ -355,8 +400,8 @@ class CharacteristicDeclaration(Attribute): return ( f'CharacteristicDeclaration(handle=0x{self.handle:04X}, ' f'value_handle=0x{self.value_handle:04X}, ' - f'uuid={self.characteristic.uuid}, properties=' - f'{Characteristic.properties_as_string(self.characteristic.properties)})' + f'uuid={self.characteristic.uuid}, ' + f'{self.characteristic.properties!s})' ) diff --git a/bumble/gatt_client.py b/bumble/gatt_client.py index 25add18..a33039e 100644 --- a/bumble/gatt_client.py +++ b/bumble/gatt_client.py @@ -27,7 +27,8 @@ from __future__ import annotations import asyncio import logging import struct -from typing import List, Optional +from datetime import datetime +from typing import List, Optional, Dict, Tuple, Callable, Union, Any from pyee import EventEmitter @@ -62,7 +63,7 @@ from .gatt import ( GATT_PRIMARY_SERVICE_ATTRIBUTE_TYPE, GATT_REQUEST_TIMEOUT, GATT_SECONDARY_SERVICE_ATTRIBUTE_TYPE, - Service, + GATT_INCLUDE_ATTRIBUTE_TYPE, Characteristic, ClientCharacteristicConfigurationBits, ) @@ -109,6 +110,7 @@ class AttributeProxy(EventEmitter): class ServiceProxy(AttributeProxy): uuid: UUID characteristics: List[CharacteristicProxy] + included_services: List[ServiceProxy] @staticmethod def from_client(service_class, client, service_uuid): @@ -139,12 +141,21 @@ class ServiceProxy(AttributeProxy): class CharacteristicProxy(AttributeProxy): + properties: Characteristic.Properties descriptors: List[DescriptorProxy] + subscribers: Dict[Any, Callable] - def __init__(self, client, handle, end_group_handle, uuid, properties): + def __init__( + self, + client, + handle, + end_group_handle, + uuid, + properties: int, + ): super().__init__(client, handle, end_group_handle, uuid) self.uuid = uuid - self.properties = properties + self.properties = Characteristic.Properties(properties) self.descriptors = [] self.descriptors_discovered = False self.subscribers = {} # Map from subscriber to proxy subscriber @@ -159,7 +170,9 @@ class CharacteristicProxy(AttributeProxy): async def discover_descriptors(self): return await self.client.discover_descriptors(self) - async def subscribe(self, subscriber=None, prefer_notify=True): + async def subscribe( + self, subscriber: Optional[Callable] = None, prefer_notify=True + ): if subscriber is not None: if subscriber in self.subscribers: # We already have a proxy subscriber @@ -186,7 +199,7 @@ class CharacteristicProxy(AttributeProxy): return ( f'Characteristic(handle=0x{self.handle:04X}, ' f'uuid={self.uuid}, ' - f'properties={Characteristic.properties_as_string(self.properties)})' + f'{self.properties!s})' ) @@ -213,6 +226,7 @@ class ProfileServiceProxy: # ----------------------------------------------------------------------------- class Client: services: List[ServiceProxy] + cached_values: Dict[int, Tuple[datetime, bytes]] def __init__(self, connection): self.connection = connection @@ -225,6 +239,7 @@ class Client: ) # Notification subscribers, by attribute handle self.indication_subscribers = {} # Indication subscribers, by attribute handle self.services = [] + self.cached_values = {} def send_gatt_pdu(self, pdu): self.connection.send_l2cap_pdu(ATT_CID, pdu) @@ -309,6 +324,35 @@ class Client: if c.uuid == uuid ] + def get_attribute_grouping( + self, attribute_handle: int + ) -> Optional[ + Union[ + ServiceProxy, + Tuple[ServiceProxy, CharacteristicProxy], + Tuple[ServiceProxy, CharacteristicProxy, DescriptorProxy], + ] + ]: + """ + Get the attribute(s) associated with an attribute handle + """ + for service in self.services: + if service.handle == attribute_handle: + return service + if service.handle <= attribute_handle <= service.end_group_handle: + for characteristic in service.characteristics: + if characteristic.handle == attribute_handle: + return (service, characteristic) + if ( + characteristic.handle + <= attribute_handle + <= characteristic.end_group_handle + ): + for descriptor in characteristic.descriptors: + if descriptor.handle == attribute_handle: + return (service, characteristic, descriptor) + return None + def on_service_discovered(self, service): '''Add a service to the service list if it wasn't already there''' already_known = False @@ -460,12 +504,69 @@ class Client: return services - async def discover_included_services(self, _service): + async def discover_included_services( + self, service: ServiceProxy + ) -> List[ServiceProxy]: ''' See Vol 3, Part G - 4.5.1 Find Included Services ''' - # TODO - return [] + + starting_handle = service.handle + ending_handle = service.end_group_handle + + included_services: List[ServiceProxy] = [] + while starting_handle <= ending_handle: + response = await self.send_request( + ATT_Read_By_Type_Request( + starting_handle=starting_handle, + ending_handle=ending_handle, + attribute_type=GATT_INCLUDE_ATTRIBUTE_TYPE, + ) + ) + if response is None: + # TODO raise appropriate exception + return [] + + # Check if we reached the end of the iteration + if response.op_code == ATT_ERROR_RESPONSE: + if response.error_code != ATT_ATTRIBUTE_NOT_FOUND_ERROR: + # Unexpected end + logger.warning( + '!!! unexpected error while discovering included services: ' + f'{HCI_Constant.error_name(response.error_code)}' + ) + raise ATT_Error( + error_code=response.error_code, + message='Unexpected error while discovering included services', + ) + break + + # Stop if for some reason the list was empty + if not response.attributes: + break + + # Process all included services returned in this iteration + for attribute_handle, attribute_value in response.attributes: + if attribute_handle < starting_handle: + # Something's not right + logger.warning(f'bogus handle value: {attribute_handle}') + return [] + + group_starting_handle, group_ending_handle = struct.unpack_from( + '<HH', attribute_value + ) + service_uuid = UUID.from_bytes(attribute_value[4:]) + included_service = ServiceProxy( + self, group_starting_handle, group_ending_handle, service_uuid, True + ) + + included_services.append(included_service) + + # Move on to the next included services + starting_handle = response.attributes[-1][0] + 1 + + service.included_services = included_services + return included_services async def discover_characteristics( self, uuids, service: Optional[ServiceProxy] @@ -678,8 +779,8 @@ class Client: return if ( - characteristic.properties & Characteristic.NOTIFY - and characteristic.properties & Characteristic.INDICATE + characteristic.properties & Characteristic.Properties.NOTIFY + and characteristic.properties & Characteristic.Properties.INDICATE ): if prefer_notify: bits = ClientCharacteristicConfigurationBits.NOTIFICATION @@ -687,10 +788,10 @@ class Client: else: bits = ClientCharacteristicConfigurationBits.INDICATION subscribers = self.indication_subscribers - elif characteristic.properties & Characteristic.NOTIFY: + elif characteristic.properties & Characteristic.Properties.NOTIFY: bits = ClientCharacteristicConfigurationBits.NOTIFICATION subscribers = self.notification_subscribers - elif characteristic.properties & Characteristic.INDICATE: + elif characteristic.properties & Characteristic.Properties.INDICATE: bits = ClientCharacteristicConfigurationBits.INDICATION subscribers = self.indication_subscribers else: @@ -800,6 +901,7 @@ class Client: offset += len(part) + self.cache_value(attribute_handle, attribute_value) # Return the value as bytes return attribute_value @@ -934,6 +1036,8 @@ class Client: ) if not subscribers: logger.warning('!!! received notification with no subscriber') + + self.cache_value(notification.attribute_handle, notification.attribute_value) for subscriber in subscribers: if callable(subscriber): subscriber(notification.attribute_value) @@ -945,6 +1049,8 @@ class Client: subscribers = self.indication_subscribers.get(indication.attribute_handle, []) if not subscribers: logger.warning('!!! received indication with no subscriber') + + self.cache_value(indication.attribute_handle, indication.attribute_value) for subscriber in subscribers: if callable(subscriber): subscriber(indication.attribute_value) @@ -953,3 +1059,9 @@ class Client: # Confirm that we received the indication self.send_confirmation(ATT_Handle_Value_Confirmation()) + + def cache_value(self, attribute_handle: int, value: bytes): + self.cached_values[attribute_handle] = ( + datetime.now(), + value, + ) diff --git a/bumble/gatt_server.py b/bumble/gatt_server.py index 3a5953a..3624905 100644 --- a/bumble/gatt_server.py +++ b/bumble/gatt_server.py @@ -27,7 +27,7 @@ import asyncio import logging from collections import defaultdict import struct -from typing import List, Tuple, Optional +from typing import List, Tuple, Optional, TypeVar, Type from pyee import EventEmitter from .colors import color @@ -68,6 +68,7 @@ from .gatt import ( Characteristic, CharacteristicDeclaration, CharacteristicValue, + IncludedServiceDeclaration, Descriptor, Service, ) @@ -94,6 +95,7 @@ class Server(EventEmitter): def __init__(self, device): super().__init__() self.device = device + self.services = [] self.attributes = [] # Attributes, ordered by increasing handle values self.attributes_by_handle = {} # Map for fast attribute access by handle self.max_mtu = ( @@ -135,6 +137,21 @@ class Server(EventEmitter): return attribute return None + AttributeGroupType = TypeVar('AttributeGroupType', Service, Characteristic) + + def get_attribute_group( + self, handle: int, group_type: Type[AttributeGroupType] + ) -> Optional[AttributeGroupType]: + return next( + ( + attribute + for attribute in self.attributes + if isinstance(attribute, group_type) + and attribute.handle <= handle <= attribute.end_group_handle + ), + None, + ) + def get_service_attribute(self, service_uuid: UUID) -> Optional[Service]: return next( ( @@ -207,7 +224,14 @@ class Server(EventEmitter): # Add the service attribute to the DB self.add_attribute(service) - # TODO: add included services + # Add all included service + for included_service in service.included_services: + # Not registered yet, register the included service first. + if included_service not in self.services: + self.add_service(included_service) + # TODO: Handle circular service reference + include_declaration = IncludedServiceDeclaration(included_service) + self.add_attribute(include_declaration) # Add all characteristics for characteristic in service.characteristics: @@ -228,7 +252,10 @@ class Server(EventEmitter): # unless there is one already if ( characteristic.properties - & (Characteristic.NOTIFY | Characteristic.INDICATE) + & ( + Characteristic.Properties.NOTIFY + | Characteristic.Properties.INDICATE + ) and characteristic.get_descriptor( GATT_CLIENT_CHARACTERISTIC_CONFIGURATION_DESCRIPTOR ) @@ -256,6 +283,7 @@ class Server(EventEmitter): # Update the service group end service.end_group_handle = self.attributes[-1].handle + self.services.append(service) def add_services(self, services): for service in services: diff --git a/bumble/host.py b/bumble/host.py index 9e05c8c..afde2ee 100644 --- a/bumble/host.py +++ b/bumble/host.py @@ -94,10 +94,9 @@ HOST_HC_TOTAL_NUM_ACL_DATA_PACKETS = 1 # ----------------------------------------------------------------------------- class Connection: - def __init__(self, host, handle, role, peer_address, transport): + def __init__(self, host, handle, peer_address, transport): self.host = host self.handle = handle - self.role = role self.peer_address = peer_address self.assembler = HCI_AclDataPacketAssembler(self.on_acl_pdu) self.transport = transport @@ -396,8 +395,8 @@ class Host(AbortableEventEmitter): def supports_command(self, command): # Find the support flag position for this command - for (octet, flags) in enumerate(HCI_SUPPORTED_COMMANDS_FLAGS): - for (flag_position, value) in enumerate(flags): + for octet, flags in enumerate(HCI_SUPPORTED_COMMANDS_FLAGS): + for flag_position, value in enumerate(flags): if value == command: # Check if the flag is set if octet < len(self.local_supported_commands) and flag_position < 8: @@ -410,7 +409,7 @@ class Host(AbortableEventEmitter): @property def supported_commands(self): commands = [] - for (octet, flags) in enumerate(self.local_supported_commands): + for octet, flags in enumerate(self.local_supported_commands): if octet < len(HCI_SUPPORTED_COMMANDS_FLAGS): for flag in range(8): if flags & (1 << flag) != 0: @@ -534,7 +533,7 @@ class Host(AbortableEventEmitter): if event.status == HCI_SUCCESS: # Create/update the connection logger.debug( - f'### CONNECTION: [0x{event.connection_handle:04X}] ' + f'### LE CONNECTION: [0x{event.connection_handle:04X}] ' f'{event.peer_address} as {HCI_Constant.role_name(event.role)}' ) @@ -543,7 +542,6 @@ class Host(AbortableEventEmitter): connection = Connection( self, event.connection_handle, - event.role, event.peer_address, BT_LE_TRANSPORT, ) @@ -560,7 +558,6 @@ class Host(AbortableEventEmitter): event.connection_handle, BT_LE_TRANSPORT, event.peer_address, - None, event.role, connection_parameters, ) @@ -589,7 +586,6 @@ class Host(AbortableEventEmitter): connection = Connection( self, event.connection_handle, - BT_CENTRAL_ROLE, event.bd_addr, BT_BR_EDR_TRANSPORT, ) @@ -602,7 +598,6 @@ class Host(AbortableEventEmitter): BT_BR_EDR_TRANSPORT, event.bd_addr, None, - BT_CENTRAL_ROLE, None, ) else: @@ -622,8 +617,7 @@ class Host(AbortableEventEmitter): if event.status == HCI_SUCCESS: logger.debug( f'### DISCONNECTION: [0x{event.connection_handle:04X}] ' - f'{connection.peer_address} as ' - f'{HCI_Constant.role_name(connection.role)}, ' + f'{connection.peer_address} ' f'reason={event.reason}' ) del self.connections[event.connection_handle] @@ -739,10 +733,6 @@ class Host(AbortableEventEmitter): f'role change for {event.bd_addr}: ' f'{HCI_Constant.role_name(event.new_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( @@ -849,7 +839,12 @@ class Host(AbortableEventEmitter): self.emit('authentication_io_capability_request', event.bd_addr) def on_hci_io_capability_response_event(self, event): - pass + self.emit( + 'authentication_io_capability_response', + event.bd_addr, + event.io_capability, + event.authentication_requirements, + ) def on_hci_user_confirmation_request_event(self, event): self.emit( diff --git a/bumble/keys.py b/bumble/keys.py index 7fed660..a30e753 100644 --- a/bumble/keys.py +++ b/bumble/keys.py @@ -20,15 +20,19 @@ # ----------------------------------------------------------------------------- # Imports # ----------------------------------------------------------------------------- +from __future__ import annotations import asyncio import logging import os import json -from typing import Optional +from typing import TYPE_CHECKING, Dict, List, Optional, Tuple from .colors import color from .hci import Address +if TYPE_CHECKING: + from .device import Device + # ----------------------------------------------------------------------------- # Logging @@ -135,19 +139,19 @@ class PairingKeys: # ----------------------------------------------------------------------------- class KeyStore: - async def delete(self, name): + async def delete(self, name: str): pass - async def update(self, name, keys): + async def update(self, name: str, keys: PairingKeys) -> None: pass - async def get(self, _name): - return PairingKeys() + async def get(self, _name: str) -> Optional[PairingKeys]: + return None - async def get_all(self): + async def get_all(self) -> List[Tuple[str, PairingKeys]]: return [] - async def delete_all(self): + async def delete_all(self) -> None: all_keys = await self.get_all() await asyncio.gather(*(self.delete(name) for (name, _) in all_keys)) @@ -173,15 +177,15 @@ class KeyStore: separator = '\n' @staticmethod - def create_for_device(device_config): - if device_config.keystore is None: - return None + def create_for_device(device: Device) -> KeyStore: + if device.config.keystore is None: + return MemoryKeyStore() - keystore_type = device_config.keystore.split(':', 1)[0] + keystore_type = device.config.keystore.split(':', 1)[0] if keystore_type == 'JsonKeyStore': - return JsonKeyStore.from_device_config(device_config) + return JsonKeyStore.from_device(device) - return None + return MemoryKeyStore() # ----------------------------------------------------------------------------- @@ -204,7 +208,9 @@ class JsonKeyStore(KeyStore): self.directory_name = os.path.join( appdirs.user_data_dir(self.APP_NAME, self.APP_AUTHOR), self.KEYS_DIR ) - json_filename = f'{self.namespace}.json'.lower().replace(':', '-') + json_filename = ( + f'{self.namespace}.json'.lower().replace(':', '-').replace('/p', '-p') + ) self.filename = os.path.join(self.directory_name, json_filename) else: self.filename = filename @@ -213,9 +219,19 @@ class JsonKeyStore(KeyStore): logger.debug(f'JSON keystore: {self.filename}') @staticmethod - def from_device_config(device_config): - params = device_config.keystore.split(':', 1)[1:] - namespace = str(device_config.address) + def from_device(device: Device) -> Optional[JsonKeyStore]: + if not device.config.keystore: + return None + + params = device.config.keystore.split(':', 1)[1:] + + # Use a namespace based on the device address + if device.public_address not in (Address.ANY, Address.ANY_RANDOM): + namespace = str(device.public_address) + elif device.random_address != Address.ANY_RANDOM: + namespace = str(device.random_address) + else: + namespace = JsonKeyStore.DEFAULT_NAMESPACE if params: filename = params[0] else: @@ -241,7 +257,7 @@ class JsonKeyStore(KeyStore): json.dump(db, output, sort_keys=True, indent=4) # Atomically replace the previous file - os.rename(temp_filename, self.filename) + os.replace(temp_filename, self.filename) async def delete(self, name: str) -> None: db = await self.load() @@ -257,7 +273,7 @@ class JsonKeyStore(KeyStore): db = await self.load() namespace = db.setdefault(self.namespace, {}) - namespace[name] = keys.to_dict() + namespace.setdefault(name, {}).update(keys.to_dict()) await self.save(db) @@ -291,3 +307,24 @@ class JsonKeyStore(KeyStore): return None return PairingKeys.from_dict(keys) + + +# ----------------------------------------------------------------------------- +class MemoryKeyStore(KeyStore): + all_keys: Dict[str, PairingKeys] + + def __init__(self) -> None: + self.all_keys = {} + + async def delete(self, name: str) -> None: + if name in self.all_keys: + del self.all_keys[name] + + async def update(self, name: str, keys: PairingKeys) -> None: + self.all_keys[name] = keys + + async def get(self, name: str) -> Optional[PairingKeys]: + return self.all_keys.get(name) + + async def get_all(self) -> List[Tuple[str, PairingKeys]]: + return list(self.all_keys.items()) diff --git a/bumble/pairing.py b/bumble/pairing.py new file mode 100644 index 0000000..ab356ee --- /dev/null +++ b/bumble/pairing.py @@ -0,0 +1,188 @@ +# 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 enum +from typing import Optional, Tuple + +from .hci import ( + HCI_NO_INPUT_NO_OUTPUT_IO_CAPABILITY, + HCI_DISPLAY_ONLY_IO_CAPABILITY, + HCI_DISPLAY_YES_NO_IO_CAPABILITY, + HCI_KEYBOARD_ONLY_IO_CAPABILITY, +) +from .smp import ( + SMP_NO_INPUT_NO_OUTPUT_IO_CAPABILITY, + SMP_KEYBOARD_ONLY_IO_CAPABILITY, + SMP_DISPLAY_ONLY_IO_CAPABILITY, + SMP_DISPLAY_YES_NO_IO_CAPABILITY, + SMP_KEYBOARD_DISPLAY_IO_CAPABILITY, + SMP_ENC_KEY_DISTRIBUTION_FLAG, + SMP_ID_KEY_DISTRIBUTION_FLAG, + SMP_SIGN_KEY_DISTRIBUTION_FLAG, + SMP_LINK_KEY_DISTRIBUTION_FLAG, +) + + +# ----------------------------------------------------------------------------- +class PairingDelegate: + """Abstract base class for Pairing Delegates.""" + + # I/O Capabilities. + # These are defined abstractly, and can be mapped to specific Classic pairing + # and/or SMP constants. + class IoCapability(enum.IntEnum): + NO_OUTPUT_NO_INPUT = SMP_NO_INPUT_NO_OUTPUT_IO_CAPABILITY + KEYBOARD_INPUT_ONLY = SMP_KEYBOARD_ONLY_IO_CAPABILITY + DISPLAY_OUTPUT_ONLY = SMP_DISPLAY_ONLY_IO_CAPABILITY + DISPLAY_OUTPUT_AND_YES_NO_INPUT = SMP_DISPLAY_YES_NO_IO_CAPABILITY + DISPLAY_OUTPUT_AND_KEYBOARD_INPUT = SMP_KEYBOARD_DISPLAY_IO_CAPABILITY + + # Direct names for backward compatibility. + NO_OUTPUT_NO_INPUT = IoCapability.NO_OUTPUT_NO_INPUT + KEYBOARD_INPUT_ONLY = IoCapability.KEYBOARD_INPUT_ONLY + DISPLAY_OUTPUT_ONLY = IoCapability.DISPLAY_OUTPUT_ONLY + DISPLAY_OUTPUT_AND_YES_NO_INPUT = IoCapability.DISPLAY_OUTPUT_AND_YES_NO_INPUT + DISPLAY_OUTPUT_AND_KEYBOARD_INPUT = IoCapability.DISPLAY_OUTPUT_AND_KEYBOARD_INPUT + + # Key Distribution [LE only] + class KeyDistribution(enum.IntFlag): + DISTRIBUTE_ENCRYPTION_KEY = SMP_ENC_KEY_DISTRIBUTION_FLAG + DISTRIBUTE_IDENTITY_KEY = SMP_ID_KEY_DISTRIBUTION_FLAG + DISTRIBUTE_SIGNING_KEY = SMP_SIGN_KEY_DISTRIBUTION_FLAG + DISTRIBUTE_LINK_KEY = SMP_LINK_KEY_DISTRIBUTION_FLAG + + DEFAULT_KEY_DISTRIBUTION: KeyDistribution = ( + KeyDistribution.DISTRIBUTE_ENCRYPTION_KEY + | KeyDistribution.DISTRIBUTE_IDENTITY_KEY + ) + + # Default mapping from abstract to Classic I/O capabilities. + # Subclasses may override this if they prefer a different mapping. + CLASSIC_IO_CAPABILITIES_MAP = { + NO_OUTPUT_NO_INPUT: HCI_NO_INPUT_NO_OUTPUT_IO_CAPABILITY, + KEYBOARD_INPUT_ONLY: HCI_KEYBOARD_ONLY_IO_CAPABILITY, + DISPLAY_OUTPUT_ONLY: HCI_DISPLAY_ONLY_IO_CAPABILITY, + DISPLAY_OUTPUT_AND_YES_NO_INPUT: HCI_DISPLAY_YES_NO_IO_CAPABILITY, + DISPLAY_OUTPUT_AND_KEYBOARD_INPUT: HCI_DISPLAY_YES_NO_IO_CAPABILITY, + } + + io_capability: IoCapability + local_initiator_key_distribution: KeyDistribution + local_responder_key_distribution: KeyDistribution + + def __init__( + self, + io_capability: IoCapability = NO_OUTPUT_NO_INPUT, + local_initiator_key_distribution: KeyDistribution = DEFAULT_KEY_DISTRIBUTION, + local_responder_key_distribution: KeyDistribution = DEFAULT_KEY_DISTRIBUTION, + ) -> None: + self.io_capability = io_capability + self.local_initiator_key_distribution = local_initiator_key_distribution + self.local_responder_key_distribution = local_responder_key_distribution + + @property + def classic_io_capability(self) -> int: + """Map the abstract I/O capability to a Classic constant.""" + + # pylint: disable=line-too-long + return self.CLASSIC_IO_CAPABILITIES_MAP.get( + self.io_capability, HCI_NO_INPUT_NO_OUTPUT_IO_CAPABILITY + ) + + @property + def smp_io_capability(self) -> int: + """Map the abstract I/O capability to an SMP constant.""" + + # This is just a 1-1 direct mapping + return self.io_capability + + async def accept(self) -> bool: + """Accept or reject a Pairing request.""" + return True + + async def confirm(self, auto: bool = False) -> bool: + """ + Respond yes or no to a Pairing confirmation question. + The `auto` parameter stands for automatic confirmation. + """ + return True + + # pylint: disable-next=unused-argument + async def compare_numbers(self, number: int, digits: int) -> bool: + """Compare two numbers.""" + return True + + async def get_number(self) -> Optional[int]: + """ + Return 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: int) -> Optional[str]: + """ + Return 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: + """Display a number.""" + + # [LE only] + async def key_distribution_response( + self, peer_initiator_key_distribution: int, peer_responder_key_distribution: int + ) -> Tuple[int, int]: + """ + Return the key distribution response in an SMP protocol context. + + NOTE: since it is only used by the SMP protocol, this method's input and output + are directly as integers, using the SMP constants, rather than the abstract + KeyDistribution enums. + """ + return ( + int( + peer_initiator_key_distribution & self.local_initiator_key_distribution + ), + int( + peer_responder_key_distribution & self.local_responder_key_distribution + ), + ) + + +# ----------------------------------------------------------------------------- +class PairingConfig: + """Configuration for the Pairing protocol.""" + + def __init__( + self, + sc: bool = True, + mitm: bool = True, + bonding: bool = True, + delegate: Optional[PairingDelegate] = None, + ) -> None: + self.sc = sc + self.mitm = mitm + self.bonding = bonding + self.delegate = delegate or PairingDelegate() + + def __str__(self) -> str: + return ( + f'PairingConfig(sc={self.sc}, ' + f'mitm={self.mitm}, bonding={self.bonding}, ' + f'delegate[{self.delegate.io_capability}])' + ) diff --git a/bumble/pandora/__init__.py b/bumble/pandora/__init__.py new file mode 100644 index 0000000..e02f54a --- /dev/null +++ b/bumble/pandora/__init__.py @@ -0,0 +1,105 @@ +# Copyright 2022 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +""" +Bumble Pandora server. +This module implement the Pandora Bluetooth test APIs for the Bumble stack. +""" + +__version__ = "0.0.1" + +import grpc +import grpc.aio + +from .config import Config +from .device import PandoraDevice +from .host import HostService +from .security import SecurityService, SecurityStorageService +from pandora.host_grpc_aio import add_HostServicer_to_server +from pandora.security_grpc_aio import ( + add_SecurityServicer_to_server, + add_SecurityStorageServicer_to_server, +) +from typing import Callable, List, Optional + +# public symbols +__all__ = [ + 'register_servicer_hook', + 'serve', + 'Config', + 'PandoraDevice', +] + + +# Add servicers hooks. +_SERVICERS_HOOKS: List[Callable[[PandoraDevice, Config, grpc.aio.Server], None]] = [] + + +def register_servicer_hook( + hook: Callable[[PandoraDevice, Config, grpc.aio.Server], None] +) -> None: + _SERVICERS_HOOKS.append(hook) + + +async def serve( + bumble: PandoraDevice, + config: Config = Config(), + grpc_server: Optional[grpc.aio.Server] = None, + port: int = 0, +) -> None: + # initialize a gRPC server if not provided. + server = grpc_server if grpc_server is not None else grpc.aio.server() + port = server.add_insecure_port(f'localhost:{port}') + + try: + while True: + # load server config from dict. + config.load_from_dict(bumble.config.get('server', {})) + + # add Pandora services to the gRPC server. + add_HostServicer_to_server( + HostService(server, bumble.device, config), server + ) + add_SecurityServicer_to_server( + SecurityService(bumble.device, config), server + ) + add_SecurityStorageServicer_to_server( + SecurityStorageService(bumble.device, config), server + ) + + # call hooks if any. + for hook in _SERVICERS_HOOKS: + hook(bumble, config, server) + + # open device. + await bumble.open() + try: + # Pandora require classic devices to be discoverable & connectable. + if bumble.device.classic_enabled: + await bumble.device.set_discoverable(True) + await bumble.device.set_connectable(True) + + # start & serve gRPC server. + await server.start() + await server.wait_for_termination() + finally: + # close device. + await bumble.close() + + # re-initialize the gRPC server. + server = grpc.aio.server() + server.add_insecure_port(f'localhost:{port}') + finally: + # stop server. + await server.stop(None) diff --git a/bumble/pandora/config.py b/bumble/pandora/config.py new file mode 100644 index 0000000..5edba55 --- /dev/null +++ b/bumble/pandora/config.py @@ -0,0 +1,48 @@ +# Copyright 2022 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from bumble.pairing import PairingDelegate +from dataclasses import dataclass +from typing import Any, Dict + + +@dataclass +class Config: + io_capability: PairingDelegate.IoCapability = PairingDelegate.NO_OUTPUT_NO_INPUT + pairing_sc_enable: bool = True + pairing_mitm_enable: bool = True + pairing_bonding_enable: bool = True + smp_local_initiator_key_distribution: PairingDelegate.KeyDistribution = ( + PairingDelegate.DEFAULT_KEY_DISTRIBUTION + ) + smp_local_responder_key_distribution: PairingDelegate.KeyDistribution = ( + PairingDelegate.DEFAULT_KEY_DISTRIBUTION + ) + + def load_from_dict(self, config: Dict[str, Any]) -> None: + io_capability_name: str = config.get( + 'io_capability', 'no_output_no_input' + ).upper() + self.io_capability = getattr(PairingDelegate, io_capability_name) + self.pairing_sc_enable = config.get('pairing_sc_enable', True) + self.pairing_mitm_enable = config.get('pairing_mitm_enable', True) + self.pairing_bonding_enable = config.get('pairing_bonding_enable', True) + self.smp_local_initiator_key_distribution = config.get( + 'smp_local_initiator_key_distribution', + PairingDelegate.DEFAULT_KEY_DISTRIBUTION, + ) + self.smp_local_responder_key_distribution = config.get( + 'smp_local_responder_key_distribution', + PairingDelegate.DEFAULT_KEY_DISTRIBUTION, + ) diff --git a/bumble/pandora/device.py b/bumble/pandora/device.py new file mode 100644 index 0000000..a4403b6 --- /dev/null +++ b/bumble/pandora/device.py @@ -0,0 +1,157 @@ +# Copyright 2022 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Generic & dependency free Bumble (reference) device.""" + +from bumble import transport +from bumble.core import ( + BT_GENERIC_AUDIO_SERVICE, + BT_HANDSFREE_SERVICE, + BT_L2CAP_PROTOCOL_ID, + BT_RFCOMM_PROTOCOL_ID, +) +from bumble.device import Device, DeviceConfiguration +from bumble.host import Host +from bumble.sdp import ( + SDP_BLUETOOTH_PROFILE_DESCRIPTOR_LIST_ATTRIBUTE_ID, + SDP_PROTOCOL_DESCRIPTOR_LIST_ATTRIBUTE_ID, + SDP_SERVICE_CLASS_ID_LIST_ATTRIBUTE_ID, + SDP_SERVICE_RECORD_HANDLE_ATTRIBUTE_ID, + DataElement, + ServiceAttribute, +) +from typing import Any, Dict, List, Optional + + +class PandoraDevice: + """ + Small wrapper around a Bumble device and it's HCI transport. + Notes: + - The Bumble device is idle by default. + - Repetitive calls to `open`/`close` will result on new Bumble device instances. + """ + + # Bumble device instance & configuration. + device: Device + config: Dict[str, Any] + + # HCI transport name & instance. + _hci_name: str + _hci: Optional[transport.Transport] # type: ignore[name-defined] + + def __init__(self, config: Dict[str, Any]) -> None: + self.config = config + self.device = _make_device(config) + self._hci_name = config.get('transport', '') + self._hci = None + + @property + def idle(self) -> bool: + return self._hci is None + + async def open(self) -> None: + if self._hci is not None: + return + + # open HCI transport & set device host. + self._hci = await transport.open_transport(self._hci_name) + self.device.host = Host(controller_source=self._hci.source, controller_sink=self._hci.sink) # type: ignore[no-untyped-call] + + # power-on. + await self.device.power_on() + + async def close(self) -> None: + if self._hci is None: + return + + # flush & re-initialize device. + await self.device.host.flush() + self.device.host = None # type: ignore[assignment] + self.device = _make_device(self.config) + + # close HCI transport. + await self._hci.close() + self._hci = None + + async def reset(self) -> None: + await self.close() + await self.open() + + def info(self) -> Optional[Dict[str, str]]: + return { + 'public_bd_address': str(self.device.public_address), + 'random_address': str(self.device.random_address), + } + + +def _make_device(config: Dict[str, Any]) -> Device: + """Initialize an idle Bumble device instance.""" + + # initialize bumble device. + device_config = DeviceConfiguration() + device_config.load_from_dict(config) + device = Device(config=device_config, host=None) + + # Add fake a2dp service to avoid Android disconnect + device.sdp_service_records = _make_sdp_records(1) + + return device + + +# TODO(b/267540823): remove when Pandora A2dp is supported +def _make_sdp_records(rfcomm_channel: int) -> Dict[int, List[ServiceAttribute]]: + return { + 0x00010001: [ + ServiceAttribute( + SDP_SERVICE_RECORD_HANDLE_ATTRIBUTE_ID, + DataElement.unsigned_integer_32(0x00010001), + ), + ServiceAttribute( + SDP_SERVICE_CLASS_ID_LIST_ATTRIBUTE_ID, + DataElement.sequence( + [ + DataElement.uuid(BT_HANDSFREE_SERVICE), + DataElement.uuid(BT_GENERIC_AUDIO_SERVICE), + ] + ), + ), + 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(rfcomm_channel), + ] + ), + ] + ), + ), + ServiceAttribute( + SDP_BLUETOOTH_PROFILE_DESCRIPTOR_LIST_ATTRIBUTE_ID, + DataElement.sequence( + [ + DataElement.sequence( + [ + DataElement.uuid(BT_HANDSFREE_SERVICE), + DataElement.unsigned_integer_16(0x0105), + ] + ) + ] + ), + ), + ] + } diff --git a/bumble/pandora/host.py b/bumble/pandora/host.py new file mode 100644 index 0000000..63b295d --- /dev/null +++ b/bumble/pandora/host.py @@ -0,0 +1,856 @@ +# Copyright 2022 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import asyncio +import bumble.device +import grpc +import grpc.aio +import logging +import struct + +from . import utils +from .config import Config +from bumble.core import ( + BT_BR_EDR_TRANSPORT, + BT_LE_TRANSPORT, + BT_PERIPHERAL_ROLE, + UUID, + AdvertisingData, + ConnectionError, +) +from bumble.device import ( + DEVICE_DEFAULT_SCAN_INTERVAL, + DEVICE_DEFAULT_SCAN_WINDOW, + Advertisement, + AdvertisingType, + Device, +) +from bumble.gatt import Service +from bumble.hci import ( + HCI_CONNECTION_ALREADY_EXISTS_ERROR, + HCI_PAGE_TIMEOUT_ERROR, + HCI_REMOTE_USER_TERMINATED_CONNECTION_ERROR, + Address, +) +from google.protobuf import any_pb2, empty_pb2 # pytype: disable=pyi-error +from pandora.host_grpc_aio import HostServicer +from pandora.host_pb2 import ( + NOT_CONNECTABLE, + NOT_DISCOVERABLE, + PRIMARY_1M, + PRIMARY_CODED, + SECONDARY_1M, + SECONDARY_2M, + SECONDARY_CODED, + SECONDARY_NONE, + AdvertiseRequest, + AdvertiseResponse, + Connection, + ConnectLERequest, + ConnectLEResponse, + ConnectRequest, + ConnectResponse, + DataTypes, + DisconnectRequest, + InquiryResponse, + PrimaryPhy, + ReadLocalAddressResponse, + ScanningResponse, + ScanRequest, + SecondaryPhy, + SetConnectabilityModeRequest, + SetDiscoverabilityModeRequest, + WaitConnectionRequest, + WaitConnectionResponse, + WaitDisconnectionRequest, +) +from typing import AsyncGenerator, Dict, List, Optional, Set, Tuple, cast + +PRIMARY_PHY_MAP: Dict[int, PrimaryPhy] = { + # Default value reported by Bumble for legacy Advertising reports. + # FIXME(uael): `None` might be a better value, but Bumble need to change accordingly. + 0: PRIMARY_1M, + 1: PRIMARY_1M, + 3: PRIMARY_CODED, +} + +SECONDARY_PHY_MAP: Dict[int, SecondaryPhy] = { + 0: SECONDARY_NONE, + 1: SECONDARY_1M, + 2: SECONDARY_2M, + 3: SECONDARY_CODED, +} + + +class HostService(HostServicer): + waited_connections: Set[int] + + def __init__( + self, grpc_server: grpc.aio.Server, device: Device, config: Config + ) -> None: + self.log = utils.BumbleServerLoggerAdapter( + logging.getLogger(), {'service_name': 'Host', 'device': device} + ) + self.grpc_server = grpc_server + self.device = device + self.config = config + self.waited_connections = set() + + @utils.rpc + async def FactoryReset( + self, request: empty_pb2.Empty, context: grpc.ServicerContext + ) -> empty_pb2.Empty: + self.log.info('FactoryReset') + + # delete all bonds + if self.device.keystore is not None: + await self.device.keystore.delete_all() + + # trigger gRCP server stop then return + asyncio.create_task(self.grpc_server.stop(None)) + return empty_pb2.Empty() + + @utils.rpc + async def Reset( + self, request: empty_pb2.Empty, context: grpc.ServicerContext + ) -> empty_pb2.Empty: + self.log.info('Reset') + + # clear service. + self.waited_connections.clear() + + # (re) power device on + await self.device.power_on() + return empty_pb2.Empty() + + @utils.rpc + async def ReadLocalAddress( + self, request: empty_pb2.Empty, context: grpc.ServicerContext + ) -> ReadLocalAddressResponse: + self.log.info('ReadLocalAddress') + return ReadLocalAddressResponse( + address=bytes(reversed(bytes(self.device.public_address))) + ) + + @utils.rpc + async def Connect( + self, request: ConnectRequest, context: grpc.ServicerContext + ) -> ConnectResponse: + # Need to reverse bytes order since Bumble Address is using MSB. + address = Address( + bytes(reversed(request.address)), address_type=Address.PUBLIC_DEVICE_ADDRESS + ) + self.log.info(f"Connect to {address}") + + try: + connection = await self.device.connect( + address, transport=BT_BR_EDR_TRANSPORT + ) + except ConnectionError as e: + if e.error_code == HCI_PAGE_TIMEOUT_ERROR: + self.log.warning(f"Peer not found: {e}") + return ConnectResponse(peer_not_found=empty_pb2.Empty()) + if e.error_code == HCI_CONNECTION_ALREADY_EXISTS_ERROR: + self.log.warning(f"Connection already exists: {e}") + return ConnectResponse(connection_already_exists=empty_pb2.Empty()) + raise e + + self.log.info(f"Connect to {address} done (handle={connection.handle})") + + cookie = any_pb2.Any(value=connection.handle.to_bytes(4, 'big')) + return ConnectResponse(connection=Connection(cookie=cookie)) + + @utils.rpc + async def WaitConnection( + self, request: WaitConnectionRequest, context: grpc.ServicerContext + ) -> WaitConnectionResponse: + if not request.address: + raise ValueError('Request address field must be set') + + # Need to reverse bytes order since Bumble Address is using MSB. + address = Address( + bytes(reversed(request.address)), address_type=Address.PUBLIC_DEVICE_ADDRESS + ) + if address in (Address.NIL, Address.ANY): + raise ValueError('Invalid address') + + self.log.info(f"WaitConnection from {address}...") + + connection = self.device.find_connection_by_bd_addr( + address, transport=BT_BR_EDR_TRANSPORT + ) + if connection and id(connection) in self.waited_connections: + # this connection was already returned: wait for a new one. + connection = None + + if not connection: + connection = await self.device.accept(address) + + # save connection has waited and respond. + self.waited_connections.add(id(connection)) + + self.log.info( + f"WaitConnection from {address} done (handle={connection.handle})" + ) + + cookie = any_pb2.Any(value=connection.handle.to_bytes(4, 'big')) + return WaitConnectionResponse(connection=Connection(cookie=cookie)) + + @utils.rpc + async def ConnectLE( + self, request: ConnectLERequest, context: grpc.ServicerContext + ) -> ConnectLEResponse: + address = utils.address_from_request(request, request.WhichOneof("address")) + if address in (Address.NIL, Address.ANY): + raise ValueError('Invalid address') + + self.log.info(f"ConnectLE to {address}...") + + try: + connection = await self.device.connect( + address, + transport=BT_LE_TRANSPORT, + own_address_type=request.own_address_type, + ) + except ConnectionError as e: + if e.error_code == HCI_PAGE_TIMEOUT_ERROR: + self.log.warning(f"Peer not found: {e}") + return ConnectLEResponse(peer_not_found=empty_pb2.Empty()) + if e.error_code == HCI_CONNECTION_ALREADY_EXISTS_ERROR: + self.log.warning(f"Connection already exists: {e}") + return ConnectLEResponse(connection_already_exists=empty_pb2.Empty()) + raise e + + self.log.info(f"ConnectLE to {address} done (handle={connection.handle})") + + cookie = any_pb2.Any(value=connection.handle.to_bytes(4, 'big')) + return ConnectLEResponse(connection=Connection(cookie=cookie)) + + @utils.rpc + async def Disconnect( + self, request: DisconnectRequest, context: grpc.ServicerContext + ) -> empty_pb2.Empty: + connection_handle = int.from_bytes(request.connection.cookie.value, 'big') + self.log.info(f"Disconnect: {connection_handle}") + + self.log.info("Disconnecting...") + if connection := self.device.lookup_connection(connection_handle): + await connection.disconnect(HCI_REMOTE_USER_TERMINATED_CONNECTION_ERROR) + self.log.info("Disconnected") + + return empty_pb2.Empty() + + @utils.rpc + async def WaitDisconnection( + self, request: WaitDisconnectionRequest, context: grpc.ServicerContext + ) -> empty_pb2.Empty: + connection_handle = int.from_bytes(request.connection.cookie.value, 'big') + self.log.info(f"WaitDisconnection: {connection_handle}") + + if connection := self.device.lookup_connection(connection_handle): + disconnection_future: asyncio.Future[ + None + ] = asyncio.get_running_loop().create_future() + + def on_disconnection(_: None) -> None: + disconnection_future.set_result(None) + + connection.on('disconnection', on_disconnection) + try: + await disconnection_future + self.log.info("Disconnected") + finally: + connection.remove_listener('disconnection', on_disconnection) # type: ignore + + return empty_pb2.Empty() + + @utils.rpc + async def Advertise( + self, request: AdvertiseRequest, context: grpc.ServicerContext + ) -> AsyncGenerator[AdvertiseResponse, None]: + if not request.legacy: + raise NotImplementedError( + "TODO: add support for extended advertising in Bumble" + ) + if request.interval: + raise NotImplementedError("TODO: add support for `request.interval`") + if request.interval_range: + raise NotImplementedError("TODO: add support for `request.interval_range`") + if request.primary_phy: + raise NotImplementedError("TODO: add support for `request.primary_phy`") + if request.secondary_phy: + raise NotImplementedError("TODO: add support for `request.secondary_phy`") + + if self.device.is_advertising: + raise NotImplementedError('TODO: add support for advertising sets') + + if data := request.data: + self.device.advertising_data = bytes(self.unpack_data_types(data)) + + if scan_response_data := request.scan_response_data: + self.device.scan_response_data = bytes( + self.unpack_data_types(scan_response_data) + ) + scannable = True + else: + scannable = False + + # Retrieve services data + for service in self.device.gatt_server.attributes: + if isinstance(service, Service) and ( + service_data := service.get_advertising_data() + ): + service_uuid = service.uuid.to_hex_str('-') + if ( + service_uuid in request.data.incomplete_service_class_uuids16 + or service_uuid in request.data.complete_service_class_uuids16 + or service_uuid in request.data.incomplete_service_class_uuids32 + or service_uuid in request.data.complete_service_class_uuids32 + or service_uuid + in request.data.incomplete_service_class_uuids128 + or service_uuid in request.data.complete_service_class_uuids128 + ): + self.device.advertising_data += service_data + if ( + service_uuid + in scan_response_data.incomplete_service_class_uuids16 + or service_uuid + in scan_response_data.complete_service_class_uuids16 + or service_uuid + in scan_response_data.incomplete_service_class_uuids32 + or service_uuid + in scan_response_data.complete_service_class_uuids32 + or service_uuid + in scan_response_data.incomplete_service_class_uuids128 + or service_uuid + in scan_response_data.complete_service_class_uuids128 + ): + self.device.scan_response_data += service_data + + target = None + if request.connectable and scannable: + advertising_type = AdvertisingType.UNDIRECTED_CONNECTABLE_SCANNABLE + elif scannable: + advertising_type = AdvertisingType.UNDIRECTED_SCANNABLE + else: + advertising_type = AdvertisingType.UNDIRECTED + else: + target = None + advertising_type = AdvertisingType.UNDIRECTED + + if request.target: + # Need to reverse bytes order since Bumble Address is using MSB. + target_bytes = bytes(reversed(request.target)) + if request.target_variant() == "public": + target = Address(target_bytes, Address.PUBLIC_DEVICE_ADDRESS) + advertising_type = ( + AdvertisingType.DIRECTED_CONNECTABLE_HIGH_DUTY + ) # FIXME: HIGH_DUTY ? + else: + target = Address(target_bytes, Address.RANDOM_DEVICE_ADDRESS) + advertising_type = ( + AdvertisingType.DIRECTED_CONNECTABLE_HIGH_DUTY + ) # FIXME: HIGH_DUTY ? + + if request.connectable: + + def on_connection(connection: bumble.device.Connection) -> None: + if ( + connection.transport == BT_LE_TRANSPORT + and connection.role == BT_PERIPHERAL_ROLE + ): + pending_connection.set_result(connection) + + self.device.on('connection', on_connection) + + try: + while True: + if not self.device.is_advertising: + self.log.info('Advertise') + await self.device.start_advertising( + target=target, + advertising_type=advertising_type, + own_address_type=request.own_address_type, + ) + + if not request.connectable: + await asyncio.sleep(1) + continue + + pending_connection: asyncio.Future[ + bumble.device.Connection + ] = asyncio.get_running_loop().create_future() + + self.log.info('Wait for LE connection...') + connection = await pending_connection + + self.log.info( + f"Advertise: Connected to {connection.peer_address} (handle={connection.handle})" + ) + + cookie = any_pb2.Any(value=connection.handle.to_bytes(4, 'big')) + yield AdvertiseResponse(connection=Connection(cookie=cookie)) + + # wait a small delay before restarting the advertisement. + await asyncio.sleep(1) + finally: + if request.connectable: + self.device.remove_listener('connection', on_connection) # type: ignore + + try: + self.log.info('Stop advertising') + await self.device.abort_on('flush', self.device.stop_advertising()) + except: + pass + + @utils.rpc + async def Scan( + self, request: ScanRequest, context: grpc.ServicerContext + ) -> AsyncGenerator[ScanningResponse, None]: + # TODO: modify `start_scanning` to accept floats instead of int for ms values + if request.phys: + raise NotImplementedError("TODO: add support for `request.phys`") + + self.log.info('Scan') + + scan_queue: asyncio.Queue[Advertisement] = asyncio.Queue() + handler = self.device.on('advertisement', scan_queue.put_nowait) + await self.device.start_scanning( + legacy=request.legacy, + active=not request.passive, + own_address_type=request.own_address_type, + scan_interval=int(request.interval) + if request.interval + else DEVICE_DEFAULT_SCAN_INTERVAL, + scan_window=int(request.window) + if request.window + else DEVICE_DEFAULT_SCAN_WINDOW, + ) + + try: + # TODO: add support for `direct_address` in Bumble + # TODO: add support for `periodic_advertising_interval` in Bumble + while adv := await scan_queue.get(): + sr = ScanningResponse( + legacy=adv.is_legacy, + connectable=adv.is_connectable, + scannable=adv.is_scannable, + truncated=adv.is_truncated, + sid=adv.sid, + primary_phy=PRIMARY_PHY_MAP[adv.primary_phy], + secondary_phy=SECONDARY_PHY_MAP[adv.secondary_phy], + tx_power=adv.tx_power, + rssi=adv.rssi, + data=self.pack_data_types(adv.data), + ) + + if adv.address.address_type == Address.PUBLIC_DEVICE_ADDRESS: + sr.public = bytes(reversed(bytes(adv.address))) + elif adv.address.address_type == Address.RANDOM_DEVICE_ADDRESS: + sr.random = bytes(reversed(bytes(adv.address))) + elif adv.address.address_type == Address.PUBLIC_IDENTITY_ADDRESS: + sr.public_identity = bytes(reversed(bytes(adv.address))) + else: + sr.random_static_identity = bytes(reversed(bytes(adv.address))) + + yield sr + + finally: + self.device.remove_listener('advertisement', handler) # type: ignore + try: + self.log.info('Stop scanning') + await self.device.abort_on('flush', self.device.stop_scanning()) + except: + pass + + @utils.rpc + async def Inquiry( + self, request: empty_pb2.Empty, context: grpc.ServicerContext + ) -> AsyncGenerator[InquiryResponse, None]: + self.log.info('Inquiry') + + inquiry_queue: asyncio.Queue[ + Optional[Tuple[Address, int, AdvertisingData, int]] + ] = asyncio.Queue() + complete_handler = self.device.on( + 'inquiry_complete', lambda: inquiry_queue.put_nowait(None) + ) + result_handler = self.device.on( # type: ignore + 'inquiry_result', + lambda address, class_of_device, eir_data, rssi: inquiry_queue.put_nowait( # type: ignore + (address, class_of_device, eir_data, rssi) # type: ignore + ), + ) + + await self.device.start_discovery(auto_restart=False) + try: + while inquiry_result := await inquiry_queue.get(): + (address, class_of_device, eir_data, rssi) = inquiry_result + # FIXME: if needed, add support for `page_scan_repetition_mode` and `clock_offset` in Bumble + yield InquiryResponse( + address=bytes(reversed(bytes(address))), + class_of_device=class_of_device, + rssi=rssi, + data=self.pack_data_types(eir_data), + ) + + finally: + self.device.remove_listener('inquiry_complete', complete_handler) # type: ignore + self.device.remove_listener('inquiry_result', result_handler) # type: ignore + try: + self.log.info('Stop inquiry') + await self.device.abort_on('flush', self.device.stop_discovery()) + except: + pass + + @utils.rpc + async def SetDiscoverabilityMode( + self, request: SetDiscoverabilityModeRequest, context: grpc.ServicerContext + ) -> empty_pb2.Empty: + self.log.info("SetDiscoverabilityMode") + await self.device.set_discoverable(request.mode != NOT_DISCOVERABLE) + return empty_pb2.Empty() + + @utils.rpc + async def SetConnectabilityMode( + self, request: SetConnectabilityModeRequest, context: grpc.ServicerContext + ) -> empty_pb2.Empty: + self.log.info("SetConnectabilityMode") + await self.device.set_connectable(request.mode != NOT_CONNECTABLE) + return empty_pb2.Empty() + + def unpack_data_types(self, dt: DataTypes) -> AdvertisingData: + ad_structures: List[Tuple[int, bytes]] = [] + + uuids: List[str] + datas: Dict[str, bytes] + + def uuid128_from_str(uuid: str) -> bytes: + """Decode a 128-bit uuid encoded as XXXXXXXX-XXXX-XXXX-XXXX-XXXXXXXXXXXX + to byte format.""" + return bytes(reversed(bytes.fromhex(uuid.replace('-', '')))) + + def uuid32_from_str(uuid: str) -> bytes: + """Decode a 32-bit uuid encoded as XXXXXXXX to byte format.""" + return bytes(reversed(bytes.fromhex(uuid))) + + def uuid16_from_str(uuid: str) -> bytes: + """Decode a 16-bit uuid encoded as XXXX to byte format.""" + return bytes(reversed(bytes.fromhex(uuid))) + + if uuids := dt.incomplete_service_class_uuids16: + ad_structures.append( + ( + AdvertisingData.INCOMPLETE_LIST_OF_16_BIT_SERVICE_CLASS_UUIDS, + b''.join([uuid16_from_str(uuid) for uuid in uuids]), + ) + ) + if uuids := dt.complete_service_class_uuids16: + ad_structures.append( + ( + AdvertisingData.COMPLETE_LIST_OF_16_BIT_SERVICE_CLASS_UUIDS, + b''.join([uuid16_from_str(uuid) for uuid in uuids]), + ) + ) + if uuids := dt.incomplete_service_class_uuids32: + ad_structures.append( + ( + AdvertisingData.INCOMPLETE_LIST_OF_32_BIT_SERVICE_CLASS_UUIDS, + b''.join([uuid32_from_str(uuid) for uuid in uuids]), + ) + ) + if uuids := dt.complete_service_class_uuids32: + ad_structures.append( + ( + AdvertisingData.COMPLETE_LIST_OF_32_BIT_SERVICE_CLASS_UUIDS, + b''.join([uuid32_from_str(uuid) for uuid in uuids]), + ) + ) + if uuids := dt.incomplete_service_class_uuids128: + ad_structures.append( + ( + AdvertisingData.INCOMPLETE_LIST_OF_128_BIT_SERVICE_CLASS_UUIDS, + b''.join([uuid128_from_str(uuid) for uuid in uuids]), + ) + ) + if uuids := dt.complete_service_class_uuids128: + ad_structures.append( + ( + AdvertisingData.COMPLETE_LIST_OF_128_BIT_SERVICE_CLASS_UUIDS, + b''.join([uuid128_from_str(uuid) for uuid in uuids]), + ) + ) + if dt.HasField('include_shortened_local_name'): + ad_structures.append( + ( + AdvertisingData.SHORTENED_LOCAL_NAME, + bytes(self.device.name[:8], 'utf-8'), + ) + ) + elif dt.shortened_local_name: + ad_structures.append( + ( + AdvertisingData.SHORTENED_LOCAL_NAME, + bytes(dt.shortened_local_name, 'utf-8'), + ) + ) + if dt.HasField('include_complete_local_name'): + ad_structures.append( + (AdvertisingData.COMPLETE_LOCAL_NAME, bytes(self.device.name, 'utf-8')) + ) + elif dt.complete_local_name: + ad_structures.append( + ( + AdvertisingData.COMPLETE_LOCAL_NAME, + bytes(dt.complete_local_name, 'utf-8'), + ) + ) + if dt.HasField('include_tx_power_level'): + raise ValueError('unsupported data type') + elif dt.tx_power_level: + ad_structures.append( + ( + AdvertisingData.TX_POWER_LEVEL, + bytes(struct.pack('<I', dt.tx_power_level)[:1]), + ) + ) + if dt.HasField('include_class_of_device'): + ad_structures.append( + ( + AdvertisingData.CLASS_OF_DEVICE, + bytes(struct.pack('<I', self.device.class_of_device)[:-1]), + ) + ) + elif dt.class_of_device: + ad_structures.append( + ( + AdvertisingData.CLASS_OF_DEVICE, + bytes(struct.pack('<I', dt.class_of_device)[:-1]), + ) + ) + if dt.peripheral_connection_interval_min: + ad_structures.append( + ( + AdvertisingData.PERIPHERAL_CONNECTION_INTERVAL_RANGE, + bytes( + [ + *struct.pack('<H', dt.peripheral_connection_interval_min), + *struct.pack( + '<H', + dt.peripheral_connection_interval_max + if dt.peripheral_connection_interval_max + else dt.peripheral_connection_interval_min, + ), + ] + ), + ) + ) + if uuids := dt.service_solicitation_uuids16: + ad_structures.append( + ( + AdvertisingData.LIST_OF_16_BIT_SERVICE_SOLICITATION_UUIDS, + b''.join([uuid16_from_str(uuid) for uuid in uuids]), + ) + ) + if uuids := dt.service_solicitation_uuids32: + ad_structures.append( + ( + AdvertisingData.LIST_OF_32_BIT_SERVICE_SOLICITATION_UUIDS, + b''.join([uuid32_from_str(uuid) for uuid in uuids]), + ) + ) + if uuids := dt.service_solicitation_uuids128: + ad_structures.append( + ( + AdvertisingData.LIST_OF_128_BIT_SERVICE_SOLICITATION_UUIDS, + b''.join([uuid128_from_str(uuid) for uuid in uuids]), + ) + ) + if datas := dt.service_data_uuid16: + ad_structures.extend( + [ + ( + AdvertisingData.SERVICE_DATA_16_BIT_UUID, + uuid16_from_str(uuid) + data, + ) + for uuid, data in datas.items() + ] + ) + if datas := dt.service_data_uuid32: + ad_structures.extend( + [ + ( + AdvertisingData.SERVICE_DATA_32_BIT_UUID, + uuid32_from_str(uuid) + data, + ) + for uuid, data in datas.items() + ] + ) + if datas := dt.service_data_uuid128: + ad_structures.extend( + [ + ( + AdvertisingData.SERVICE_DATA_128_BIT_UUID, + uuid128_from_str(uuid) + data, + ) + for uuid, data in datas.items() + ] + ) + if dt.appearance: + ad_structures.append( + (AdvertisingData.APPEARANCE, struct.pack('<H', dt.appearance)) + ) + if dt.advertising_interval: + ad_structures.append( + ( + AdvertisingData.ADVERTISING_INTERVAL, + struct.pack('<H', dt.advertising_interval), + ) + ) + if dt.uri: + ad_structures.append((AdvertisingData.URI, bytes(dt.uri, 'utf-8'))) + if dt.le_supported_features: + ad_structures.append( + (AdvertisingData.LE_SUPPORTED_FEATURES, dt.le_supported_features) + ) + if dt.manufacturer_specific_data: + ad_structures.append( + ( + AdvertisingData.MANUFACTURER_SPECIFIC_DATA, + dt.manufacturer_specific_data, + ) + ) + + return AdvertisingData(ad_structures) + + def pack_data_types(self, ad: AdvertisingData) -> DataTypes: + dt = DataTypes() + uuids: List[UUID] + s: str + i: int + ij: Tuple[int, int] + uuid_data: Tuple[UUID, bytes] + data: bytes + + if uuids := cast( + List[UUID], + ad.get(AdvertisingData.INCOMPLETE_LIST_OF_16_BIT_SERVICE_CLASS_UUIDS), + ): + dt.incomplete_service_class_uuids16.extend( + list(map(lambda x: x.to_hex_str('-'), uuids)) + ) + if uuids := cast( + List[UUID], + ad.get(AdvertisingData.COMPLETE_LIST_OF_16_BIT_SERVICE_CLASS_UUIDS), + ): + dt.complete_service_class_uuids16.extend( + list(map(lambda x: x.to_hex_str('-'), uuids)) + ) + if uuids := cast( + List[UUID], + ad.get(AdvertisingData.INCOMPLETE_LIST_OF_32_BIT_SERVICE_CLASS_UUIDS), + ): + dt.incomplete_service_class_uuids32.extend( + list(map(lambda x: x.to_hex_str('-'), uuids)) + ) + if uuids := cast( + List[UUID], + ad.get(AdvertisingData.COMPLETE_LIST_OF_32_BIT_SERVICE_CLASS_UUIDS), + ): + dt.complete_service_class_uuids32.extend( + list(map(lambda x: x.to_hex_str('-'), uuids)) + ) + if uuids := cast( + List[UUID], + ad.get(AdvertisingData.INCOMPLETE_LIST_OF_128_BIT_SERVICE_CLASS_UUIDS), + ): + dt.incomplete_service_class_uuids128.extend( + list(map(lambda x: x.to_hex_str('-'), uuids)) + ) + if uuids := cast( + List[UUID], + ad.get(AdvertisingData.COMPLETE_LIST_OF_128_BIT_SERVICE_CLASS_UUIDS), + ): + dt.complete_service_class_uuids128.extend( + list(map(lambda x: x.to_hex_str('-'), uuids)) + ) + if s := cast(str, ad.get(AdvertisingData.SHORTENED_LOCAL_NAME)): + dt.shortened_local_name = s + if s := cast(str, ad.get(AdvertisingData.COMPLETE_LOCAL_NAME)): + dt.complete_local_name = s + if i := cast(int, ad.get(AdvertisingData.TX_POWER_LEVEL)): + dt.tx_power_level = i + if i := cast(int, ad.get(AdvertisingData.CLASS_OF_DEVICE)): + dt.class_of_device = i + if ij := cast( + Tuple[int, int], + ad.get(AdvertisingData.PERIPHERAL_CONNECTION_INTERVAL_RANGE), + ): + dt.peripheral_connection_interval_min = ij[0] + dt.peripheral_connection_interval_max = ij[1] + if uuids := cast( + List[UUID], + ad.get(AdvertisingData.LIST_OF_16_BIT_SERVICE_SOLICITATION_UUIDS), + ): + dt.service_solicitation_uuids16.extend( + list(map(lambda x: x.to_hex_str('-'), uuids)) + ) + if uuids := cast( + List[UUID], + ad.get(AdvertisingData.LIST_OF_32_BIT_SERVICE_SOLICITATION_UUIDS), + ): + dt.service_solicitation_uuids32.extend( + list(map(lambda x: x.to_hex_str('-'), uuids)) + ) + if uuids := cast( + List[UUID], + ad.get(AdvertisingData.LIST_OF_128_BIT_SERVICE_SOLICITATION_UUIDS), + ): + dt.service_solicitation_uuids128.extend( + list(map(lambda x: x.to_hex_str('-'), uuids)) + ) + if uuid_data := cast( + Tuple[UUID, bytes], ad.get(AdvertisingData.SERVICE_DATA_16_BIT_UUID) + ): + dt.service_data_uuid16[uuid_data[0].to_hex_str('-')] = uuid_data[1] + if uuid_data := cast( + Tuple[UUID, bytes], ad.get(AdvertisingData.SERVICE_DATA_32_BIT_UUID) + ): + dt.service_data_uuid32[uuid_data[0].to_hex_str('-')] = uuid_data[1] + if uuid_data := cast( + Tuple[UUID, bytes], ad.get(AdvertisingData.SERVICE_DATA_128_BIT_UUID) + ): + dt.service_data_uuid128[uuid_data[0].to_hex_str('-')] = uuid_data[1] + if data := cast(bytes, ad.get(AdvertisingData.PUBLIC_TARGET_ADDRESS, raw=True)): + dt.public_target_addresses.extend( + [data[i * 6 :: i * 6 + 6] for i in range(int(len(data) / 6))] + ) + if data := cast(bytes, ad.get(AdvertisingData.RANDOM_TARGET_ADDRESS, raw=True)): + dt.random_target_addresses.extend( + [data[i * 6 :: i * 6 + 6] for i in range(int(len(data) / 6))] + ) + if i := cast(int, ad.get(AdvertisingData.APPEARANCE)): + dt.appearance = i + if i := cast(int, ad.get(AdvertisingData.ADVERTISING_INTERVAL)): + dt.advertising_interval = i + if s := cast(str, ad.get(AdvertisingData.URI)): + dt.uri = s + if data := cast(bytes, ad.get(AdvertisingData.LE_SUPPORTED_FEATURES, raw=True)): + dt.le_supported_features = data + if data := cast( + bytes, ad.get(AdvertisingData.MANUFACTURER_SPECIFIC_DATA, raw=True) + ): + dt.manufacturer_specific_data = data + + return dt diff --git a/bumble/pandora/py.typed b/bumble/pandora/py.typed new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/bumble/pandora/py.typed diff --git a/bumble/pandora/security.py b/bumble/pandora/security.py new file mode 100644 index 0000000..fee1b7a --- /dev/null +++ b/bumble/pandora/security.py @@ -0,0 +1,529 @@ +# Copyright 2022 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import asyncio +import grpc +import logging + +from . import utils +from .config import Config +from bumble import hci +from bumble.core import ( + BT_BR_EDR_TRANSPORT, + BT_LE_TRANSPORT, + BT_PERIPHERAL_ROLE, + ProtocolError, +) +from bumble.device import Connection as BumbleConnection, Device +from bumble.hci import HCI_Error +from bumble.pairing import PairingConfig, PairingDelegate as BasePairingDelegate +from contextlib import suppress +from google.protobuf import ( + any_pb2, + empty_pb2, + wrappers_pb2, +) # pytype: disable=pyi-error +from google.protobuf.wrappers_pb2 import BoolValue # pytype: disable=pyi-error +from pandora.host_pb2 import Connection +from pandora.security_grpc_aio import SecurityServicer, SecurityStorageServicer +from pandora.security_pb2 import ( + LE_LEVEL1, + LE_LEVEL2, + LE_LEVEL3, + LE_LEVEL4, + LEVEL0, + LEVEL1, + LEVEL2, + LEVEL3, + LEVEL4, + DeleteBondRequest, + IsBondedRequest, + LESecurityLevel, + PairingEvent, + PairingEventAnswer, + SecureRequest, + SecureResponse, + SecurityLevel, + WaitSecurityRequest, + WaitSecurityResponse, +) +from typing import Any, AsyncGenerator, AsyncIterator, Callable, Dict, Optional, Union + + +class PairingDelegate(BasePairingDelegate): + def __init__( + self, + connection: BumbleConnection, + service: "SecurityService", + io_capability: BasePairingDelegate.IoCapability = BasePairingDelegate.NO_OUTPUT_NO_INPUT, + local_initiator_key_distribution: BasePairingDelegate.KeyDistribution = BasePairingDelegate.DEFAULT_KEY_DISTRIBUTION, + local_responder_key_distribution: BasePairingDelegate.KeyDistribution = BasePairingDelegate.DEFAULT_KEY_DISTRIBUTION, + ) -> None: + self.log = utils.BumbleServerLoggerAdapter( + logging.getLogger(), + {'service_name': 'Security', 'device': connection.device}, + ) + self.connection = connection + self.service = service + super().__init__( + io_capability, + local_initiator_key_distribution, + local_responder_key_distribution, + ) + + async def accept(self) -> bool: + return True + + def add_origin(self, ev: PairingEvent) -> PairingEvent: + if not self.connection.is_incomplete: + assert ev.connection + ev.connection.CopyFrom( + Connection( + cookie=any_pb2.Any(value=self.connection.handle.to_bytes(4, 'big')) + ) + ) + else: + # In BR/EDR, connection may not be complete, + # use address instead + assert self.connection.transport == BT_BR_EDR_TRANSPORT + ev.address = bytes(reversed(bytes(self.connection.peer_address))) + + return ev + + async def confirm(self, auto: bool = False) -> bool: + self.log.info( + f"Pairing event: `just_works` (io_capability: {self.io_capability})" + ) + + if self.service.event_queue is None or self.service.event_answer is None: + return True + + event = self.add_origin(PairingEvent(just_works=empty_pb2.Empty())) + self.service.event_queue.put_nowait(event) + answer = await anext(self.service.event_answer) # pytype: disable=name-error + assert answer.event == event + assert answer.answer_variant() == 'confirm' and answer.confirm is not None + return answer.confirm + + async def compare_numbers(self, number: int, digits: int = 6) -> bool: + self.log.info( + f"Pairing event: `numeric_comparison` (io_capability: {self.io_capability})" + ) + + if self.service.event_queue is None or self.service.event_answer is None: + raise RuntimeError('security: unhandled number comparison request') + + event = self.add_origin(PairingEvent(numeric_comparison=number)) + self.service.event_queue.put_nowait(event) + answer = await anext(self.service.event_answer) # pytype: disable=name-error + assert answer.event == event + assert answer.answer_variant() == 'confirm' and answer.confirm is not None + return answer.confirm + + async def get_number(self) -> Optional[int]: + self.log.info( + f"Pairing event: `passkey_entry_request` (io_capability: {self.io_capability})" + ) + + if self.service.event_queue is None or self.service.event_answer is None: + raise RuntimeError('security: unhandled number request') + + event = self.add_origin(PairingEvent(passkey_entry_request=empty_pb2.Empty())) + self.service.event_queue.put_nowait(event) + answer = await anext(self.service.event_answer) # pytype: disable=name-error + assert answer.event == event + if answer.answer_variant() is None: + return None + assert answer.answer_variant() == 'passkey' + return answer.passkey + + async def get_string(self, max_length: int) -> Optional[str]: + self.log.info( + f"Pairing event: `pin_code_request` (io_capability: {self.io_capability})" + ) + + if self.service.event_queue is None or self.service.event_answer is None: + raise RuntimeError('security: unhandled pin_code request') + + event = self.add_origin(PairingEvent(pin_code_request=empty_pb2.Empty())) + self.service.event_queue.put_nowait(event) + answer = await anext(self.service.event_answer) # pytype: disable=name-error + assert answer.event == event + if answer.answer_variant() is None: + return None + assert answer.answer_variant() == 'pin' + + if answer.pin is None: + return None + + pin = answer.pin.decode('utf-8') + if not pin or len(pin) > max_length: + raise ValueError(f'Pin must be utf-8 encoded up to {max_length} bytes') + + return pin + + async def display_number(self, number: int, digits: int = 6) -> None: + if ( + self.connection.transport == BT_BR_EDR_TRANSPORT + and self.io_capability == BasePairingDelegate.DISPLAY_OUTPUT_ONLY + ): + return + + self.log.info( + f"Pairing event: `passkey_entry_notification` (io_capability: {self.io_capability})" + ) + + if self.service.event_queue is None: + raise RuntimeError('security: unhandled number display request') + + event = self.add_origin(PairingEvent(passkey_entry_notification=number)) + self.service.event_queue.put_nowait(event) + + +BR_LEVEL_REACHED: Dict[SecurityLevel, Callable[[BumbleConnection], bool]] = { + LEVEL0: lambda connection: True, + LEVEL1: lambda connection: connection.encryption == 0 or connection.authenticated, + LEVEL2: lambda connection: connection.encryption != 0 and connection.authenticated, + LEVEL3: lambda connection: connection.encryption != 0 + and connection.authenticated + and connection.link_key_type + in ( + hci.HCI_AUTHENTICATED_COMBINATION_KEY_GENERATED_FROM_P_192_TYPE, + hci.HCI_AUTHENTICATED_COMBINATION_KEY_GENERATED_FROM_P_256_TYPE, + ), + LEVEL4: lambda connection: connection.encryption + == hci.HCI_Encryption_Change_Event.AES_CCM + and connection.authenticated + and connection.link_key_type + == hci.HCI_AUTHENTICATED_COMBINATION_KEY_GENERATED_FROM_P_256_TYPE, +} + +LE_LEVEL_REACHED: Dict[LESecurityLevel, Callable[[BumbleConnection], bool]] = { + LE_LEVEL1: lambda connection: True, + LE_LEVEL2: lambda connection: connection.encryption != 0, + LE_LEVEL3: lambda connection: connection.encryption != 0 + and connection.authenticated, + LE_LEVEL4: lambda connection: connection.encryption != 0 + and connection.authenticated + and connection.sc, +} + + +class SecurityService(SecurityServicer): + def __init__(self, device: Device, config: Config) -> None: + self.log = utils.BumbleServerLoggerAdapter( + logging.getLogger(), {'service_name': 'Security', 'device': device} + ) + self.event_queue: Optional[asyncio.Queue[PairingEvent]] = None + self.event_answer: Optional[AsyncIterator[PairingEventAnswer]] = None + self.device = device + self.config = config + + def pairing_config_factory(connection: BumbleConnection) -> PairingConfig: + return PairingConfig( + sc=config.pairing_sc_enable, + mitm=config.pairing_mitm_enable, + bonding=config.pairing_bonding_enable, + delegate=PairingDelegate( + connection, + self, + io_capability=config.io_capability, + local_initiator_key_distribution=config.smp_local_initiator_key_distribution, + local_responder_key_distribution=config.smp_local_responder_key_distribution, + ), + ) + + self.device.pairing_config_factory = pairing_config_factory + + @utils.rpc + async def OnPairing( + self, request: AsyncIterator[PairingEventAnswer], context: grpc.ServicerContext + ) -> AsyncGenerator[PairingEvent, None]: + self.log.info('OnPairing') + + if self.event_queue is not None: + raise RuntimeError('already streaming pairing events') + + if len(self.device.connections): + raise RuntimeError( + 'the `OnPairing` method shall be initiated before establishing any connections.' + ) + + self.event_queue = asyncio.Queue() + self.event_answer = request + + try: + while event := await self.event_queue.get(): + yield event + + finally: + self.event_queue = None + self.event_answer = None + + @utils.rpc + async def Secure( + self, request: SecureRequest, context: grpc.ServicerContext + ) -> SecureResponse: + connection_handle = int.from_bytes(request.connection.cookie.value, 'big') + self.log.info(f"Secure: {connection_handle}") + + connection = self.device.lookup_connection(connection_handle) + assert connection + + oneof = request.WhichOneof('level') + level = getattr(request, oneof) + assert {BT_BR_EDR_TRANSPORT: 'classic', BT_LE_TRANSPORT: 'le'}[ + connection.transport + ] == oneof + + # security level already reached + if self.reached_security_level(connection, level): + return SecureResponse(success=empty_pb2.Empty()) + + # trigger pairing if needed + if self.need_pairing(connection, level): + try: + self.log.info('Pair...') + + if ( + connection.transport == BT_LE_TRANSPORT + and connection.role == BT_PERIPHERAL_ROLE + ): + wait_for_security: asyncio.Future[ + bool + ] = asyncio.get_running_loop().create_future() + connection.on("pairing", lambda *_: wait_for_security.set_result(True)) # type: ignore + connection.on("pairing_failure", wait_for_security.set_exception) + + connection.request_pairing() + + await wait_for_security + else: + await connection.pair() + + self.log.info('Paired') + except asyncio.CancelledError: + self.log.warning("Connection died during encryption") + return SecureResponse(connection_died=empty_pb2.Empty()) + except (HCI_Error, ProtocolError) as e: + self.log.warning(f"Pairing failure: {e}") + return SecureResponse(pairing_failure=empty_pb2.Empty()) + + # trigger authentication if needed + if self.need_authentication(connection, level): + try: + self.log.info('Authenticate...') + await connection.authenticate() + self.log.info('Authenticated') + except asyncio.CancelledError: + self.log.warning("Connection died during authentication") + return SecureResponse(connection_died=empty_pb2.Empty()) + except (HCI_Error, ProtocolError) as e: + self.log.warning(f"Authentication failure: {e}") + return SecureResponse(authentication_failure=empty_pb2.Empty()) + + # trigger encryption if needed + if self.need_encryption(connection, level): + try: + self.log.info('Encrypt...') + await connection.encrypt() + self.log.info('Encrypted') + except asyncio.CancelledError: + self.log.warning("Connection died during encryption") + return SecureResponse(connection_died=empty_pb2.Empty()) + except (HCI_Error, ProtocolError) as e: + self.log.warning(f"Encryption failure: {e}") + return SecureResponse(encryption_failure=empty_pb2.Empty()) + + # security level has been reached ? + if self.reached_security_level(connection, level): + return SecureResponse(success=empty_pb2.Empty()) + return SecureResponse(not_reached=empty_pb2.Empty()) + + @utils.rpc + async def WaitSecurity( + self, request: WaitSecurityRequest, context: grpc.ServicerContext + ) -> WaitSecurityResponse: + connection_handle = int.from_bytes(request.connection.cookie.value, 'big') + self.log.info(f"WaitSecurity: {connection_handle}") + + connection = self.device.lookup_connection(connection_handle) + assert connection + + assert request.level + level = request.level + assert {BT_BR_EDR_TRANSPORT: 'classic', BT_LE_TRANSPORT: 'le'}[ + connection.transport + ] == request.level_variant() + + wait_for_security: asyncio.Future[ + str + ] = asyncio.get_running_loop().create_future() + authenticate_task: Optional[asyncio.Future[None]] = None + + async def authenticate() -> None: + assert connection + if (encryption := connection.encryption) != 0: + self.log.debug('Disable encryption...') + try: + await connection.encrypt(enable=False) + except: + pass + self.log.debug('Disable encryption: done') + + self.log.debug('Authenticate...') + await connection.authenticate() + self.log.debug('Authenticate: done') + + if encryption != 0 and connection.encryption != encryption: + self.log.debug('Re-enable encryption...') + await connection.encrypt() + self.log.debug('Re-enable encryption: done') + + def set_failure(name: str) -> Callable[..., None]: + def wrapper(*args: Any) -> None: + self.log.info(f'Wait for security: error `{name}`: {args}') + wait_for_security.set_result(name) + + return wrapper + + def try_set_success(*_: Any) -> None: + assert connection + if self.reached_security_level(connection, level): + self.log.info('Wait for security: done') + wait_for_security.set_result('success') + + def on_encryption_change(*_: Any) -> None: + assert connection + if self.reached_security_level(connection, level): + self.log.info('Wait for security: done') + wait_for_security.set_result('success') + elif ( + connection.transport == BT_BR_EDR_TRANSPORT + and self.need_authentication(connection, level) + ): + nonlocal authenticate_task + if authenticate_task is None: + authenticate_task = asyncio.create_task(authenticate()) + + listeners: Dict[str, Callable[..., None]] = { + 'disconnection': set_failure('connection_died'), + 'pairing_failure': set_failure('pairing_failure'), + 'connection_authentication_failure': set_failure('authentication_failure'), + 'connection_encryption_failure': set_failure('encryption_failure'), + 'pairing': try_set_success, + 'connection_authentication': try_set_success, + 'connection_encryption_change': on_encryption_change, + } + + # register event handlers + for event, listener in listeners.items(): + connection.on(event, listener) + + # security level already reached + if self.reached_security_level(connection, level): + return WaitSecurityResponse(success=empty_pb2.Empty()) + + self.log.info('Wait for security...') + kwargs = {} + kwargs[await wait_for_security] = empty_pb2.Empty() + + # remove event handlers + for event, listener in listeners.items(): + connection.remove_listener(event, listener) # type: ignore + + # wait for `authenticate` to finish if any + if authenticate_task is not None: + self.log.info('Wait for authentication...') + try: + await authenticate_task # type: ignore + except: + pass + self.log.info('Authenticated') + + return WaitSecurityResponse(**kwargs) + + def reached_security_level( + self, connection: BumbleConnection, level: Union[SecurityLevel, LESecurityLevel] + ) -> bool: + self.log.debug( + str( + { + 'level': level, + 'encryption': connection.encryption, + 'authenticated': connection.authenticated, + 'sc': connection.sc, + 'link_key_type': connection.link_key_type, + } + ) + ) + + if isinstance(level, LESecurityLevel): + return LE_LEVEL_REACHED[level](connection) + + return BR_LEVEL_REACHED[level](connection) + + def need_pairing(self, connection: BumbleConnection, level: int) -> bool: + if connection.transport == BT_LE_TRANSPORT: + return level >= LE_LEVEL3 and not connection.authenticated + return False + + def need_authentication(self, connection: BumbleConnection, level: int) -> bool: + if connection.transport == BT_LE_TRANSPORT: + return False + if level == LEVEL2 and connection.encryption != 0: + return not connection.authenticated + return level >= LEVEL2 and not connection.authenticated + + def need_encryption(self, connection: BumbleConnection, level: int) -> bool: + # TODO(abel): need to support MITM + if connection.transport == BT_LE_TRANSPORT: + return level == LE_LEVEL2 and not connection.encryption + return level >= LEVEL2 and not connection.encryption + + +class SecurityStorageService(SecurityStorageServicer): + def __init__(self, device: Device, config: Config) -> None: + self.log = utils.BumbleServerLoggerAdapter( + logging.getLogger(), {'service_name': 'SecurityStorage', 'device': device} + ) + self.device = device + self.config = config + + @utils.rpc + async def IsBonded( + self, request: IsBondedRequest, context: grpc.ServicerContext + ) -> wrappers_pb2.BoolValue: + address = utils.address_from_request(request, request.WhichOneof("address")) + self.log.info(f"IsBonded: {address}") + + if self.device.keystore is not None: + is_bonded = await self.device.keystore.get(str(address)) is not None + else: + is_bonded = False + + return BoolValue(value=is_bonded) + + @utils.rpc + async def DeleteBond( + self, request: DeleteBondRequest, context: grpc.ServicerContext + ) -> empty_pb2.Empty: + address = utils.address_from_request(request, request.WhichOneof("address")) + self.log.info(f"DeleteBond: {address}") + + if self.device.keystore is not None: + with suppress(KeyError): + await self.device.keystore.delete(str(address)) + + return empty_pb2.Empty() diff --git a/bumble/pandora/utils.py b/bumble/pandora/utils.py new file mode 100644 index 0000000..c07a5bc --- /dev/null +++ b/bumble/pandora/utils.py @@ -0,0 +1,112 @@ +# Copyright 2022 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import contextlib +import functools +import grpc +import inspect +import logging + +from bumble.device import Device +from bumble.hci import Address +from google.protobuf.message import Message # pytype: disable=pyi-error +from typing import Any, Dict, Generator, MutableMapping, Optional, Tuple + +ADDRESS_TYPES: Dict[str, int] = { + "public": Address.PUBLIC_DEVICE_ADDRESS, + "random": Address.RANDOM_DEVICE_ADDRESS, + "public_identity": Address.PUBLIC_IDENTITY_ADDRESS, + "random_static_identity": Address.RANDOM_IDENTITY_ADDRESS, +} + + +def address_from_request(request: Message, field: Optional[str]) -> Address: + if field is None: + return Address.ANY + return Address(bytes(reversed(getattr(request, field))), ADDRESS_TYPES[field]) + + +class BumbleServerLoggerAdapter(logging.LoggerAdapter): # type: ignore + """Formats logs from the PandoraClient.""" + + def process( + self, msg: str, kwargs: MutableMapping[str, Any] + ) -> Tuple[str, MutableMapping[str, Any]]: + assert self.extra + service_name = self.extra['service_name'] + assert isinstance(service_name, str) + device = self.extra['device'] + assert isinstance(device, Device) + addr_bytes = bytes( + reversed(bytes(device.public_address)) + ) # pytype: disable=attribute-error + addr = ':'.join([f'{x:02X}' for x in addr_bytes[4:]]) + return (f'[bumble.{service_name}:{addr}] {msg}', kwargs) + + +@contextlib.contextmanager +def exception_to_rpc_error( + context: grpc.ServicerContext, +) -> Generator[None, None, None]: + try: + yield None + except NotImplementedError as e: + context.set_code(grpc.StatusCode.UNIMPLEMENTED) # type: ignore + context.set_details(str(e)) # type: ignore + except ValueError as e: + context.set_code(grpc.StatusCode.INVALID_ARGUMENT) # type: ignore + context.set_details(str(e)) # type: ignore + except RuntimeError as e: + context.set_code(grpc.StatusCode.ABORTED) # type: ignore + context.set_details(str(e)) # type: ignore + + +# Decorate an RPC servicer method with a wrapper that transform exceptions to gRPC errors. +def rpc(func: Any) -> Any: + @functools.wraps(func) + async def asyncgen_wrapper( + self: Any, request: Any, context: grpc.ServicerContext + ) -> Any: + with exception_to_rpc_error(context): + async for v in func(self, request, context): + yield v + + @functools.wraps(func) + async def async_wrapper( + self: Any, request: Any, context: grpc.ServicerContext + ) -> Any: + with exception_to_rpc_error(context): + return await func(self, request, context) + + @functools.wraps(func) + def gen_wrapper(self: Any, request: Any, context: grpc.ServicerContext) -> Any: + with exception_to_rpc_error(context): + for v in func(self, request, context): + yield v + + @functools.wraps(func) + def wrapper(self: Any, request: Any, context: grpc.ServicerContext) -> Any: + with exception_to_rpc_error(context): + return func(self, request, context) + + if inspect.isasyncgenfunction(func): + return asyncgen_wrapper + + if inspect.iscoroutinefunction(func): + return async_wrapper + + if inspect.isgenerator(func): + return gen_wrapper + + return wrapper diff --git a/bumble/profiles/asha_service.py b/bumble/profiles/asha_service.py index 1b1e93a..6898397 100644 --- a/bumble/profiles/asha_service.py +++ b/bumble/profiles/asha_service.py @@ -103,7 +103,7 @@ class AshaService(TemplateService): self.read_only_properties_characteristic = Characteristic( GATT_ASHA_READ_ONLY_PROPERTIES_CHARACTERISTIC, - Characteristic.READ, + Characteristic.Properties.READ, Characteristic.READABLE, bytes( [ @@ -120,19 +120,20 @@ class AshaService(TemplateService): self.audio_control_point_characteristic = Characteristic( GATT_ASHA_AUDIO_CONTROL_POINT_CHARACTERISTIC, - Characteristic.WRITE | Characteristic.WRITE_WITHOUT_RESPONSE, + Characteristic.Properties.WRITE + | Characteristic.Properties.WRITE_WITHOUT_RESPONSE, Characteristic.WRITEABLE, CharacteristicValue(write=on_audio_control_point_write), ) self.audio_status_characteristic = Characteristic( GATT_ASHA_AUDIO_STATUS_CHARACTERISTIC, - Characteristic.READ | Characteristic.NOTIFY, + Characteristic.Properties.READ | Characteristic.Properties.NOTIFY, Characteristic.READABLE, bytes([0]), ) self.volume_characteristic = Characteristic( GATT_ASHA_VOLUME_CHARACTERISTIC, - Characteristic.WRITE_WITHOUT_RESPONSE, + Characteristic.Properties.WRITE_WITHOUT_RESPONSE, Characteristic.WRITEABLE, CharacteristicValue(write=on_volume_write), ) @@ -151,7 +152,7 @@ class AshaService(TemplateService): self.psm = self.device.register_l2cap_channel_server(self.psm, on_coc, 8) self.le_psm_out_characteristic = Characteristic( GATT_ASHA_LE_PSM_OUT_CHARACTERISTIC, - Characteristic.READ, + Characteristic.Properties.READ, Characteristic.READABLE, struct.pack('<H', self.psm), ) diff --git a/bumble/profiles/battery_service.py b/bumble/profiles/battery_service.py index f6ccc10..211fee0 100644 --- a/bumble/profiles/battery_service.py +++ b/bumble/profiles/battery_service.py @@ -36,7 +36,7 @@ class BatteryService(TemplateService): self.battery_level_characteristic = PackedCharacteristicAdapter( Characteristic( GATT_BATTERY_LEVEL_CHARACTERISTIC, - Characteristic.READ | Characteristic.NOTIFY, + Characteristic.Properties.READ | Characteristic.Properties.NOTIFY, Characteristic.READABLE, CharacteristicValue(read=read_battery_level), ), diff --git a/bumble/profiles/device_information_service.py b/bumble/profiles/device_information_service.py index 2ad9cae..09bfd6c 100644 --- a/bumble/profiles/device_information_service.py +++ b/bumble/profiles/device_information_service.py @@ -63,7 +63,9 @@ class DeviceInformationService(TemplateService): # TODO: pnp_id ): characteristics = [ - Characteristic(uuid, Characteristic.READ, Characteristic.READABLE, field) + Characteristic( + uuid, Characteristic.Properties.READ, Characteristic.READABLE, field + ) for (field, uuid) in ( (manufacturer_name, GATT_MANUFACTURER_NAME_STRING_CHARACTERISTIC), (model_number, GATT_MODEL_NUMBER_STRING_CHARACTERISTIC), @@ -79,7 +81,7 @@ class DeviceInformationService(TemplateService): characteristics.append( Characteristic( GATT_SYSTEM_ID_CHARACTERISTIC, - Characteristic.READ, + Characteristic.Properties.READ, Characteristic.READABLE, self.pack_system_id(*system_id), ) @@ -89,7 +91,7 @@ class DeviceInformationService(TemplateService): characteristics.append( Characteristic( GATT_REGULATORY_CERTIFICATION_DATA_LIST_CHARACTERISTIC, - Characteristic.READ, + Characteristic.Properties.READ, Characteristic.READABLE, ieee_regulatory_certification_data_list, ) diff --git a/bumble/profiles/heart_rate_service.py b/bumble/profiles/heart_rate_service.py index 5755535..c7d3018 100644 --- a/bumble/profiles/heart_rate_service.py +++ b/bumble/profiles/heart_rate_service.py @@ -152,7 +152,7 @@ class HeartRateService(TemplateService): self.heart_rate_measurement_characteristic = DelegatedCharacteristicAdapter( Characteristic( GATT_HEART_RATE_MEASUREMENT_CHARACTERISTIC, - Characteristic.NOTIFY, + Characteristic.Properties.NOTIFY, 0, CharacteristicValue(read=read_heart_rate_measurement), ), @@ -164,7 +164,7 @@ class HeartRateService(TemplateService): if body_sensor_location is not None: self.body_sensor_location_characteristic = Characteristic( GATT_BODY_SENSOR_LOCATION_CHARACTERISTIC, - Characteristic.READ, + Characteristic.Properties.READ, Characteristic.READABLE, bytes([int(body_sensor_location)]), ) @@ -182,7 +182,7 @@ class HeartRateService(TemplateService): self.heart_rate_control_point_characteristic = PackedCharacteristicAdapter( Characteristic( GATT_HEART_RATE_CONTROL_POINT_CHARACTERISTIC, - Characteristic.WRITE, + Characteristic.Properties.WRITE, Characteristic.WRITEABLE, CharacteristicValue(write=write_heart_rate_control_point_value), ), diff --git a/bumble/rfcomm.py b/bumble/rfcomm.py index dbb2795..71be8dc 100644 --- a/bumble/rfcomm.py +++ b/bumble/rfcomm.py @@ -439,7 +439,7 @@ class DLC(EventEmitter): logger.debug( f'<<< Credits [{self.dlci}]: ' - f'received {credits}, total={self.tx_credits}' + f'received {received_credits}, total={self.tx_credits}' ) data = data[1:] diff --git a/bumble/smp.py b/bumble/smp.py index 1714743..f3fbf27 100644 --- a/bumble/smp.py +++ b/bumble/smp.py @@ -26,12 +26,27 @@ from __future__ import annotations import logging import asyncio import secrets -from typing import Dict, Optional, Type +from typing import ( + TYPE_CHECKING, + Any, + Awaitable, + Callable, + Dict, + List, + Optional, + Tuple, + Type, +) from pyee import EventEmitter from .colors import color -from .hci import Address, HCI_LE_Enable_Encryption_Command, HCI_Object, key_with_value +from .hci import ( + Address, + HCI_LE_Enable_Encryption_Command, + HCI_Object, + key_with_value, +) from .core import ( BT_BR_EDR_TRANSPORT, BT_CENTRAL_ROLE, @@ -42,6 +57,10 @@ from .core import ( from .keys import PairingKeys from . import crypto +if TYPE_CHECKING: + from bumble.device import Connection, Device + from bumble.pairing import PairingConfig + # ----------------------------------------------------------------------------- # Logging @@ -175,7 +194,7 @@ SMP_CTKD_H7_BRLE_SALT = bytes.fromhex('00000000000000000000000000000000746D7032' # ----------------------------------------------------------------------------- # Utils # ----------------------------------------------------------------------------- -def error_name(error_code): +def error_name(error_code: int) -> str: return name_or_number(SMP_ERROR_NAMES, error_code) @@ -188,11 +207,12 @@ class SMP_Command: ''' smp_classes: Dict[int, Type[SMP_Command]] = {} + fields: Any code = 0 name = '' @staticmethod - def from_bytes(pdu): + def from_bytes(pdu: bytes) -> "SMP_Command": code = pdu[0] cls = SMP_Command.smp_classes.get(code) @@ -208,11 +228,11 @@ class SMP_Command: return self @staticmethod - def command_name(code): + def command_name(code: int) -> str: return name_or_number(SMP_COMMAND_NAMES, code) @staticmethod - def auth_req_str(value): + def auth_req_str(value: int) -> str: bonding_flags = value & 3 mitm = (value >> 2) & 1 sc = (value >> 3) & 1 @@ -225,12 +245,12 @@ class SMP_Command: ) @staticmethod - def io_capability_name(io_capability): + def io_capability_name(io_capability: int) -> str: return name_or_number(SMP_IO_CAPABILITY_NAMES, io_capability) @staticmethod - def key_distribution_str(value): - key_types = [] + def key_distribution_str(value: int) -> str: + key_types: List[str] = [] if value & SMP_ENC_KEY_DISTRIBUTION_FLAG: key_types.append('ENC') if value & SMP_ID_KEY_DISTRIBUTION_FLAG: @@ -242,7 +262,7 @@ class SMP_Command: return ','.join(key_types) @staticmethod - def keypress_notification_type_name(notification_type): + def keypress_notification_type_name(notification_type: int) -> str: return name_or_number(SMP_KEYPRESS_NOTIFICATION_TYPE_NAMES, notification_type) @staticmethod @@ -263,14 +283,14 @@ class SMP_Command: return inner - def __init__(self, pdu=None, **kwargs): + def __init__(self, pdu: Optional[bytes] = None, **kwargs: Any) -> None: if hasattr(self, 'fields') and kwargs: HCI_Object.init_from_fields(self, self.fields, kwargs) if pdu is None: pdu = bytes([self.code]) + HCI_Object.dict_to_bytes(kwargs, self.fields) self.pdu = pdu - def init_from_bytes(self, pdu, offset): + def init_from_bytes(self, pdu: bytes, offset: int) -> None: return HCI_Object.init_from_bytes(self, pdu, offset, self.fields) def to_bytes(self): @@ -311,6 +331,13 @@ class SMP_Pairing_Request_Command(SMP_Command): See Bluetooth spec @ Vol 3, Part H - 3.5.1 Pairing Request ''' + io_capability: int + oob_data_flag: int + auth_req: int + maximum_encryption_key_size: int + initiator_key_distribution: int + responder_key_distribution: int + # ----------------------------------------------------------------------------- @SMP_Command.subclass( @@ -334,6 +361,13 @@ class SMP_Pairing_Response_Command(SMP_Command): See Bluetooth spec @ Vol 3, Part H - 3.5.2 Pairing Response ''' + io_capability: int + oob_data_flag: int + auth_req: int + maximum_encryption_key_size: int + initiator_key_distribution: int + responder_key_distribution: int + # ----------------------------------------------------------------------------- @SMP_Command.subclass([('confirm_value', 16)]) @@ -342,6 +376,8 @@ class SMP_Pairing_Confirm_Command(SMP_Command): See Bluetooth spec @ Vol 3, Part H - 3.5.3 Pairing Confirm ''' + confirm_value: bytes + # ----------------------------------------------------------------------------- @SMP_Command.subclass([('random_value', 16)]) @@ -350,6 +386,8 @@ class SMP_Pairing_Random_Command(SMP_Command): See Bluetooth spec @ Vol 3, Part H - 3.5.4 Pairing Random ''' + random_value: bytes + # ----------------------------------------------------------------------------- @SMP_Command.subclass([('reason', {'size': 1, 'mapper': error_name})]) @@ -358,6 +396,8 @@ class SMP_Pairing_Failed_Command(SMP_Command): See Bluetooth spec @ Vol 3, Part H - 3.5.5 Pairing Failed ''' + reason: int + # ----------------------------------------------------------------------------- @SMP_Command.subclass([('public_key_x', 32), ('public_key_y', 32)]) @@ -366,6 +406,9 @@ class SMP_Pairing_Public_Key_Command(SMP_Command): See Bluetooth spec @ Vol 3, Part H - 3.5.6 Pairing Public Key ''' + public_key_x: bytes + public_key_y: bytes + # ----------------------------------------------------------------------------- @SMP_Command.subclass( @@ -378,6 +421,8 @@ class SMP_Pairing_DHKey_Check_Command(SMP_Command): See Bluetooth spec @ Vol 3, Part H - 3.5.7 Pairing DHKey Check ''' + dhkey_check: bytes + # ----------------------------------------------------------------------------- @SMP_Command.subclass( @@ -393,6 +438,8 @@ class SMP_Pairing_Keypress_Notification_Command(SMP_Command): See Bluetooth spec @ Vol 3, Part H - 3.5.8 Keypress Notification ''' + notification_type: int + # ----------------------------------------------------------------------------- @SMP_Command.subclass([('long_term_key', 16)]) @@ -401,6 +448,8 @@ class SMP_Encryption_Information_Command(SMP_Command): See Bluetooth spec @ Vol 3, Part H - 3.6.2 Encryption Information ''' + long_term_key: bytes + # ----------------------------------------------------------------------------- @SMP_Command.subclass([('ediv', 2), ('rand', 8)]) @@ -409,6 +458,9 @@ class SMP_Master_Identification_Command(SMP_Command): See Bluetooth spec @ Vol 3, Part H - 3.6.3 Master Identification ''' + ediv: int + rand: bytes + # ----------------------------------------------------------------------------- @SMP_Command.subclass([('identity_resolving_key', 16)]) @@ -417,6 +469,8 @@ class SMP_Identity_Information_Command(SMP_Command): See Bluetooth spec @ Vol 3, Part H - 3.6.4 Identity Information ''' + identity_resolving_key: bytes + # ----------------------------------------------------------------------------- @SMP_Command.subclass( @@ -430,6 +484,9 @@ class SMP_Identity_Address_Information_Command(SMP_Command): See Bluetooth spec @ Vol 3, Part H - 3.6.5 Identity Address Information ''' + addr_type: int + bd_addr: Address + # ----------------------------------------------------------------------------- @SMP_Command.subclass([('signature_key', 16)]) @@ -438,6 +495,8 @@ class SMP_Signing_Information_Command(SMP_Command): See Bluetooth spec @ Vol 3, Part H - 3.6.6 Signing Information ''' + signature_key: bytes + # ----------------------------------------------------------------------------- @SMP_Command.subclass( @@ -450,9 +509,11 @@ class SMP_Security_Request_Command(SMP_Command): See Bluetooth spec @ Vol 3, Part H - 3.6.7 Security Request ''' + auth_req: int + # ----------------------------------------------------------------------------- -def smp_auth_req(bonding, mitm, sc, keypress, ct2): +def smp_auth_req(bonding: bool, mitm: bool, sc: bool, keypress: bool, ct2: bool) -> int: value = 0 if bonding: value |= SMP_BONDING_AUTHREQ @@ -476,7 +537,7 @@ class AddressResolver: address_bytes = bytes(address) hash_part = address_bytes[0:3] prand = address_bytes[3:6] - for (irk, resolved_address) in self.resolving_keys: + for irk, resolved_address in self.resolving_keys: local_hash = crypto.ah(irk, prand) if local_hash == hash_part: # Match! @@ -492,86 +553,6 @@ class AddressResolver: # ----------------------------------------------------------------------------- -class PairingDelegate: - NO_OUTPUT_NO_INPUT = SMP_NO_INPUT_NO_OUTPUT_IO_CAPABILITY - KEYBOARD_INPUT_ONLY = SMP_KEYBOARD_ONLY_IO_CAPABILITY - DISPLAY_OUTPUT_ONLY = SMP_DISPLAY_ONLY_IO_CAPABILITY - DISPLAY_OUTPUT_AND_YES_NO_INPUT = SMP_DISPLAY_YES_NO_IO_CAPABILITY - DISPLAY_OUTPUT_AND_KEYBOARD_INPUT = SMP_KEYBOARD_DISPLAY_IO_CAPABILITY - DEFAULT_KEY_DISTRIBUTION: int = ( - SMP_ENC_KEY_DISTRIBUTION_FLAG | SMP_ID_KEY_DISTRIBUTION_FLAG - ) - - def __init__( - self, - io_capability: int = NO_OUTPUT_NO_INPUT, - local_initiator_key_distribution: int = DEFAULT_KEY_DISTRIBUTION, - local_responder_key_distribution: int = DEFAULT_KEY_DISTRIBUTION, - ) -> None: - self.io_capability = io_capability - self.local_initiator_key_distribution = local_initiator_key_distribution - self.local_responder_key_distribution = local_responder_key_distribution - - async def accept(self) -> bool: - return True - - async def confirm(self) -> bool: - return True - - # pylint: disable-next=unused-argument - async def compare_numbers(self, number: int, digits: int) -> bool: - return True - - 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 - - async def key_distribution_response( - self, peer_initiator_key_distribution, peer_responder_key_distribution - ): - return ( - (peer_initiator_key_distribution & self.local_initiator_key_distribution), - (peer_responder_key_distribution & self.local_responder_key_distribution), - ) - - -# ----------------------------------------------------------------------------- -class PairingConfig: - def __init__( - self, - sc: bool = True, - mitm: bool = True, - bonding: bool = True, - delegate: Optional[PairingDelegate] = None, - ) -> None: - self.sc = sc - self.mitm = mitm - self.bonding = bonding - self.delegate = delegate or PairingDelegate() - - def __str__(self): - io_capability_str = SMP_Command.io_capability_name(self.delegate.io_capability) - return ( - f'PairingConfig(sc={self.sc}, ' - f'mitm={self.mitm}, bonding={self.bonding}, ' - f'delegate[{io_capability_str}])' - ) - - -# ----------------------------------------------------------------------------- class Session: # Pairing methods JUST_WORKS = 0 @@ -645,11 +626,17 @@ class Session: }, } - def __init__(self, manager, connection, pairing_config): + def __init__( + self, + manager: Manager, + connection: Connection, + pairing_config: PairingConfig, + is_initiator: bool, + ) -> None: self.manager = manager self.connection = connection - self.preq = None - self.pres = None + self.preq: Optional[bytes] = None + self.pres: Optional[bytes] = None self.ea = None self.eb = None self.tk = bytes(16) @@ -659,32 +646,32 @@ class Session: self.ltk_ediv = 0 self.ltk_rand = bytes(8) self.link_key = None - self.initiator_key_distribution = 0 - self.responder_key_distribution = 0 - self.peer_random_value = None - self.peer_public_key_x = bytes(32) + self.initiator_key_distribution: int = 0 + self.responder_key_distribution: int = 0 + self.peer_random_value: Optional[bytes] = None + self.peer_public_key_x: bytes = bytes(32) self.peer_public_key_y = bytes(32) self.peer_ltk = None self.peer_ediv = None - self.peer_rand = None + self.peer_rand: Optional[bytes] = None self.peer_identity_resolving_key = None - self.peer_bd_addr = None + self.peer_bd_addr: Optional[Address] = None self.peer_signature_key = None - self.peer_expected_distributions = [] + self.peer_expected_distributions: List[Type[SMP_Command]] = [] self.dh_key = None self.confirm_value = None - self.passkey = None + self.passkey: Optional[int] = None self.passkey_ready = asyncio.Event() self.passkey_step = 0 self.passkey_display = False self.pairing_method = 0 self.pairing_config = pairing_config - self.wait_before_continuing = None + self.wait_before_continuing: Optional[asyncio.Future[None]] = None self.completed = False - self.ctkd_task = None + self.ctkd_task: Optional[Awaitable[None]] = None # Decide if we're the initiator or the responder - self.is_initiator = connection.role == BT_CENTRAL_ROLE + self.is_initiator = is_initiator self.is_responder = not self.is_initiator # Listen for connection events @@ -699,7 +686,9 @@ class Session: # Create a future that can be used to wait for the session to complete if self.is_initiator: - self.pairing_result = asyncio.get_running_loop().create_future() + self.pairing_result: Optional[ + asyncio.Future[None] + ] = asyncio.get_running_loop().create_future() else: self.pairing_result = None @@ -712,11 +701,11 @@ class Session: ) # Authentication Requirements Flags - Vol 3, Part H, Figure 3.3 - self.bonding = pairing_config.bonding - self.sc = pairing_config.sc - self.mitm = pairing_config.mitm + self.bonding: bool = pairing_config.bonding + self.sc: bool = pairing_config.sc + self.mitm: bool = pairing_config.mitm self.keypress = False - self.ct2 = False + self.ct2: bool = False # I/O Capabilities self.io_capability = pairing_config.delegate.io_capability @@ -740,34 +729,35 @@ class Session: self.iat = 1 if peer_address.is_random else 0 @property - def pkx(self): + def pkx(self) -> Tuple[bytes, bytes]: return (bytes(reversed(self.manager.ecc_key.x)), self.peer_public_key_x) @property - def pka(self): + def pka(self) -> bytes: return self.pkx[0 if self.is_initiator else 1] @property - def pkb(self): + def pkb(self) -> bytes: return self.pkx[0 if self.is_responder else 1] @property - def nx(self): + def nx(self) -> Tuple[bytes, bytes]: + assert self.peer_random_value return (self.r, self.peer_random_value) @property - def na(self): + def na(self) -> bytes: return self.nx[0 if self.is_initiator else 1] @property - def nb(self): + def nb(self) -> bytes: return self.nx[0 if self.is_responder else 1] @property - def auth_req(self): + def auth_req(self) -> int: return smp_auth_req(self.bonding, self.mitm, self.sc, self.keypress, self.ct2) - def get_long_term_key(self, rand, ediv): + def get_long_term_key(self, rand: bytes, ediv: int) -> Optional[bytes]: if not self.sc and not self.completed: if rand == self.ltk_rand and ediv == self.ltk_ediv: return self.stk @@ -777,13 +767,13 @@ class Session: return None def decide_pairing_method( - self, auth_req, initiator_io_capability, responder_io_capability - ): + self, auth_req: int, initiator_io_capability: int, responder_io_capability: int + ) -> None: if (not self.mitm) and (auth_req & SMP_MITM_AUTHREQ == 0): self.pairing_method = self.JUST_WORKS return - details = self.PAIRING_METHODS[initiator_io_capability][responder_io_capability] + details = self.PAIRING_METHODS[initiator_io_capability][responder_io_capability] # type: ignore[index] if isinstance(details, tuple) and len(details) == 2: # One entry for legacy pairing and one for secure connections details = details[1 if self.sc else 0] @@ -795,7 +785,9 @@ class Session: self.pairing_method = details[0] self.passkey_display = details[1 if self.is_initiator else 2] - def check_expected_value(self, expected, received, error): + def check_expected_value( + self, expected: bytes, received: bytes, error: int + ) -> bool: logger.debug(f'expected={expected.hex()} got={received.hex()}') if expected != received: logger.info(color('pairing confirm/check mismatch', 'red')) @@ -803,8 +795,8 @@ class Session: return False return True - def prompt_user_for_confirmation(self, next_steps): - async def prompt(): + def prompt_user_for_confirmation(self, next_steps: Callable[[], None]) -> None: + async def prompt() -> None: logger.debug('ask for confirmation') try: response = await self.pairing_config.delegate.confirm() @@ -818,8 +810,10 @@ class Session: self.connection.abort_on('disconnection', prompt()) - def prompt_user_for_numeric_comparison(self, code, next_steps): - async def prompt(): + def prompt_user_for_numeric_comparison( + self, code: int, next_steps: Callable[[], None] + ) -> None: + async def prompt() -> None: logger.debug(f'verification code: {code}') try: response = await self.pairing_config.delegate.compare_numbers( @@ -835,11 +829,15 @@ class Session: self.connection.abort_on('disconnection', prompt()) - def prompt_user_for_number(self, next_steps): - async def prompt(): + def prompt_user_for_number(self, next_steps: Callable[[int], None]) -> None: + async def prompt() -> None: logger.debug('prompting user for passkey') try: passkey = await self.pairing_config.delegate.get_number() + if passkey is None: + logger.debug('Passkey request rejected') + self.send_pairing_failed(SMP_PASSKEY_ENTRY_FAILED_ERROR) + return logger.debug(f'user input: {passkey}') next_steps(passkey) except Exception as error: @@ -848,9 +846,10 @@ class Session: self.connection.abort_on('disconnection', prompt()) - def display_passkey(self): + def display_passkey(self) -> None: # Generate random Passkey/PIN code self.passkey = secrets.randbelow(1000000) + assert self.passkey is not None logger.debug(f'Pairing PIN CODE: {self.passkey:06}') self.passkey_ready.set() @@ -864,9 +863,9 @@ class Session: self.pairing_config.delegate.display_number(self.passkey, digits=6), ) - def input_passkey(self, next_steps=None): + def input_passkey(self, next_steps: Optional[Callable[[], None]] = None) -> None: # Prompt the user for the passkey displayed on the peer - def after_input(passkey): + def after_input(passkey: int) -> None: self.passkey = passkey if not self.sc: @@ -880,7 +879,9 @@ class Session: self.prompt_user_for_number(after_input) - def display_or_input_passkey(self, next_steps=None): + def display_or_input_passkey( + self, next_steps: Optional[Callable[[], None]] = None + ) -> None: if self.passkey_display: self.display_passkey() if next_steps is not None: @@ -888,14 +889,14 @@ class Session: else: self.input_passkey(next_steps) - def send_command(self, command): + def send_command(self, command: SMP_Command) -> None: self.manager.send_command(self.connection, command) - def send_pairing_failed(self, error): + def send_pairing_failed(self, error: int) -> None: self.send_command(SMP_Pairing_Failed_Command(reason=error)) self.on_pairing_failure(error) - def send_pairing_request_command(self): + def send_pairing_request_command(self) -> None: self.manager.on_session_start(self) command = SMP_Pairing_Request_Command( @@ -909,7 +910,7 @@ class Session: self.preq = bytes(command) self.send_command(command) - def send_pairing_response_command(self): + def send_pairing_response_command(self) -> None: response = SMP_Pairing_Response_Command( io_capability=self.io_capability, oob_data_flag=0, @@ -921,18 +922,19 @@ class Session: self.pres = bytes(response) self.send_command(response) - def send_pairing_confirm_command(self): + def send_pairing_confirm_command(self) -> None: self.r = crypto.r() logger.debug(f'generated random: {self.r.hex()}') if self.sc: - async def next_steps(): + async def next_steps() -> None: if self.pairing_method in (self.JUST_WORKS, self.NUMERIC_COMPARISON): z = 0 elif self.pairing_method == self.PASSKEY: # We need a passkey await self.passkey_ready.wait() + assert self.passkey z = 0x80 + ((self.passkey >> self.passkey_step) & 1) else: @@ -963,10 +965,10 @@ class Session: self.send_command(SMP_Pairing_Confirm_Command(confirm_value=confirm_value)) - def send_pairing_random_command(self): + def send_pairing_random_command(self) -> None: self.send_command(SMP_Pairing_Random_Command(random_value=self.r)) - def send_public_key_command(self): + def send_public_key_command(self) -> None: self.send_command( SMP_Pairing_Public_Key_Command( public_key_x=bytes(reversed(self.manager.ecc_key.x)), @@ -974,18 +976,18 @@ class Session: ) ) - def send_pairing_dhkey_check_command(self): + def send_pairing_dhkey_check_command(self) -> None: self.send_command( SMP_Pairing_DHKey_Check_Command( dhkey_check=self.ea if self.is_initiator else self.eb ) ) - def start_encryption(self, key): + def start_encryption(self, key: bytes) -> None: # We can now encrypt the connection with the short term key, so that we can # distribute the long term and/or other keys over an encrypted connection self.manager.device.host.send_command_sync( - HCI_LE_Enable_Encryption_Command( + HCI_LE_Enable_Encryption_Command( # type: ignore[call-arg] connection_handle=self.connection.handle, random_number=bytes(8), encrypted_diversifier=0, @@ -993,7 +995,7 @@ class Session: ) ) - async def derive_ltk(self): + async def derive_ltk(self) -> None: link_key = await self.manager.device.get_link_key(self.connection.peer_address) assert link_key is not None ilk = ( @@ -1003,7 +1005,7 @@ class Session: ) self.ltk = crypto.h6(ilk, b'brle') - def distribute_keys(self): + def distribute_keys(self) -> None: # Distribute the keys as required if self.is_initiator: # CTKD: Derive LTK from LinkKey @@ -1103,7 +1105,7 @@ class Session: ) self.link_key = crypto.h6(ilk, b'lebr') - def compute_peer_expected_distributions(self, key_distribution_flags): + def compute_peer_expected_distributions(self, key_distribution_flags: int) -> None: # Set our expectations for what to wait for in the key distribution phase self.peer_expected_distributions = [] if not self.sc and self.connection.transport == BT_LE_TRANSPORT: @@ -1126,7 +1128,7 @@ class Session: f'{[c.__name__ for c in self.peer_expected_distributions]}' ) - def check_key_distribution(self, command_class): + def check_key_distribution(self, command_class: Type[SMP_Command]) -> None: # First, check that the connection is encrypted if not self.connection.is_encrypted: logger.warning( @@ -1154,7 +1156,7 @@ class Session: ) self.send_pairing_failed(SMP_UNSPECIFIED_REASON_ERROR) - async def pair(self): + async def pair(self) -> None: # Start pairing as an initiator # TODO: check that this session isn't already active @@ -1162,9 +1164,10 @@ class Session: self.send_pairing_request_command() # Wait for the pairing process to finish + assert self.pairing_result await self.connection.abort_on('disconnection', self.pairing_result) - def on_disconnection(self, _): + def on_disconnection(self, _: int) -> None: self.connection.remove_listener('disconnection', self.on_disconnection) self.connection.remove_listener( 'connection_encryption_change', self.on_connection_encryption_change @@ -1175,14 +1178,14 @@ class Session: ) self.manager.on_session_end(self) - def on_peer_key_distribution_complete(self): + def on_peer_key_distribution_complete(self) -> None: # The initiator can now send its keys if self.is_initiator: self.distribute_keys() self.connection.abort_on('disconnection', self.on_pairing()) - def on_connection_encryption_change(self): + def on_connection_encryption_change(self) -> None: if self.connection.is_encrypted: if self.is_responder: # The responder distributes its keys first, the initiator later @@ -1192,11 +1195,11 @@ class Session: if not self.peer_expected_distributions: self.on_peer_key_distribution_complete() - def on_connection_encryption_key_refresh(self): + def on_connection_encryption_key_refresh(self) -> None: # Do as if the connection had just been encrypted self.on_connection_encryption_change() - async def on_pairing(self): + async def on_pairing(self) -> None: logger.debug('pairing complete') if self.completed: @@ -1208,7 +1211,7 @@ class Session: self.pairing_result.set_result(None) # Use the peer address from the pairing protocol or the connection - if self.peer_bd_addr: + if self.peer_bd_addr is not None: peer_address = self.peer_bd_addr else: peer_address = self.connection.peer_address @@ -1257,7 +1260,7 @@ class Session: ) self.manager.on_pairing(self, peer_address, keys) - def on_pairing_failure(self, reason): + def on_pairing_failure(self, reason: int) -> None: logger.warning(f'pairing failure ({error_name(reason)})') if self.completed: @@ -1270,7 +1273,7 @@ class Session: self.pairing_result.set_exception(error) self.manager.on_pairing_failure(self, reason) - def on_smp_command(self, command): + def on_smp_command(self, command: SMP_Command) -> None: # Find the handler method handler_name = f'on_{command.name.lower()}' handler = getattr(self, handler_name, None) @@ -1286,12 +1289,16 @@ class Session: else: logger.error(color('SMP command not handled???', 'red')) - def on_smp_pairing_request_command(self, command): + def on_smp_pairing_request_command( + self, command: SMP_Pairing_Request_Command + ) -> None: self.connection.abort_on( 'disconnection', self.on_smp_pairing_request_command_async(command) ) - async def on_smp_pairing_request_command_async(self, command): + async def on_smp_pairing_request_command_async( + self, command: SMP_Pairing_Request_Command + ) -> None: # Check if the request should proceed accepted = await self.pairing_config.delegate.accept() if not accepted: @@ -1351,7 +1358,9 @@ class Session: ): self.distribute_keys() - def on_smp_pairing_response_command(self, command): + def on_smp_pairing_response_command( + self, command: SMP_Pairing_Response_Command + ) -> None: if self.is_responder: logger.warning(color('received pairing response as a responder', 'red')) return @@ -1402,7 +1411,9 @@ class Session: else: self.send_pairing_confirm_command() - def on_smp_pairing_confirm_command_legacy(self, _): + def on_smp_pairing_confirm_command_legacy( + self, _: SMP_Pairing_Confirm_Command + ) -> None: if self.is_initiator: self.send_pairing_random_command() else: @@ -1412,7 +1423,9 @@ class Session: else: self.send_pairing_confirm_command() - def on_smp_pairing_confirm_command_secure_connections(self, _): + def on_smp_pairing_confirm_command_secure_connections( + self, _: SMP_Pairing_Confirm_Command + ) -> None: if self.pairing_method in (self.JUST_WORKS, self.NUMERIC_COMPARISON): if self.is_initiator: self.r = crypto.r() @@ -1423,14 +1436,18 @@ class Session: else: self.send_pairing_confirm_command() - def on_smp_pairing_confirm_command(self, command): + def on_smp_pairing_confirm_command( + self, command: SMP_Pairing_Confirm_Command + ) -> None: self.confirm_value = command.confirm_value if self.sc: self.on_smp_pairing_confirm_command_secure_connections(command) else: self.on_smp_pairing_confirm_command_legacy(command) - def on_smp_pairing_random_command_legacy(self, command): + def on_smp_pairing_random_command_legacy( + self, command: SMP_Pairing_Random_Command + ) -> None: # Check that the confirmation values match confirm_verifier = crypto.c1( self.tk, @@ -1442,6 +1459,7 @@ class Session: self.ia, self.ra, ) + assert self.confirm_value if not self.check_expected_value( self.confirm_value, confirm_verifier, SMP_CONFIRM_VALUE_FAILED_ERROR ): @@ -1465,7 +1483,9 @@ class Session: else: self.send_pairing_random_command() - def on_smp_pairing_random_command_secure_connections(self, command): + def on_smp_pairing_random_command_secure_connections( + self, command: SMP_Pairing_Random_Command + ) -> None: if self.pairing_method == self.PASSKEY and self.passkey is None: logger.warning('no passkey entered, ignoring command') return @@ -1473,6 +1493,7 @@ class Session: # pylint: disable=too-many-return-statements if self.is_initiator: if self.pairing_method in (self.JUST_WORKS, self.NUMERIC_COMPARISON): + assert self.confirm_value # Check that the random value matches what was committed to earlier confirm_verifier = crypto.f4( self.pkb, self.pka, command.random_value, bytes([0]) @@ -1482,6 +1503,7 @@ class Session: ): return elif self.pairing_method == self.PASSKEY: + assert self.passkey and self.confirm_value # Check that the random value matches what was committed to earlier confirm_verifier = crypto.f4( self.pkb, @@ -1506,6 +1528,7 @@ class Session: if self.pairing_method in (self.JUST_WORKS, self.NUMERIC_COMPARISON): self.send_pairing_random_command() elif self.pairing_method == self.PASSKEY: + assert self.passkey and self.confirm_value # Check that the random value matches what was committed to earlier confirm_verifier = crypto.f4( self.pka, @@ -1539,19 +1562,21 @@ class Session: ra = bytes(16) rb = ra elif self.pairing_method == self.PASSKEY: + assert self.passkey ra = self.passkey.to_bytes(16, byteorder='little') rb = ra else: # OOB not implemented yet return + assert self.preq and self.pres io_cap_a = self.preq[1:4] io_cap_b = self.pres[1:4] self.ea = crypto.f6(mac_key, self.na, self.nb, rb, io_cap_a, a, b) self.eb = crypto.f6(mac_key, self.nb, self.na, ra, io_cap_b, b, a) # Next steps to be performed after possible user confirmation - def next_steps(): + def next_steps() -> None: # The initiator sends the DH Key check to the responder if self.is_initiator: self.send_pairing_dhkey_check_command() @@ -1573,14 +1598,18 @@ class Session: else: next_steps() - def on_smp_pairing_random_command(self, command): + def on_smp_pairing_random_command( + self, command: SMP_Pairing_Random_Command + ) -> None: self.peer_random_value = command.random_value if self.sc: self.on_smp_pairing_random_command_secure_connections(command) else: self.on_smp_pairing_random_command_legacy(command) - def on_smp_pairing_public_key_command(self, command): + def on_smp_pairing_public_key_command( + self, command: SMP_Pairing_Public_Key_Command + ) -> None: # Store the public key so that we can compute the confirmation value later self.peer_public_key_x = command.public_key_x self.peer_public_key_y = command.public_key_y @@ -1609,9 +1638,12 @@ class Session: # We can now send the confirmation value self.send_pairing_confirm_command() - def on_smp_pairing_dhkey_check_command(self, command): + def on_smp_pairing_dhkey_check_command( + self, command: SMP_Pairing_DHKey_Check_Command + ) -> None: # Check that what we received matches what we computed earlier expected = self.eb if self.is_initiator else self.ea + assert expected if not self.check_expected_value( expected, command.dhkey_check, SMP_DHKEY_CHECK_FAILED_ERROR ): @@ -1620,7 +1652,8 @@ class Session: if self.is_responder: if self.wait_before_continuing is not None: - async def next_steps(): + async def next_steps() -> None: + assert self.wait_before_continuing await self.wait_before_continuing self.wait_before_continuing = None self.send_pairing_dhkey_check_command() @@ -1629,29 +1662,42 @@ class Session: else: self.send_pairing_dhkey_check_command() else: + assert self.ltk self.start_encryption(self.ltk) - def on_smp_pairing_failed_command(self, command): + def on_smp_pairing_failed_command( + self, command: SMP_Pairing_Failed_Command + ) -> None: self.on_pairing_failure(command.reason) - def on_smp_encryption_information_command(self, command): + def on_smp_encryption_information_command( + self, command: SMP_Encryption_Information_Command + ) -> None: self.peer_ltk = command.long_term_key self.check_key_distribution(SMP_Encryption_Information_Command) - def on_smp_master_identification_command(self, command): + def on_smp_master_identification_command( + self, command: SMP_Master_Identification_Command + ) -> None: self.peer_ediv = command.ediv self.peer_rand = command.rand self.check_key_distribution(SMP_Master_Identification_Command) - def on_smp_identity_information_command(self, command): + def on_smp_identity_information_command( + self, command: SMP_Identity_Information_Command + ) -> None: self.peer_identity_resolving_key = command.identity_resolving_key self.check_key_distribution(SMP_Identity_Information_Command) - def on_smp_identity_address_information_command(self, command): + def on_smp_identity_address_information_command( + self, command: SMP_Identity_Address_Information_Command + ) -> None: self.peer_bd_addr = command.bd_addr self.check_key_distribution(SMP_Identity_Address_Information_Command) - def on_smp_signing_information_command(self, command): + def on_smp_signing_information_command( + self, command: SMP_Signing_Information_Command + ) -> None: self.peer_signature_key = command.signature_key self.check_key_distribution(SMP_Signing_Information_Command) @@ -1662,14 +1708,24 @@ class Manager(EventEmitter): Implements the Initiator and Responder roles of the Security Manager Protocol ''' - def __init__(self, device): + device: Device + sessions: Dict[int, Session] + pairing_config_factory: Callable[[Connection], PairingConfig] + session_proxy: Type[Session] + + def __init__( + self, + device: Device, + pairing_config_factory: Callable[[Connection], PairingConfig], + ) -> None: super().__init__() self.device = device self.sessions = {} self._ecc_key = None - self.pairing_config_factory = lambda connection: PairingConfig() + self.pairing_config_factory = pairing_config_factory + self.session_proxy = Session - def send_command(self, connection, command): + def send_command(self, connection: Connection, command: SMP_Command) -> None: logger.debug( f'>>> Sending SMP Command on connection [0x{connection.handle:04X}] ' f'{connection.peer_address}: {command}' @@ -1677,18 +1733,15 @@ class Manager(EventEmitter): cid = SMP_BR_CID if connection.transport == BT_BR_EDR_TRANSPORT else SMP_CID connection.send_l2cap_pdu(cid, command.to_bytes()) - def on_smp_pdu(self, connection, pdu): + def on_smp_pdu(self, connection: Connection, pdu: bytes) -> None: # Look for a session with this connection, and create one if none exists if not (session := self.sessions.get(connection.handle)): + if connection.role == BT_CENTRAL_ROLE: + logger.warning('Remote starts pairing as Peripheral!') pairing_config = self.pairing_config_factory(connection) - if pairing_config is None: - # Pairing disabled - self.send_command( - connection, - SMP_Pairing_Failed_Command(reason=SMP_PAIRING_NOT_SUPPORTED_ERROR), - ) - return - session = Session(self, connection, pairing_config) + session = self.session_proxy( + self, connection, pairing_config, is_initiator=False + ) self.sessions[connection.handle] = session # Parse the L2CAP payload into an SMP Command object @@ -1702,21 +1755,24 @@ class Manager(EventEmitter): session.on_smp_command(command) @property - def ecc_key(self): + def ecc_key(self) -> crypto.EccKey: if self._ecc_key is None: self._ecc_key = crypto.EccKey.generate() + assert self._ecc_key return self._ecc_key - async def pair(self, connection): + async def pair(self, connection: Connection) -> None: # TODO: check if there's already a session for this connection + if connection.role != BT_CENTRAL_ROLE: + logger.warning('Start pairing as Peripheral!') pairing_config = self.pairing_config_factory(connection) - if pairing_config is None: - raise ValueError('pairing config must not be None when initiating') - session = Session(self, connection, pairing_config) + session = self.session_proxy( + self, connection, pairing_config, is_initiator=True + ) self.sessions[connection.handle] = session return await session.pair() - def request_pairing(self, connection): + def request_pairing(self, connection: Connection) -> None: pairing_config = self.pairing_config_factory(connection) if pairing_config: auth_req = smp_auth_req( @@ -1730,15 +1786,18 @@ class Manager(EventEmitter): auth_req = 0 self.send_command(connection, SMP_Security_Request_Command(auth_req=auth_req)) - def on_session_start(self, session): - self.device.on_pairing_start(session.connection.handle) + def on_session_start(self, session: Session) -> None: + self.device.on_pairing_start(session.connection) - def on_pairing(self, session, identity_address, keys): + def on_pairing( + self, session: Session, identity_address: Optional[Address], keys: PairingKeys + ) -> None: # Store the keys in the key store if self.device.keystore and identity_address is not None: async def store_keys(): try: + assert self.device.keystore await self.device.keystore.update(str(identity_address), keys) except Exception as error: logger.warning(f'!!! error while storing keys: {error}') @@ -1746,17 +1805,19 @@ class Manager(EventEmitter): self.device.abort_on('flush', store_keys()) # Notify the device - self.device.on_pairing(session.connection.handle, keys, session.sc) + self.device.on_pairing(session.connection, keys, session.sc) - def on_pairing_failure(self, session, reason): - self.device.on_pairing_failure(session.connection.handle, reason) + def on_pairing_failure(self, session: Session, reason: int) -> None: + self.device.on_pairing_failure(session.connection, reason) - def on_session_end(self, session): + def on_session_end(self, session: Session) -> None: logger.debug(f'session end for connection 0x{session.connection.handle:04X}') if session.connection.handle in self.sessions: del self.sessions[session.connection.handle] - def get_long_term_key(self, connection, rand, ediv): + def get_long_term_key( + self, connection: Connection, rand: bytes, ediv: int + ) -> Optional[bytes]: if session := self.sessions.get(connection.handle): return session.get_long_term_key(rand, ediv) diff --git a/bumble/transport/__init__.py b/bumble/transport/__init__.py index 2d4600f..840b3e5 100644 --- a/bumble/transport/__init__.py +++ b/bumble/transport/__init__.py @@ -145,6 +145,11 @@ async def _open_transport(name: str) -> Transport: return await open_android_emulator_transport(spec[0] if spec else None) + if scheme == 'android-netsim': + from .android_netsim import open_android_netsim_transport + + return await open_android_netsim_transport(spec[0] if spec else None) + raise ValueError('unknown transport scheme') diff --git a/bumble/transport/android_emulator.py b/bumble/transport/android_emulator.py index 6d9e4d1..b78e263 100644 --- a/bumble/transport/android_emulator.py +++ b/bumble/transport/android_emulator.py @@ -16,14 +16,14 @@ # Imports # ----------------------------------------------------------------------------- import logging -import grpc +import grpc.aio from .common import PumpedTransport, PumpedPacketSource, PumpedPacketSink -from .emulated_bluetooth_pb2_grpc import EmulatedBluetoothServiceStub -from .emulated_bluetooth_vhci_pb2_grpc import VhciForwardingServiceStub -# pylint: disable-next=no-name-in-module -from .emulated_bluetooth_packets_pb2 import HCIPacket +# pylint: disable=no-name-in-module +from .grpc_protobuf.emulated_bluetooth_pb2_grpc import EmulatedBluetoothServiceStub +from .grpc_protobuf.emulated_bluetooth_packets_pb2 import HCIPacket +from .grpc_protobuf.emulated_bluetooth_vhci_pb2_grpc import VhciForwardingServiceStub # ----------------------------------------------------------------------------- diff --git a/bumble/transport/android_netsim.py b/bumble/transport/android_netsim.py new file mode 100644 index 0000000..99ebf87 --- /dev/null +++ b/bumble/transport/android_netsim.py @@ -0,0 +1,410 @@ +# 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 atexit +import logging +import grpc.aio +import os +import pathlib +import sys +from typing import Optional + +from .common import ( + ParserSource, + PumpedTransport, + PumpedPacketSource, + PumpedPacketSink, + Transport, +) + +# pylint: disable=no-name-in-module +from .grpc_protobuf.packet_streamer_pb2_grpc import PacketStreamerStub +from .grpc_protobuf.packet_streamer_pb2_grpc import ( + PacketStreamerServicer, + add_PacketStreamerServicer_to_server, +) +from .grpc_protobuf.packet_streamer_pb2 import PacketRequest, PacketResponse +from .grpc_protobuf.hci_packet_pb2 import HCIPacket +from .grpc_protobuf.startup_pb2 import Chip, ChipInfo +from .grpc_protobuf.common_pb2 import ChipKind + +# ----------------------------------------------------------------------------- +# Logging +# ----------------------------------------------------------------------------- +logger = logging.getLogger(__name__) + + +# ----------------------------------------------------------------------------- +# Constants +# ----------------------------------------------------------------------------- +DEFAULT_NAME = 'bumble0' +DEFAULT_MANUFACTURER = 'Bumble' + + +# ----------------------------------------------------------------------------- +def get_ini_dir() -> Optional[pathlib.Path]: + if sys.platform == 'darwin': + if tmpdir := os.getenv('TMPDIR', None): + return pathlib.Path(tmpdir) + if home := os.getenv('HOME', None): + return pathlib.Path(home) / 'Library/Caches/TemporaryItems' + elif sys.platform == 'linux': + if xdg_runtime_dir := os.environ.get('XDG_RUNTIME_DIR', None): + return pathlib.Path(xdg_runtime_dir) + elif sys.platform == 'win32': + if local_app_data_dir := os.environ.get('LOCALAPPDATA', None): + return pathlib.Path(local_app_data_dir) / 'Temp' + + return None + + +# ----------------------------------------------------------------------------- +def find_grpc_port() -> int: + if not (ini_dir := get_ini_dir()): + logger.debug('no known directory for .ini file') + return 0 + + ini_file = ini_dir / 'netsim.ini' + if ini_file.is_file(): + logger.debug(f'Found .ini file at {ini_file}') + with open(ini_file, 'r') as ini_file_data: + for line in ini_file_data.readlines(): + if '=' in line: + key, value = line.split('=') + if key == 'grpc.port': + logger.debug(f'gRPC port = {value}') + return int(value) + + # Not found + return 0 + + +# ----------------------------------------------------------------------------- +def publish_grpc_port(grpc_port) -> bool: + if not (ini_dir := get_ini_dir()): + logger.debug('no known directory for .ini file') + return False + + if not ini_dir.is_dir(): + logger.debug('ini directory does not exist') + return False + + ini_file = ini_dir / 'netsim.ini' + try: + ini_file.write_text(f'grpc.port={grpc_port}\n') + logger.debug(f"published gRPC port at {ini_file}") + + def cleanup(): + logger.debug("removing .ini file") + ini_file.unlink() + + atexit.register(cleanup) + return True + except OSError: + logger.debug('failed to write to .ini file') + return False + + +# ----------------------------------------------------------------------------- +async def open_android_netsim_controller_transport(server_host, server_port): + if not server_port: + raise ValueError('invalid port') + if server_host == '_' or not server_host: + server_host = 'localhost' + + if not publish_grpc_port(server_port): + logger.warning("unable to publish gRPC port") + + class HciDevice: + def __init__(self, context, on_data_received): + self.context = context + self.on_data_received = on_data_received + self.name = None + self.loop = asyncio.get_running_loop() + self.done = self.loop.create_future() + self.task = self.loop.create_task(self.pump()) + + async def pump(self): + try: + await self.pump_loop() + except asyncio.CancelledError: + logger.debug('Pump task canceled') + self.done.set_result(None) + + async def pump_loop(self): + while True: + request = await self.context.read() + if request == grpc.aio.EOF: + logger.debug('End of request stream') + self.done.set_result(None) + return + + # If we're not initialized yet, wait for a init packet. + if self.name is None: + if request.WhichOneof('request_type') == 'initial_info': + logger.debug(f'Received initial info: {request}') + + # We only accept BLUETOOTH + if request.initial_info.chip.kind != ChipKind.BLUETOOTH: + logger.warning('Unsupported chip type') + error = PacketResponse(error='Unsupported chip type') + await self.context.write(error) + return + + self.name = request.initial_info.name + continue + + # Expect a data packet + request_type = request.WhichOneof('request_type') + if request_type != 'hci_packet': + logger.warning(f'Unexpected request type: {request_type}') + error = PacketResponse(error='Unexpected request type') + await self.context.write(error) + continue + + # Process the packet + data = ( + bytes([request.hci_packet.packet_type]) + request.hci_packet.packet + ) + logger.debug(f'<<< PACKET: {data.hex()}') + self.on_data_received(data) + + def send_packet(self, data): + async def send(): + await self.context.write( + PacketResponse( + hci_packet=HCIPacket(packet_type=data[0], packet=data[1:]) + ) + ) + + self.loop.create_task(send()) + + def terminate(self): + self.task.cancel() + + async def wait_for_termination(self): + await self.done + + class Server(PacketStreamerServicer, ParserSource): + def __init__(self): + PacketStreamerServicer.__init__(self) + ParserSource.__init__(self) + self.device = None + + # Create a gRPC server with `so_reuseport=0` so that if there's already + # a server listening on that port, we get an exception. + self.grpc_server = grpc.aio.server(options=(('grpc.so_reuseport', 0),)) + add_PacketStreamerServicer_to_server(self, self.grpc_server) + self.grpc_server.add_insecure_port(f'{server_host}:{server_port}') + logger.debug(f'gRPC server listening on {server_host}:{server_port}') + + async def start(self): + logger.debug('Starting gRPC server') + await self.grpc_server.start() + + async def serve(self): + # Keep serving until terminated. + try: + await self.grpc_server.wait_for_termination() + logger.debug('gRPC server terminated') + except asyncio.CancelledError: + logger.debug('gRPC server cancelled') + await self.grpc_server.stop(None) + + def on_packet(self, packet): + if not self.device: + logger.debug('no device, dropping packet') + return + + self.device.send_packet(packet) + + async def StreamPackets(self, _request_iterator, context): + logger.debug('StreamPackets request') + + # Check that we won't already have a device + if self.device: + logger.debug('busy, already serving a device') + return PacketResponse(error='Busy') + + # Instantiate a new device + self.device = HciDevice(context, self.parser.feed_data) + + # Wait for the device to terminate + logger.debug('Waiting for device to terminate') + try: + await self.device.wait_for_termination() + except asyncio.CancelledError: + logger.debug('Request canceled') + self.device.terminate() + + logger.debug('Device terminated') + self.device = None + + server = Server() + await server.start() + asyncio.get_running_loop().create_task(server.serve()) + + class GrpcServerTransport(Transport): + async def close(self): + await super().close() + + return GrpcServerTransport(server, server) + + +# ----------------------------------------------------------------------------- +async def open_android_netsim_host_transport(server_host, server_port, options): + # Wrapper for I/O operations + class HciDevice: + def __init__(self, name, manufacturer, hci_device): + self.name = name + self.manufacturer = manufacturer + self.hci_device = hci_device + + async def start(self): # Send the startup info + chip_info = ChipInfo( + name=self.name, + chip=Chip(kind=ChipKind.BLUETOOTH, manufacturer=self.manufacturer), + ) + logger.debug(f'Sending chip info to netsim: {chip_info}') + await self.hci_device.write(PacketRequest(initial_info=chip_info)) + + async def read(self): + response = await self.hci_device.read() + response_type = response.WhichOneof('response_type') + if response_type == 'error': + logger.warning(f'received error: {response.error}') + raise RuntimeError(response.error) + elif response_type == 'hci_packet': + return ( + bytes([response.hci_packet.packet_type]) + + response.hci_packet.packet + ) + + raise ValueError('unsupported response type') + + async def write(self, packet): + await self.hci_device.write( + PacketRequest( + hci_packet=HCIPacket(packet_type=packet[0], packet=packet[1:]) + ) + ) + + name = options.get('name', DEFAULT_NAME) + manufacturer = DEFAULT_MANUFACTURER + + if server_host == '_' or not server_host: + server_host = 'localhost' + + if not server_port: + # Look for the gRPC config in a .ini file + server_host = 'localhost' + server_port = find_grpc_port() + if not server_port: + raise RuntimeError('gRPC server port not found') + + # Connect to the gRPC server + server_address = f'{server_host}:{server_port}' + logger.debug(f'Connecting to gRPC server at {server_address}') + channel = grpc.aio.insecure_channel(server_address) + + # Connect as a host + service = PacketStreamerStub(channel) + hci_device = HciDevice( + name=name, + manufacturer=manufacturer, + hci_device=service.StreamPackets(), + ) + await hci_device.start() + + # Create the transport object + transport = PumpedTransport( + PumpedPacketSource(hci_device.read), + PumpedPacketSink(hci_device.write), + channel.close, + ) + transport.start() + + return transport + + +# ----------------------------------------------------------------------------- +async def open_android_netsim_transport(spec): + ''' + Open a transport connection as a client or server, implementing Android's `netsim` + simulator protocol over gRPC. + The parameter string has this syntax: + [<host>:<port>][<options>] + Where <options> is a ','-separated list of <name>=<value> pairs. + + General options: + mode=host|controller (default: host) + Specifies whether the transport is used + to connect *to* a netsim server (netsim is the controller), or accept + connections *as* a netsim-compatible server. + + In `host` mode: + The <host>:<port> part is optional. When not specified, the transport + looks for a netsim .ini file, from which it will read the `grpc.backend.port` + property. + Options for this mode are: + name=<name> + The "chip" name, used to identify the "chip" instance. This + may be useful when several clients are connected, since each needs to use a + different name. + + In `controller` mode: + The <host>:<port> part is required. <host> may be the address of a local network + interface, or '_' to accept connections on all local network interfaces. + + Examples: + (empty string) --> connect to netsim on the port specified in the .ini file + localhost:8555 --> connect to netsim on localhost:8555 + name=bumble1 --> connect to netsim, using `bumble1` as the "chip" name. + localhost:8555,name=bumble1 --> connect to netsim on localhost:8555, using + `bumble1` as the "chip" name. + _:8877,mode=controller --> accept connections as a controller on any interface + on port 8877. + ''' + + # Parse the parameters + params = spec.split(',') if spec else [] + if params and ':' in params[0]: + # Explicit <host>:<port> + host, port = params[0].split(':') + params_offset = 1 + else: + host = None + port = 0 + params_offset = 0 + + options = {} + for param in params[params_offset:]: + if '=' not in param: + raise ValueError('invalid parameter, expected <name>=<value>') + option_name, option_value = param.split('=') + options[option_name] = option_value + + mode = options.get('mode', 'host') + if mode == 'host': + return await open_android_netsim_host_transport(host, port, options) + if mode == 'controller': + if host is None: + raise ValueError('<host>:<port> missing') + return await open_android_netsim_controller_transport(host, port) + + raise ValueError('invalid mode option') diff --git a/bumble/transport/emulated_bluetooth_packets_pb2.py b/bumble/transport/emulated_bluetooth_packets_pb2.py deleted file mode 100644 index 2802ff1..0000000 --- a/bumble/transport/emulated_bluetooth_packets_pb2.py +++ /dev/null @@ -1,45 +0,0 @@ -# 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. - -# -*- coding: utf-8 -*- -# Generated by the protocol buffer compiler. DO NOT EDIT! -# source: emulated_bluetooth_packets.proto -"""Generated protocol buffer code.""" -from google.protobuf.internal import builder as _builder -from google.protobuf import descriptor as _descriptor -from google.protobuf import descriptor_pool as _descriptor_pool -from google.protobuf import symbol_database as _symbol_database - -# @@protoc_insertion_point(imports) - -_sym_db = _symbol_database.Default() - - -DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile( - b'\n emulated_bluetooth_packets.proto\x12\x1b\x61ndroid.emulation.bluetooth\"\xfb\x01\n\tHCIPacket\x12?\n\x04type\x18\x01 \x01(\x0e\x32\x31.android.emulation.bluetooth.HCIPacket.PacketType\x12\x0e\n\x06packet\x18\x02 \x01(\x0c\"\x9c\x01\n\nPacketType\x12\x1b\n\x17PACKET_TYPE_UNSPECIFIED\x10\x00\x12\x1b\n\x17PACKET_TYPE_HCI_COMMAND\x10\x01\x12\x13\n\x0fPACKET_TYPE_ACL\x10\x02\x12\x13\n\x0fPACKET_TYPE_SCO\x10\x03\x12\x15\n\x11PACKET_TYPE_EVENT\x10\x04\x12\x13\n\x0fPACKET_TYPE_ISO\x10\x05\x42J\n\x1f\x63om.android.emulation.bluetoothP\x01\xf8\x01\x01\xa2\x02\x03\x41\x45\x42\xaa\x02\x1b\x41ndroid.Emulation.Bluetoothb\x06proto3' -) - -_builder.BuildMessageAndEnumDescriptors(DESCRIPTOR, globals()) -_builder.BuildTopDescriptorsAndMessages( - DESCRIPTOR, 'emulated_bluetooth_packets_pb2', globals() -) -if _descriptor._USE_C_DESCRIPTORS == False: - - DESCRIPTOR._options = None - DESCRIPTOR._serialized_options = b'\n\037com.android.emulation.bluetoothP\001\370\001\001\242\002\003AEB\252\002\033Android.Emulation.Bluetooth' - _HCIPACKET._serialized_start = 66 - _HCIPACKET._serialized_end = 317 - _HCIPACKET_PACKETTYPE._serialized_start = 161 - _HCIPACKET_PACKETTYPE._serialized_end = 317 -# @@protoc_insertion_point(module_scope) diff --git a/bumble/transport/emulated_bluetooth_packets_pb2_grpc.py b/bumble/transport/emulated_bluetooth_packets_pb2_grpc.py deleted file mode 100644 index 3450039..0000000 --- a/bumble/transport/emulated_bluetooth_packets_pb2_grpc.py +++ /dev/null @@ -1,17 +0,0 @@ -# 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. - -# Generated by the gRPC Python protocol compiler plugin. DO NOT EDIT! -"""Client and server classes corresponding to protobuf-defined services.""" -import grpc diff --git a/bumble/transport/emulated_bluetooth_pb2.py b/bumble/transport/emulated_bluetooth_pb2.py deleted file mode 100644 index 42015f6..0000000 --- a/bumble/transport/emulated_bluetooth_pb2.py +++ /dev/null @@ -1,46 +0,0 @@ -# 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. - -# -*- coding: utf-8 -*- -# Generated by the protocol buffer compiler. DO NOT EDIT! -# source: emulated_bluetooth.proto -"""Generated protocol buffer code.""" -from google.protobuf.internal import builder as _builder -from google.protobuf import descriptor as _descriptor -from google.protobuf import descriptor_pool as _descriptor_pool -from google.protobuf import symbol_database as _symbol_database - -# @@protoc_insertion_point(imports) - -_sym_db = _symbol_database.Default() - - -from . import emulated_bluetooth_packets_pb2 as emulated__bluetooth__packets__pb2 - - -DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile( - b'\n\x18\x65mulated_bluetooth.proto\x12\x1b\x61ndroid.emulation.bluetooth\x1a emulated_bluetooth_packets.proto\"\x19\n\x07RawData\x12\x0e\n\x06packet\x18\x01 \x01(\x0c\x32\xcb\x02\n\x18\x45mulatedBluetoothService\x12\x64\n\x12registerClassicPhy\x12$.android.emulation.bluetooth.RawData\x1a$.android.emulation.bluetooth.RawData(\x01\x30\x01\x12`\n\x0eregisterBlePhy\x12$.android.emulation.bluetooth.RawData\x1a$.android.emulation.bluetooth.RawData(\x01\x30\x01\x12g\n\x11registerHCIDevice\x12&.android.emulation.bluetooth.HCIPacket\x1a&.android.emulation.bluetooth.HCIPacket(\x01\x30\x01\x42\"\n\x1e\x63om.android.emulator.bluetoothP\x01\x62\x06proto3' -) - -_builder.BuildMessageAndEnumDescriptors(DESCRIPTOR, globals()) -_builder.BuildTopDescriptorsAndMessages(DESCRIPTOR, 'emulated_bluetooth_pb2', globals()) -if _descriptor._USE_C_DESCRIPTORS == False: - - DESCRIPTOR._options = None - DESCRIPTOR._serialized_options = b'\n\036com.android.emulator.bluetoothP\001' - _RAWDATA._serialized_start = 91 - _RAWDATA._serialized_end = 116 - _EMULATEDBLUETOOTHSERVICE._serialized_start = 119 - _EMULATEDBLUETOOTHSERVICE._serialized_end = 450 -# @@protoc_insertion_point(module_scope) diff --git a/bumble/transport/emulated_bluetooth_pb2.pyi b/bumble/transport/emulated_bluetooth_pb2.pyi deleted file mode 100644 index fb87a52..0000000 --- a/bumble/transport/emulated_bluetooth_pb2.pyi +++ /dev/null @@ -1,26 +0,0 @@ -# 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. - -import emulated_bluetooth_packets_pb2 as _emulated_bluetooth_packets_pb2 -from google.protobuf import descriptor as _descriptor -from google.protobuf import message as _message -from typing import ClassVar as _ClassVar, Optional as _Optional - -DESCRIPTOR: _descriptor.FileDescriptor - -class RawData(_message.Message): - __slots__ = ["packet"] - PACKET_FIELD_NUMBER: _ClassVar[int] - packet: bytes - def __init__(self, packet: _Optional[bytes] = ...) -> None: ... diff --git a/bumble/transport/emulated_bluetooth_pb2_grpc.py b/bumble/transport/emulated_bluetooth_pb2_grpc.py deleted file mode 100644 index 8559c9e..0000000 --- a/bumble/transport/emulated_bluetooth_pb2_grpc.py +++ /dev/null @@ -1,244 +0,0 @@ -# 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. - -# Generated by the gRPC Python protocol compiler plugin. DO NOT EDIT! -"""Client and server classes corresponding to protobuf-defined services.""" -import grpc - -from . import emulated_bluetooth_packets_pb2 as emulated__bluetooth__packets__pb2 -from . import emulated_bluetooth_pb2 as emulated__bluetooth__pb2 - - -class EmulatedBluetoothServiceStub(object): - """An Emulated Bluetooth Service exposes the emulated bluetooth chip from the - android emulator. It allows you to register emulated bluetooth devices and - control the packets that are exchanged between the device and the world. - - This service enables you to establish a "virtual network" of emulated - bluetooth devices that can interact with each other. - - Note: This is not yet finalized, it is likely that these definitions will - evolve. - """ - - def __init__(self, channel): - """Constructor. - - Args: - channel: A grpc.Channel. - """ - self.registerClassicPhy = channel.stream_stream( - '/android.emulation.bluetooth.EmulatedBluetoothService/registerClassicPhy', - request_serializer=emulated__bluetooth__pb2.RawData.SerializeToString, - response_deserializer=emulated__bluetooth__pb2.RawData.FromString, - ) - self.registerBlePhy = channel.stream_stream( - '/android.emulation.bluetooth.EmulatedBluetoothService/registerBlePhy', - request_serializer=emulated__bluetooth__pb2.RawData.SerializeToString, - response_deserializer=emulated__bluetooth__pb2.RawData.FromString, - ) - self.registerHCIDevice = channel.stream_stream( - '/android.emulation.bluetooth.EmulatedBluetoothService/registerHCIDevice', - request_serializer=emulated__bluetooth__packets__pb2.HCIPacket.SerializeToString, - response_deserializer=emulated__bluetooth__packets__pb2.HCIPacket.FromString, - ) - - -class EmulatedBluetoothServiceServicer(object): - """An Emulated Bluetooth Service exposes the emulated bluetooth chip from the - android emulator. It allows you to register emulated bluetooth devices and - control the packets that are exchanged between the device and the world. - - This service enables you to establish a "virtual network" of emulated - bluetooth devices that can interact with each other. - - Note: This is not yet finalized, it is likely that these definitions will - evolve. - """ - - def registerClassicPhy(self, request_iterator, context): - """Connect device to link layer. This will establish a direct connection - to the emulated bluetooth chip and configure the following: - - - Each connection creates a new device and attaches it to the link layer - - Link Layer packets are transmitted directly to the phy - - This should be used for classic connections. - - This is used to directly connect various android emulators together. - For example a wear device can connect to an android emulator through - this. - """ - context.set_code(grpc.StatusCode.UNIMPLEMENTED) - context.set_details('Method not implemented!') - raise NotImplementedError('Method not implemented!') - - def registerBlePhy(self, request_iterator, context): - """Connect device to link layer. This will establish a direct connection - to root canal and execute the following: - - - Each connection creates a new device and attaches it to the link layer - - Link Layer packets are transmitted directly to the phy - - This should be used for BLE connections. - - This is used to directly connect various android emulators together. - For example a wear device can connect to an android emulator through - this. - """ - context.set_code(grpc.StatusCode.UNIMPLEMENTED) - context.set_details('Method not implemented!') - raise NotImplementedError('Method not implemented!') - - def registerHCIDevice(self, request_iterator, context): - """Connect the device to the emulated bluetooth chip. The device will - participate in the network. You can configure the chip to scan, advertise - and setup connections with other devices that are connected to the - network. - - This is usually used when you have a need for an emulated bluetooth chip - and have a bluetooth stack that can interpret and handle the packets - correctly. - - For example the apache nimble stack can use this endpoint as the - transport layer. - """ - context.set_code(grpc.StatusCode.UNIMPLEMENTED) - context.set_details('Method not implemented!') - raise NotImplementedError('Method not implemented!') - - -def add_EmulatedBluetoothServiceServicer_to_server(servicer, server): - rpc_method_handlers = { - 'registerClassicPhy': grpc.stream_stream_rpc_method_handler( - servicer.registerClassicPhy, - request_deserializer=emulated__bluetooth__pb2.RawData.FromString, - response_serializer=emulated__bluetooth__pb2.RawData.SerializeToString, - ), - 'registerBlePhy': grpc.stream_stream_rpc_method_handler( - servicer.registerBlePhy, - request_deserializer=emulated__bluetooth__pb2.RawData.FromString, - response_serializer=emulated__bluetooth__pb2.RawData.SerializeToString, - ), - 'registerHCIDevice': grpc.stream_stream_rpc_method_handler( - servicer.registerHCIDevice, - request_deserializer=emulated__bluetooth__packets__pb2.HCIPacket.FromString, - response_serializer=emulated__bluetooth__packets__pb2.HCIPacket.SerializeToString, - ), - } - generic_handler = grpc.method_handlers_generic_handler( - 'android.emulation.bluetooth.EmulatedBluetoothService', rpc_method_handlers - ) - server.add_generic_rpc_handlers((generic_handler,)) - - -# This class is part of an EXPERIMENTAL API. -class EmulatedBluetoothService(object): - """An Emulated Bluetooth Service exposes the emulated bluetooth chip from the - android emulator. It allows you to register emulated bluetooth devices and - control the packets that are exchanged between the device and the world. - - This service enables you to establish a "virtual network" of emulated - bluetooth devices that can interact with each other. - - Note: This is not yet finalized, it is likely that these definitions will - evolve. - """ - - @staticmethod - def registerClassicPhy( - request_iterator, - target, - options=(), - channel_credentials=None, - call_credentials=None, - insecure=False, - compression=None, - wait_for_ready=None, - timeout=None, - metadata=None, - ): - return grpc.experimental.stream_stream( - request_iterator, - target, - '/android.emulation.bluetooth.EmulatedBluetoothService/registerClassicPhy', - emulated__bluetooth__pb2.RawData.SerializeToString, - emulated__bluetooth__pb2.RawData.FromString, - options, - channel_credentials, - insecure, - call_credentials, - compression, - wait_for_ready, - timeout, - metadata, - ) - - @staticmethod - def registerBlePhy( - request_iterator, - target, - options=(), - channel_credentials=None, - call_credentials=None, - insecure=False, - compression=None, - wait_for_ready=None, - timeout=None, - metadata=None, - ): - return grpc.experimental.stream_stream( - request_iterator, - target, - '/android.emulation.bluetooth.EmulatedBluetoothService/registerBlePhy', - emulated__bluetooth__pb2.RawData.SerializeToString, - emulated__bluetooth__pb2.RawData.FromString, - options, - channel_credentials, - insecure, - call_credentials, - compression, - wait_for_ready, - timeout, - metadata, - ) - - @staticmethod - def registerHCIDevice( - request_iterator, - target, - options=(), - channel_credentials=None, - call_credentials=None, - insecure=False, - compression=None, - wait_for_ready=None, - timeout=None, - metadata=None, - ): - return grpc.experimental.stream_stream( - request_iterator, - target, - '/android.emulation.bluetooth.EmulatedBluetoothService/registerHCIDevice', - emulated__bluetooth__packets__pb2.HCIPacket.SerializeToString, - emulated__bluetooth__packets__pb2.HCIPacket.FromString, - options, - channel_credentials, - insecure, - call_credentials, - compression, - wait_for_ready, - timeout, - metadata, - ) diff --git a/bumble/transport/emulated_bluetooth_vhci_pb2.py b/bumble/transport/emulated_bluetooth_vhci_pb2.py deleted file mode 100644 index e8009ad..0000000 --- a/bumble/transport/emulated_bluetooth_vhci_pb2.py +++ /dev/null @@ -1,46 +0,0 @@ -# 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. - -# -*- coding: utf-8 -*- -# Generated by the protocol buffer compiler. DO NOT EDIT! -# source: emulated_bluetooth_vhci.proto -"""Generated protocol buffer code.""" -from google.protobuf.internal import builder as _builder -from google.protobuf import descriptor as _descriptor -from google.protobuf import descriptor_pool as _descriptor_pool -from google.protobuf import symbol_database as _symbol_database - -# @@protoc_insertion_point(imports) - -_sym_db = _symbol_database.Default() - - -from . import emulated_bluetooth_packets_pb2 as emulated__bluetooth__packets__pb2 - - -DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile( - b'\n\x1d\x65mulated_bluetooth_vhci.proto\x12\x1b\x61ndroid.emulation.bluetooth\x1a emulated_bluetooth_packets.proto2y\n\x15VhciForwardingService\x12`\n\nattachVhci\x12&.android.emulation.bluetooth.HCIPacket\x1a&.android.emulation.bluetooth.HCIPacket(\x01\x30\x01\x42J\n\x1f\x63om.android.emulation.bluetoothP\x01\xf8\x01\x01\xa2\x02\x03\x41\x45\x42\xaa\x02\x1b\x41ndroid.Emulation.Bluetoothb\x06proto3' -) - -_builder.BuildMessageAndEnumDescriptors(DESCRIPTOR, globals()) -_builder.BuildTopDescriptorsAndMessages( - DESCRIPTOR, 'emulated_bluetooth_vhci_pb2', globals() -) -if _descriptor._USE_C_DESCRIPTORS == False: - - DESCRIPTOR._options = None - DESCRIPTOR._serialized_options = b'\n\037com.android.emulation.bluetoothP\001\370\001\001\242\002\003AEB\252\002\033Android.Emulation.Bluetooth' - _VHCIFORWARDINGSERVICE._serialized_start = 96 - _VHCIFORWARDINGSERVICE._serialized_end = 217 -# @@protoc_insertion_point(module_scope) diff --git a/bumble/transport/emulated_bluetooth_vhci_pb2.pyi b/bumble/transport/emulated_bluetooth_vhci_pb2.pyi deleted file mode 100644 index 0877ad0..0000000 --- a/bumble/transport/emulated_bluetooth_vhci_pb2.pyi +++ /dev/null @@ -1,19 +0,0 @@ -# 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. - -import emulated_bluetooth_packets_pb2 as _emulated_bluetooth_packets_pb2 -from google.protobuf import descriptor as _descriptor -from typing import ClassVar as _ClassVar - -DESCRIPTOR: _descriptor.FileDescriptor diff --git a/bumble/transport/grpc_protobuf/__init__.py b/bumble/transport/grpc_protobuf/__init__.py new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/bumble/transport/grpc_protobuf/__init__.py diff --git a/bumble/transport/grpc_protobuf/common_pb2.py b/bumble/transport/grpc_protobuf/common_pb2.py new file mode 100644 index 0000000..c54a2e0 --- /dev/null +++ b/bumble/transport/grpc_protobuf/common_pb2.py @@ -0,0 +1,25 @@ +# -*- coding: utf-8 -*- +# Generated by the protocol buffer compiler. DO NOT EDIT! +# source: common.proto +"""Generated protocol buffer code.""" +from google.protobuf.internal import builder as _builder +from google.protobuf import descriptor as _descriptor +from google.protobuf import descriptor_pool as _descriptor_pool +from google.protobuf import symbol_database as _symbol_database +# @@protoc_insertion_point(imports) + +_sym_db = _symbol_database.Default() + + + + +DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile(b'\n\x0c\x63ommon.proto\x12\rnetsim.common*=\n\x08\x43hipKind\x12\x0f\n\x0bUNSPECIFIED\x10\x00\x12\r\n\tBLUETOOTH\x10\x01\x12\x08\n\x04WIFI\x10\x02\x12\x07\n\x03UWB\x10\x03\x62\x06proto3') + +_builder.BuildMessageAndEnumDescriptors(DESCRIPTOR, globals()) +_builder.BuildTopDescriptorsAndMessages(DESCRIPTOR, 'common_pb2', globals()) +if _descriptor._USE_C_DESCRIPTORS == False: + + DESCRIPTOR._options = None + _CHIPKIND._serialized_start=31 + _CHIPKIND._serialized_end=92 +# @@protoc_insertion_point(module_scope) diff --git a/bumble/transport/grpc_protobuf/common_pb2.pyi b/bumble/transport/grpc_protobuf/common_pb2.pyi new file mode 100644 index 0000000..4cc934d --- /dev/null +++ b/bumble/transport/grpc_protobuf/common_pb2.pyi @@ -0,0 +1,12 @@ +from google.protobuf.internal import enum_type_wrapper as _enum_type_wrapper +from google.protobuf import descriptor as _descriptor +from typing import ClassVar as _ClassVar + +BLUETOOTH: ChipKind +DESCRIPTOR: _descriptor.FileDescriptor +UNSPECIFIED: ChipKind +UWB: ChipKind +WIFI: ChipKind + +class ChipKind(int, metaclass=_enum_type_wrapper.EnumTypeWrapper): + __slots__ = [] diff --git a/bumble/transport/grpc_protobuf/common_pb2_grpc.py b/bumble/transport/grpc_protobuf/common_pb2_grpc.py new file mode 100644 index 0000000..2daafff --- /dev/null +++ b/bumble/transport/grpc_protobuf/common_pb2_grpc.py @@ -0,0 +1,4 @@ +# Generated by the gRPC Python protocol compiler plugin. DO NOT EDIT! +"""Client and server classes corresponding to protobuf-defined services.""" +import grpc + diff --git a/bumble/transport/grpc_protobuf/emulated_bluetooth_device_pb2.py b/bumble/transport/grpc_protobuf/emulated_bluetooth_device_pb2.py new file mode 100644 index 0000000..7380cb4 --- /dev/null +++ b/bumble/transport/grpc_protobuf/emulated_bluetooth_device_pb2.py @@ -0,0 +1,64 @@ +# -*- coding: utf-8 -*- +# Generated by the protocol buffer compiler. DO NOT EDIT! +# source: emulated_bluetooth_device.proto +"""Generated protocol buffer code.""" +from google.protobuf.internal import builder as _builder +from google.protobuf import descriptor as _descriptor +from google.protobuf import descriptor_pool as _descriptor_pool +from google.protobuf import symbol_database as _symbol_database +# @@protoc_insertion_point(imports) + +_sym_db = _symbol_database.Default() + + +from . import grpc_endpoint_description_pb2 as grpc__endpoint__description__pb2 +from google.protobuf import empty_pb2 as google_dot_protobuf_dot_empty__pb2 + + +DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile(b'\n\x1f\x65mulated_bluetooth_device.proto\x12\x1b\x61ndroid.emulation.bluetooth\x1a\x1fgrpc_endpoint_description.proto\x1a\x1bgoogle/protobuf/empty.proto\"&\n\x12\x43\x61llbackIdentifier\x12\x10\n\x08identity\x18\x01 \x01(\t\"#\n\x10\x44\x65viceIdentifier\x12\x0f\n\x07\x61\x64\x64ress\x18\x01 \x01(\t\"\xf5\x02\n\x15\x43onnectionStateChange\x12K\n\x12\x63\x61llback_device_id\x18\x01 \x01(\x0b\x32/.android.emulation.bluetooth.CallbackIdentifier\x12\x42\n\x0b\x66rom_device\x18\x02 \x01(\x0b\x32-.android.emulation.bluetooth.DeviceIdentifier\x12U\n\tnew_state\x18\x03 \x01(\x0e\x32\x42.android.emulation.bluetooth.ConnectionStateChange.ConnectionState\"t\n\x0f\x43onnectionState\x12\x1e\n\x1a\x43ONNECTION_STATE_UNDEFINED\x10\x00\x12!\n\x1d\x43ONNECTION_STATE_DISCONNECTED\x10\x01\x12\x1e\n\x1a\x43ONNECTION_STATE_CONNECTED\x10\x02\"A\n\x04Uuid\x12\x0c\n\x02id\x18\x01 \x01(\rH\x00\x12\r\n\x03lsb\x18\x02 \x01(\x04H\x00\x12\x0b\n\x03msb\x18\x03 \x01(\x03\x42\x0f\n\rshort_or_long\"\xf3\x01\n\x1a\x43haracteristicValueRequest\x12K\n\x12\x63\x61llback_device_id\x18\x01 \x01(\x0b\x32/.android.emulation.bluetooth.CallbackIdentifier\x12\x42\n\x0b\x66rom_device\x18\x02 \x01(\x0b\x32-.android.emulation.bluetooth.DeviceIdentifier\x12\x36\n\x0b\x63\x61llback_id\x18\x03 \x01(\x0b\x32!.android.emulation.bluetooth.Uuid\x12\x0c\n\x04\x64\x61ta\x18\x04 \x01(\x0c\"\xdd\x01\n\x1b\x43haracteristicValueResponse\x12S\n\x06status\x18\x01 \x01(\x0e\x32\x43.android.emulation.bluetooth.CharacteristicValueResponse.GattStatus\x12\x0c\n\x04\x64\x61ta\x18\x02 \x01(\x0c\"[\n\nGattStatus\x12\x1b\n\x17GATT_STATUS_UNSPECIFIED\x10\x00\x12\x17\n\x13GATT_STATUS_SUCCESS\x10\x01\x12\x17\n\x13GATT_STATUS_FAILURE\x10\x02\"\xb3\x05\n\x12GattCharacteristic\x12/\n\x04uuid\x18\x01 \x01(\x0b\x32!.android.emulation.bluetooth.Uuid\x12\x12\n\nproperties\x18\x02 \x01(\r\x12\x13\n\x0bpermissions\x18\x03 \x01(\r\x12\x36\n\x0b\x63\x61llback_id\x18\x04 \x01(\x0b\x32!.android.emulation.bluetooth.Uuid\"\xea\x01\n\nProperties\x12\x18\n\x14PROPERTY_UNSPECIFIED\x10\x00\x12\x16\n\x12PROPERTY_BROADCAST\x10\x01\x12\x11\n\rPROPERTY_READ\x10\x02\x12\x1e\n\x1aPROPERTY_WRITE_NO_RESPONSE\x10\x04\x12\x12\n\x0ePROPERTY_WRITE\x10\x08\x12\x13\n\x0fPROPERTY_NOTIFY\x10\x10\x12\x15\n\x11PROPERTY_INDICATE\x10 \x12\x19\n\x15PROPERTY_SIGNED_WRITE\x10@\x12\x1c\n\x17PROPERTY_EXTENDED_PROPS\x10\x80\x01\"\x9d\x02\n\x0bPermissions\x12\x1a\n\x16PERMISSION_UNSPECIFIED\x10\x00\x12\x13\n\x0fPERMISSION_READ\x10\x01\x12\x1d\n\x19PERMISSION_READ_ENCRYPTED\x10\x02\x12\"\n\x1ePERMISSION_READ_ENCRYPTED_MITM\x10\x04\x12\x14\n\x10PERMISSION_WRITE\x10\x10\x12\x1e\n\x1aPERMISSION_WRITE_ENCRYPTED\x10 \x12#\n\x1fPERMISSION_WRITE_ENCRYPTED_MITM\x10@\x12\x1c\n\x17PERMISSION_WRITE_SIGNED\x10\x80\x01\x12!\n\x1cPERMISSION_WRITE_SIGNED_MITM\x10\x80\x02\"\xb7\x02\n\x0bGattService\x12/\n\x04uuid\x18\x01 \x01(\x0b\x32!.android.emulation.bluetooth.Uuid\x12J\n\x0cservice_type\x18\x02 \x01(\x0e\x32\x34.android.emulation.bluetooth.GattService.ServiceType\x12H\n\x0f\x63haracteristics\x18\x03 \x03(\x0b\x32/.android.emulation.bluetooth.GattCharacteristic\"a\n\x0bServiceType\x12\x1c\n\x18SERVICE_TYPE_UNSPECIFIED\x10\x00\x12\x18\n\x14SERVICE_TYPE_PRIMARY\x10\x01\x12\x1a\n\x16SERVICE_TYPE_SECONDARY\x10\x02\"I\n\x0bGattProfile\x12:\n\x08services\x18\x01 \x03(\x0b\x32(.android.emulation.bluetooth.GattService\"\xf0\x03\n\rAdvertisement\x12\x13\n\x0b\x64\x65vice_name\x18\x01 \x01(\t\x12R\n\x0f\x63onnection_mode\x18\x02 \x01(\x0e\x32\x39.android.emulation.bluetooth.Advertisement.ConnectionMode\x12P\n\x0e\x64iscovery_mode\x18\x03 \x01(\x0e\x32\x38.android.emulation.bluetooth.Advertisement.DiscoveryMode\"\x94\x01\n\x0e\x43onnectionMode\x12\x1f\n\x1b\x43ONNECTION_MODE_UNSPECIFIED\x10\x00\x12#\n\x1f\x43ONNECTION_MODE_NON_CONNECTABLE\x10\x01\x12\x1c\n\x18\x43ONNECTION_MODE_DIRECTED\x10\x02\x12\x1e\n\x1a\x43ONNECTION_MODE_UNDIRECTED\x10\x03\"\x8c\x01\n\rDiscoveryMode\x12\x1e\n\x1a\x44ISCOVERY_MODE_UNSPECIFIED\x10\x00\x12#\n\x1f\x44ISCOVERY_MODE_NON_DISCOVERABLE\x10\x01\x12\x1a\n\x16\x44ISCOVERY_MODE_LIMITED\x10\x02\x12\x1a\n\x16\x44ISCOVERY_MODE_GENERAL\x10\x03\"\xc0\x01\n\nGattDevice\x12\x34\n\x08\x65ndpoint\x18\x01 \x01(\x0b\x32\".android.emulation.remote.Endpoint\x12\x41\n\radvertisement\x18\x02 \x01(\x0b\x32*.android.emulation.bluetooth.Advertisement\x12\x39\n\x07profile\x18\x03 \x01(\x0b\x32(.android.emulation.bluetooth.GattProfile2\xb9\x04\n\x11GattDeviceService\x12\x90\x01\n\x1bOnCharacteristicReadRequest\x12\x37.android.emulation.bluetooth.CharacteristicValueRequest\x1a\x38.android.emulation.bluetooth.CharacteristicValueResponse\x12\x91\x01\n\x1cOnCharacteristicWriteRequest\x12\x37.android.emulation.bluetooth.CharacteristicValueRequest\x1a\x38.android.emulation.bluetooth.CharacteristicValueResponse\x12\x95\x01\n\x1eOnCharacteristicObserveRequest\x12\x37.android.emulation.bluetooth.CharacteristicValueRequest\x1a\x38.android.emulation.bluetooth.CharacteristicValueResponse0\x01\x12\x65\n\x17OnConnectionStateChange\x12\x32.android.emulation.bluetooth.ConnectionStateChange\x1a\x16.google.protobuf.EmptyB\"\n\x1e\x63om.android.emulator.bluetoothP\x01\x62\x06proto3') + +_builder.BuildMessageAndEnumDescriptors(DESCRIPTOR, globals()) +_builder.BuildTopDescriptorsAndMessages(DESCRIPTOR, 'emulated_bluetooth_device_pb2', globals()) +if _descriptor._USE_C_DESCRIPTORS == False: + + DESCRIPTOR._options = None + DESCRIPTOR._serialized_options = b'\n\036com.android.emulator.bluetoothP\001' + _CALLBACKIDENTIFIER._serialized_start=126 + _CALLBACKIDENTIFIER._serialized_end=164 + _DEVICEIDENTIFIER._serialized_start=166 + _DEVICEIDENTIFIER._serialized_end=201 + _CONNECTIONSTATECHANGE._serialized_start=204 + _CONNECTIONSTATECHANGE._serialized_end=577 + _CONNECTIONSTATECHANGE_CONNECTIONSTATE._serialized_start=461 + _CONNECTIONSTATECHANGE_CONNECTIONSTATE._serialized_end=577 + _UUID._serialized_start=579 + _UUID._serialized_end=644 + _CHARACTERISTICVALUEREQUEST._serialized_start=647 + _CHARACTERISTICVALUEREQUEST._serialized_end=890 + _CHARACTERISTICVALUERESPONSE._serialized_start=893 + _CHARACTERISTICVALUERESPONSE._serialized_end=1114 + _CHARACTERISTICVALUERESPONSE_GATTSTATUS._serialized_start=1023 + _CHARACTERISTICVALUERESPONSE_GATTSTATUS._serialized_end=1114 + _GATTCHARACTERISTIC._serialized_start=1117 + _GATTCHARACTERISTIC._serialized_end=1808 + _GATTCHARACTERISTIC_PROPERTIES._serialized_start=1286 + _GATTCHARACTERISTIC_PROPERTIES._serialized_end=1520 + _GATTCHARACTERISTIC_PERMISSIONS._serialized_start=1523 + _GATTCHARACTERISTIC_PERMISSIONS._serialized_end=1808 + _GATTSERVICE._serialized_start=1811 + _GATTSERVICE._serialized_end=2122 + _GATTSERVICE_SERVICETYPE._serialized_start=2025 + _GATTSERVICE_SERVICETYPE._serialized_end=2122 + _GATTPROFILE._serialized_start=2124 + _GATTPROFILE._serialized_end=2197 + _ADVERTISEMENT._serialized_start=2200 + _ADVERTISEMENT._serialized_end=2696 + _ADVERTISEMENT_CONNECTIONMODE._serialized_start=2405 + _ADVERTISEMENT_CONNECTIONMODE._serialized_end=2553 + _ADVERTISEMENT_DISCOVERYMODE._serialized_start=2556 + _ADVERTISEMENT_DISCOVERYMODE._serialized_end=2696 + _GATTDEVICE._serialized_start=2699 + _GATTDEVICE._serialized_end=2891 + _GATTDEVICESERVICE._serialized_start=2894 + _GATTDEVICESERVICE._serialized_end=3463 +# @@protoc_insertion_point(module_scope) diff --git a/bumble/transport/grpc_protobuf/emulated_bluetooth_device_pb2.pyi b/bumble/transport/grpc_protobuf/emulated_bluetooth_device_pb2.pyi new file mode 100644 index 0000000..c14501b --- /dev/null +++ b/bumble/transport/grpc_protobuf/emulated_bluetooth_device_pb2.pyi @@ -0,0 +1,158 @@ +from . import grpc_endpoint_description_pb2 as _grpc_endpoint_description_pb2 +from google.protobuf import empty_pb2 as _empty_pb2 +from google.protobuf.internal import containers as _containers +from google.protobuf.internal import enum_type_wrapper as _enum_type_wrapper +from google.protobuf import descriptor as _descriptor +from google.protobuf import message as _message +from typing import ClassVar as _ClassVar, Iterable as _Iterable, Mapping as _Mapping, Optional as _Optional, Union as _Union + +DESCRIPTOR: _descriptor.FileDescriptor + +class Advertisement(_message.Message): + __slots__ = ["connection_mode", "device_name", "discovery_mode"] + class ConnectionMode(int, metaclass=_enum_type_wrapper.EnumTypeWrapper): + __slots__ = [] + class DiscoveryMode(int, metaclass=_enum_type_wrapper.EnumTypeWrapper): + __slots__ = [] + CONNECTION_MODE_DIRECTED: Advertisement.ConnectionMode + CONNECTION_MODE_FIELD_NUMBER: _ClassVar[int] + CONNECTION_MODE_NON_CONNECTABLE: Advertisement.ConnectionMode + CONNECTION_MODE_UNDIRECTED: Advertisement.ConnectionMode + CONNECTION_MODE_UNSPECIFIED: Advertisement.ConnectionMode + DEVICE_NAME_FIELD_NUMBER: _ClassVar[int] + DISCOVERY_MODE_FIELD_NUMBER: _ClassVar[int] + DISCOVERY_MODE_GENERAL: Advertisement.DiscoveryMode + DISCOVERY_MODE_LIMITED: Advertisement.DiscoveryMode + DISCOVERY_MODE_NON_DISCOVERABLE: Advertisement.DiscoveryMode + DISCOVERY_MODE_UNSPECIFIED: Advertisement.DiscoveryMode + connection_mode: Advertisement.ConnectionMode + device_name: str + discovery_mode: Advertisement.DiscoveryMode + def __init__(self, device_name: _Optional[str] = ..., connection_mode: _Optional[_Union[Advertisement.ConnectionMode, str]] = ..., discovery_mode: _Optional[_Union[Advertisement.DiscoveryMode, str]] = ...) -> None: ... + +class CallbackIdentifier(_message.Message): + __slots__ = ["identity"] + IDENTITY_FIELD_NUMBER: _ClassVar[int] + identity: str + def __init__(self, identity: _Optional[str] = ...) -> None: ... + +class CharacteristicValueRequest(_message.Message): + __slots__ = ["callback_device_id", "callback_id", "data", "from_device"] + CALLBACK_DEVICE_ID_FIELD_NUMBER: _ClassVar[int] + CALLBACK_ID_FIELD_NUMBER: _ClassVar[int] + DATA_FIELD_NUMBER: _ClassVar[int] + FROM_DEVICE_FIELD_NUMBER: _ClassVar[int] + callback_device_id: CallbackIdentifier + callback_id: Uuid + data: bytes + from_device: DeviceIdentifier + def __init__(self, callback_device_id: _Optional[_Union[CallbackIdentifier, _Mapping]] = ..., from_device: _Optional[_Union[DeviceIdentifier, _Mapping]] = ..., callback_id: _Optional[_Union[Uuid, _Mapping]] = ..., data: _Optional[bytes] = ...) -> None: ... + +class CharacteristicValueResponse(_message.Message): + __slots__ = ["data", "status"] + class GattStatus(int, metaclass=_enum_type_wrapper.EnumTypeWrapper): + __slots__ = [] + DATA_FIELD_NUMBER: _ClassVar[int] + GATT_STATUS_FAILURE: CharacteristicValueResponse.GattStatus + GATT_STATUS_SUCCESS: CharacteristicValueResponse.GattStatus + GATT_STATUS_UNSPECIFIED: CharacteristicValueResponse.GattStatus + STATUS_FIELD_NUMBER: _ClassVar[int] + data: bytes + status: CharacteristicValueResponse.GattStatus + def __init__(self, status: _Optional[_Union[CharacteristicValueResponse.GattStatus, str]] = ..., data: _Optional[bytes] = ...) -> None: ... + +class ConnectionStateChange(_message.Message): + __slots__ = ["callback_device_id", "from_device", "new_state"] + class ConnectionState(int, metaclass=_enum_type_wrapper.EnumTypeWrapper): + __slots__ = [] + CALLBACK_DEVICE_ID_FIELD_NUMBER: _ClassVar[int] + CONNECTION_STATE_CONNECTED: ConnectionStateChange.ConnectionState + CONNECTION_STATE_DISCONNECTED: ConnectionStateChange.ConnectionState + CONNECTION_STATE_UNDEFINED: ConnectionStateChange.ConnectionState + FROM_DEVICE_FIELD_NUMBER: _ClassVar[int] + NEW_STATE_FIELD_NUMBER: _ClassVar[int] + callback_device_id: CallbackIdentifier + from_device: DeviceIdentifier + new_state: ConnectionStateChange.ConnectionState + def __init__(self, callback_device_id: _Optional[_Union[CallbackIdentifier, _Mapping]] = ..., from_device: _Optional[_Union[DeviceIdentifier, _Mapping]] = ..., new_state: _Optional[_Union[ConnectionStateChange.ConnectionState, str]] = ...) -> None: ... + +class DeviceIdentifier(_message.Message): + __slots__ = ["address"] + ADDRESS_FIELD_NUMBER: _ClassVar[int] + address: str + def __init__(self, address: _Optional[str] = ...) -> None: ... + +class GattCharacteristic(_message.Message): + __slots__ = ["callback_id", "permissions", "properties", "uuid"] + class Permissions(int, metaclass=_enum_type_wrapper.EnumTypeWrapper): + __slots__ = [] + class Properties(int, metaclass=_enum_type_wrapper.EnumTypeWrapper): + __slots__ = [] + CALLBACK_ID_FIELD_NUMBER: _ClassVar[int] + PERMISSIONS_FIELD_NUMBER: _ClassVar[int] + PERMISSION_READ: GattCharacteristic.Permissions + PERMISSION_READ_ENCRYPTED: GattCharacteristic.Permissions + PERMISSION_READ_ENCRYPTED_MITM: GattCharacteristic.Permissions + PERMISSION_UNSPECIFIED: GattCharacteristic.Permissions + PERMISSION_WRITE: GattCharacteristic.Permissions + PERMISSION_WRITE_ENCRYPTED: GattCharacteristic.Permissions + PERMISSION_WRITE_ENCRYPTED_MITM: GattCharacteristic.Permissions + PERMISSION_WRITE_SIGNED: GattCharacteristic.Permissions + PERMISSION_WRITE_SIGNED_MITM: GattCharacteristic.Permissions + PROPERTIES_FIELD_NUMBER: _ClassVar[int] + PROPERTY_BROADCAST: GattCharacteristic.Properties + PROPERTY_EXTENDED_PROPS: GattCharacteristic.Properties + PROPERTY_INDICATE: GattCharacteristic.Properties + PROPERTY_NOTIFY: GattCharacteristic.Properties + PROPERTY_READ: GattCharacteristic.Properties + PROPERTY_SIGNED_WRITE: GattCharacteristic.Properties + PROPERTY_UNSPECIFIED: GattCharacteristic.Properties + PROPERTY_WRITE: GattCharacteristic.Properties + PROPERTY_WRITE_NO_RESPONSE: GattCharacteristic.Properties + UUID_FIELD_NUMBER: _ClassVar[int] + callback_id: Uuid + permissions: int + properties: int + uuid: Uuid + def __init__(self, uuid: _Optional[_Union[Uuid, _Mapping]] = ..., properties: _Optional[int] = ..., permissions: _Optional[int] = ..., callback_id: _Optional[_Union[Uuid, _Mapping]] = ...) -> None: ... + +class GattDevice(_message.Message): + __slots__ = ["advertisement", "endpoint", "profile"] + ADVERTISEMENT_FIELD_NUMBER: _ClassVar[int] + ENDPOINT_FIELD_NUMBER: _ClassVar[int] + PROFILE_FIELD_NUMBER: _ClassVar[int] + advertisement: Advertisement + endpoint: _grpc_endpoint_description_pb2.Endpoint + profile: GattProfile + def __init__(self, endpoint: _Optional[_Union[_grpc_endpoint_description_pb2.Endpoint, _Mapping]] = ..., advertisement: _Optional[_Union[Advertisement, _Mapping]] = ..., profile: _Optional[_Union[GattProfile, _Mapping]] = ...) -> None: ... + +class GattProfile(_message.Message): + __slots__ = ["services"] + SERVICES_FIELD_NUMBER: _ClassVar[int] + services: _containers.RepeatedCompositeFieldContainer[GattService] + def __init__(self, services: _Optional[_Iterable[_Union[GattService, _Mapping]]] = ...) -> None: ... + +class GattService(_message.Message): + __slots__ = ["characteristics", "service_type", "uuid"] + class ServiceType(int, metaclass=_enum_type_wrapper.EnumTypeWrapper): + __slots__ = [] + CHARACTERISTICS_FIELD_NUMBER: _ClassVar[int] + SERVICE_TYPE_FIELD_NUMBER: _ClassVar[int] + SERVICE_TYPE_PRIMARY: GattService.ServiceType + SERVICE_TYPE_SECONDARY: GattService.ServiceType + SERVICE_TYPE_UNSPECIFIED: GattService.ServiceType + UUID_FIELD_NUMBER: _ClassVar[int] + characteristics: _containers.RepeatedCompositeFieldContainer[GattCharacteristic] + service_type: GattService.ServiceType + uuid: Uuid + def __init__(self, uuid: _Optional[_Union[Uuid, _Mapping]] = ..., service_type: _Optional[_Union[GattService.ServiceType, str]] = ..., characteristics: _Optional[_Iterable[_Union[GattCharacteristic, _Mapping]]] = ...) -> None: ... + +class Uuid(_message.Message): + __slots__ = ["id", "lsb", "msb"] + ID_FIELD_NUMBER: _ClassVar[int] + LSB_FIELD_NUMBER: _ClassVar[int] + MSB_FIELD_NUMBER: _ClassVar[int] + id: int + lsb: int + msb: int + def __init__(self, id: _Optional[int] = ..., lsb: _Optional[int] = ..., msb: _Optional[int] = ...) -> None: ... diff --git a/bumble/transport/grpc_protobuf/emulated_bluetooth_device_pb2_grpc.py b/bumble/transport/grpc_protobuf/emulated_bluetooth_device_pb2_grpc.py new file mode 100644 index 0000000..754627d --- /dev/null +++ b/bumble/transport/grpc_protobuf/emulated_bluetooth_device_pb2_grpc.py @@ -0,0 +1,193 @@ +# Generated by the gRPC Python protocol compiler plugin. DO NOT EDIT! +"""Client and server classes corresponding to protobuf-defined services.""" +import grpc + +from . import emulated_bluetooth_device_pb2 as emulated__bluetooth__device__pb2 +from google.protobuf import empty_pb2 as google_dot_protobuf_dot_empty__pb2 + + +class GattDeviceServiceStub(object): + """You can provide your own GattDevice by implementing this service + and registering it with the android emulator. + + The device will appear as a real bluetooth device, and you will + receive callbacks when the bluetooth system wants to + read, write or observe a characteristic. + """ + + def __init__(self, channel): + """Constructor. + + Args: + channel: A grpc.Channel. + """ + self.OnCharacteristicReadRequest = channel.unary_unary( + '/android.emulation.bluetooth.GattDeviceService/OnCharacteristicReadRequest', + request_serializer=emulated__bluetooth__device__pb2.CharacteristicValueRequest.SerializeToString, + response_deserializer=emulated__bluetooth__device__pb2.CharacteristicValueResponse.FromString, + ) + self.OnCharacteristicWriteRequest = channel.unary_unary( + '/android.emulation.bluetooth.GattDeviceService/OnCharacteristicWriteRequest', + request_serializer=emulated__bluetooth__device__pb2.CharacteristicValueRequest.SerializeToString, + response_deserializer=emulated__bluetooth__device__pb2.CharacteristicValueResponse.FromString, + ) + self.OnCharacteristicObserveRequest = channel.unary_stream( + '/android.emulation.bluetooth.GattDeviceService/OnCharacteristicObserveRequest', + request_serializer=emulated__bluetooth__device__pb2.CharacteristicValueRequest.SerializeToString, + response_deserializer=emulated__bluetooth__device__pb2.CharacteristicValueResponse.FromString, + ) + self.OnConnectionStateChange = channel.unary_unary( + '/android.emulation.bluetooth.GattDeviceService/OnConnectionStateChange', + request_serializer=emulated__bluetooth__device__pb2.ConnectionStateChange.SerializeToString, + response_deserializer=google_dot_protobuf_dot_empty__pb2.Empty.FromString, + ) + + +class GattDeviceServiceServicer(object): + """You can provide your own GattDevice by implementing this service + and registering it with the android emulator. + + The device will appear as a real bluetooth device, and you will + receive callbacks when the bluetooth system wants to + read, write or observe a characteristic. + """ + + def OnCharacteristicReadRequest(self, request, context): + """A remote client has requested to read a local characteristic. + + Return the current observed value. + """ + context.set_code(grpc.StatusCode.UNIMPLEMENTED) + context.set_details('Method not implemented!') + raise NotImplementedError('Method not implemented!') + + def OnCharacteristicWriteRequest(self, request, context): + """A remote client has requested to write to a local characteristic. + + Return the current observed value. + """ + context.set_code(grpc.StatusCode.UNIMPLEMENTED) + context.set_details('Method not implemented!') + raise NotImplementedError('Method not implemented!') + + def OnCharacteristicObserveRequest(self, request, context): + """Listens for notifications from the emulated device, the device should + write to the stream with a response when a change has occurred. + """ + context.set_code(grpc.StatusCode.UNIMPLEMENTED) + context.set_details('Method not implemented!') + raise NotImplementedError('Method not implemented!') + + def OnConnectionStateChange(self, request, context): + """A remote device has been connected or disconnected. + """ + context.set_code(grpc.StatusCode.UNIMPLEMENTED) + context.set_details('Method not implemented!') + raise NotImplementedError('Method not implemented!') + + +def add_GattDeviceServiceServicer_to_server(servicer, server): + rpc_method_handlers = { + 'OnCharacteristicReadRequest': grpc.unary_unary_rpc_method_handler( + servicer.OnCharacteristicReadRequest, + request_deserializer=emulated__bluetooth__device__pb2.CharacteristicValueRequest.FromString, + response_serializer=emulated__bluetooth__device__pb2.CharacteristicValueResponse.SerializeToString, + ), + 'OnCharacteristicWriteRequest': grpc.unary_unary_rpc_method_handler( + servicer.OnCharacteristicWriteRequest, + request_deserializer=emulated__bluetooth__device__pb2.CharacteristicValueRequest.FromString, + response_serializer=emulated__bluetooth__device__pb2.CharacteristicValueResponse.SerializeToString, + ), + 'OnCharacteristicObserveRequest': grpc.unary_stream_rpc_method_handler( + servicer.OnCharacteristicObserveRequest, + request_deserializer=emulated__bluetooth__device__pb2.CharacteristicValueRequest.FromString, + response_serializer=emulated__bluetooth__device__pb2.CharacteristicValueResponse.SerializeToString, + ), + 'OnConnectionStateChange': grpc.unary_unary_rpc_method_handler( + servicer.OnConnectionStateChange, + request_deserializer=emulated__bluetooth__device__pb2.ConnectionStateChange.FromString, + response_serializer=google_dot_protobuf_dot_empty__pb2.Empty.SerializeToString, + ), + } + generic_handler = grpc.method_handlers_generic_handler( + 'android.emulation.bluetooth.GattDeviceService', rpc_method_handlers) + server.add_generic_rpc_handlers((generic_handler,)) + + + # This class is part of an EXPERIMENTAL API. +class GattDeviceService(object): + """You can provide your own GattDevice by implementing this service + and registering it with the android emulator. + + The device will appear as a real bluetooth device, and you will + receive callbacks when the bluetooth system wants to + read, write or observe a characteristic. + """ + + @staticmethod + def OnCharacteristicReadRequest(request, + target, + options=(), + channel_credentials=None, + call_credentials=None, + insecure=False, + compression=None, + wait_for_ready=None, + timeout=None, + metadata=None): + return grpc.experimental.unary_unary(request, target, '/android.emulation.bluetooth.GattDeviceService/OnCharacteristicReadRequest', + emulated__bluetooth__device__pb2.CharacteristicValueRequest.SerializeToString, + emulated__bluetooth__device__pb2.CharacteristicValueResponse.FromString, + options, channel_credentials, + insecure, call_credentials, compression, wait_for_ready, timeout, metadata) + + @staticmethod + def OnCharacteristicWriteRequest(request, + target, + options=(), + channel_credentials=None, + call_credentials=None, + insecure=False, + compression=None, + wait_for_ready=None, + timeout=None, + metadata=None): + return grpc.experimental.unary_unary(request, target, '/android.emulation.bluetooth.GattDeviceService/OnCharacteristicWriteRequest', + emulated__bluetooth__device__pb2.CharacteristicValueRequest.SerializeToString, + emulated__bluetooth__device__pb2.CharacteristicValueResponse.FromString, + options, channel_credentials, + insecure, call_credentials, compression, wait_for_ready, timeout, metadata) + + @staticmethod + def OnCharacteristicObserveRequest(request, + target, + options=(), + channel_credentials=None, + call_credentials=None, + insecure=False, + compression=None, + wait_for_ready=None, + timeout=None, + metadata=None): + return grpc.experimental.unary_stream(request, target, '/android.emulation.bluetooth.GattDeviceService/OnCharacteristicObserveRequest', + emulated__bluetooth__device__pb2.CharacteristicValueRequest.SerializeToString, + emulated__bluetooth__device__pb2.CharacteristicValueResponse.FromString, + options, channel_credentials, + insecure, call_credentials, compression, wait_for_ready, timeout, metadata) + + @staticmethod + def OnConnectionStateChange(request, + target, + options=(), + channel_credentials=None, + call_credentials=None, + insecure=False, + compression=None, + wait_for_ready=None, + timeout=None, + metadata=None): + return grpc.experimental.unary_unary(request, target, '/android.emulation.bluetooth.GattDeviceService/OnConnectionStateChange', + emulated__bluetooth__device__pb2.ConnectionStateChange.SerializeToString, + google_dot_protobuf_dot_empty__pb2.Empty.FromString, + options, channel_credentials, + insecure, call_credentials, compression, wait_for_ready, timeout, metadata) diff --git a/bumble/transport/grpc_protobuf/emulated_bluetooth_packets_pb2.py b/bumble/transport/grpc_protobuf/emulated_bluetooth_packets_pb2.py new file mode 100644 index 0000000..8544f95 --- /dev/null +++ b/bumble/transport/grpc_protobuf/emulated_bluetooth_packets_pb2.py @@ -0,0 +1,28 @@ +# -*- coding: utf-8 -*- +# Generated by the protocol buffer compiler. DO NOT EDIT! +# source: emulated_bluetooth_packets.proto +"""Generated protocol buffer code.""" +from google.protobuf.internal import builder as _builder +from google.protobuf import descriptor as _descriptor +from google.protobuf import descriptor_pool as _descriptor_pool +from google.protobuf import symbol_database as _symbol_database +# @@protoc_insertion_point(imports) + +_sym_db = _symbol_database.Default() + + + + +DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile(b'\n emulated_bluetooth_packets.proto\x12\x1b\x61ndroid.emulation.bluetooth\"\xfb\x01\n\tHCIPacket\x12?\n\x04type\x18\x01 \x01(\x0e\x32\x31.android.emulation.bluetooth.HCIPacket.PacketType\x12\x0e\n\x06packet\x18\x02 \x01(\x0c\"\x9c\x01\n\nPacketType\x12\x1b\n\x17PACKET_TYPE_UNSPECIFIED\x10\x00\x12\x1b\n\x17PACKET_TYPE_HCI_COMMAND\x10\x01\x12\x13\n\x0fPACKET_TYPE_ACL\x10\x02\x12\x13\n\x0fPACKET_TYPE_SCO\x10\x03\x12\x15\n\x11PACKET_TYPE_EVENT\x10\x04\x12\x13\n\x0fPACKET_TYPE_ISO\x10\x05\x42J\n\x1f\x63om.android.emulation.bluetoothP\x01\xf8\x01\x01\xa2\x02\x03\x41\x45\x42\xaa\x02\x1b\x41ndroid.Emulation.Bluetoothb\x06proto3') + +_builder.BuildMessageAndEnumDescriptors(DESCRIPTOR, globals()) +_builder.BuildTopDescriptorsAndMessages(DESCRIPTOR, 'emulated_bluetooth_packets_pb2', globals()) +if _descriptor._USE_C_DESCRIPTORS == False: + + DESCRIPTOR._options = None + DESCRIPTOR._serialized_options = b'\n\037com.android.emulation.bluetoothP\001\370\001\001\242\002\003AEB\252\002\033Android.Emulation.Bluetooth' + _HCIPACKET._serialized_start=66 + _HCIPACKET._serialized_end=317 + _HCIPACKET_PACKETTYPE._serialized_start=161 + _HCIPACKET_PACKETTYPE._serialized_end=317 +# @@protoc_insertion_point(module_scope) diff --git a/bumble/transport/emulated_bluetooth_packets_pb2.pyi b/bumble/transport/grpc_protobuf/emulated_bluetooth_packets_pb2.pyi index a823e78..80cdee1 100644 --- a/bumble/transport/emulated_bluetooth_packets_pb2.pyi +++ b/bumble/transport/grpc_protobuf/emulated_bluetooth_packets_pb2.pyi @@ -1,17 +1,3 @@ -# 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. - from google.protobuf.internal import enum_type_wrapper as _enum_type_wrapper from google.protobuf import descriptor as _descriptor from google.protobuf import message as _message @@ -21,7 +7,6 @@ DESCRIPTOR: _descriptor.FileDescriptor class HCIPacket(_message.Message): __slots__ = ["packet", "type"] - class PacketType(int, metaclass=_enum_type_wrapper.EnumTypeWrapper): __slots__ = [] PACKET_FIELD_NUMBER: _ClassVar[int] @@ -34,8 +19,4 @@ class HCIPacket(_message.Message): TYPE_FIELD_NUMBER: _ClassVar[int] packet: bytes type: HCIPacket.PacketType - def __init__( - self, - type: _Optional[_Union[HCIPacket.PacketType, str]] = ..., - packet: _Optional[bytes] = ..., - ) -> None: ... + def __init__(self, type: _Optional[_Union[HCIPacket.PacketType, str]] = ..., packet: _Optional[bytes] = ...) -> None: ... diff --git a/bumble/transport/grpc_protobuf/emulated_bluetooth_packets_pb2_grpc.py b/bumble/transport/grpc_protobuf/emulated_bluetooth_packets_pb2_grpc.py new file mode 100644 index 0000000..2daafff --- /dev/null +++ b/bumble/transport/grpc_protobuf/emulated_bluetooth_packets_pb2_grpc.py @@ -0,0 +1,4 @@ +# Generated by the gRPC Python protocol compiler plugin. DO NOT EDIT! +"""Client and server classes corresponding to protobuf-defined services.""" +import grpc + diff --git a/bumble/transport/grpc_protobuf/emulated_bluetooth_pb2.py b/bumble/transport/grpc_protobuf/emulated_bluetooth_pb2.py new file mode 100644 index 0000000..054b250 --- /dev/null +++ b/bumble/transport/grpc_protobuf/emulated_bluetooth_pb2.py @@ -0,0 +1,32 @@ +# -*- coding: utf-8 -*- +# Generated by the protocol buffer compiler. DO NOT EDIT! +# source: emulated_bluetooth.proto +"""Generated protocol buffer code.""" +from google.protobuf.internal import builder as _builder +from google.protobuf import descriptor as _descriptor +from google.protobuf import descriptor_pool as _descriptor_pool +from google.protobuf import symbol_database as _symbol_database +# @@protoc_insertion_point(imports) + +_sym_db = _symbol_database.Default() + + +from . import emulated_bluetooth_packets_pb2 as emulated__bluetooth__packets__pb2 +from . import emulated_bluetooth_device_pb2 as emulated__bluetooth__device__pb2 + + +DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile(b'\n\x18\x65mulated_bluetooth.proto\x12\x1b\x61ndroid.emulation.bluetooth\x1a emulated_bluetooth_packets.proto\x1a\x1f\x65mulated_bluetooth_device.proto\"\x19\n\x07RawData\x12\x0e\n\x06packet\x18\x01 \x01(\x0c\"a\n\x12RegistrationStatus\x12K\n\x12\x63\x61llback_device_id\x18\x01 \x01(\x0b\x32/.android.emulation.bluetooth.CallbackIdentifier2\xbb\x03\n\x18\x45mulatedBluetoothService\x12\x64\n\x12registerClassicPhy\x12$.android.emulation.bluetooth.RawData\x1a$.android.emulation.bluetooth.RawData(\x01\x30\x01\x12`\n\x0eregisterBlePhy\x12$.android.emulation.bluetooth.RawData\x1a$.android.emulation.bluetooth.RawData(\x01\x30\x01\x12g\n\x11registerHCIDevice\x12&.android.emulation.bluetooth.HCIPacket\x1a&.android.emulation.bluetooth.HCIPacket(\x01\x30\x01\x12n\n\x12registerGattDevice\x12\'.android.emulation.bluetooth.GattDevice\x1a/.android.emulation.bluetooth.RegistrationStatusB\"\n\x1e\x63om.android.emulator.bluetoothP\x01\x62\x06proto3') + +_builder.BuildMessageAndEnumDescriptors(DESCRIPTOR, globals()) +_builder.BuildTopDescriptorsAndMessages(DESCRIPTOR, 'emulated_bluetooth_pb2', globals()) +if _descriptor._USE_C_DESCRIPTORS == False: + + DESCRIPTOR._options = None + DESCRIPTOR._serialized_options = b'\n\036com.android.emulator.bluetoothP\001' + _RAWDATA._serialized_start=124 + _RAWDATA._serialized_end=149 + _REGISTRATIONSTATUS._serialized_start=151 + _REGISTRATIONSTATUS._serialized_end=248 + _EMULATEDBLUETOOTHSERVICE._serialized_start=251 + _EMULATEDBLUETOOTHSERVICE._serialized_end=694 +# @@protoc_insertion_point(module_scope) diff --git a/bumble/transport/grpc_protobuf/emulated_bluetooth_pb2.pyi b/bumble/transport/grpc_protobuf/emulated_bluetooth_pb2.pyi new file mode 100644 index 0000000..9f54383 --- /dev/null +++ b/bumble/transport/grpc_protobuf/emulated_bluetooth_pb2.pyi @@ -0,0 +1,19 @@ +from . import emulated_bluetooth_packets_pb2 as _emulated_bluetooth_packets_pb2 +from . import emulated_bluetooth_device_pb2 as _emulated_bluetooth_device_pb2 +from google.protobuf import descriptor as _descriptor +from google.protobuf import message as _message +from typing import ClassVar as _ClassVar, Mapping as _Mapping, Optional as _Optional, Union as _Union + +DESCRIPTOR: _descriptor.FileDescriptor + +class RawData(_message.Message): + __slots__ = ["packet"] + PACKET_FIELD_NUMBER: _ClassVar[int] + packet: bytes + def __init__(self, packet: _Optional[bytes] = ...) -> None: ... + +class RegistrationStatus(_message.Message): + __slots__ = ["callback_device_id"] + CALLBACK_DEVICE_ID_FIELD_NUMBER: _ClassVar[int] + callback_device_id: _emulated_bluetooth_device_pb2.CallbackIdentifier + def __init__(self, callback_device_id: _Optional[_Union[_emulated_bluetooth_device_pb2.CallbackIdentifier, _Mapping]] = ...) -> None: ... diff --git a/bumble/transport/grpc_protobuf/emulated_bluetooth_pb2_grpc.py b/bumble/transport/grpc_protobuf/emulated_bluetooth_pb2_grpc.py new file mode 100644 index 0000000..9ad3bb9 --- /dev/null +++ b/bumble/transport/grpc_protobuf/emulated_bluetooth_pb2_grpc.py @@ -0,0 +1,237 @@ +# Generated by the gRPC Python protocol compiler plugin. DO NOT EDIT! +"""Client and server classes corresponding to protobuf-defined services.""" +import grpc + +from . import emulated_bluetooth_device_pb2 as emulated__bluetooth__device__pb2 +from . import emulated_bluetooth_packets_pb2 as emulated__bluetooth__packets__pb2 +from . import emulated_bluetooth_pb2 as emulated__bluetooth__pb2 + + +class EmulatedBluetoothServiceStub(object): + """An Emulated Bluetooth Service exposes the emulated bluetooth chip from the + android emulator. It allows you to register emulated bluetooth devices and + control the packets that are exchanged between the device and the world. + + This service enables you to establish a "virtual network" of emulated + bluetooth devices that can interact with each other. + + Note: This is not yet finalized, it is likely that these definitions will + evolve. + """ + + def __init__(self, channel): + """Constructor. + + Args: + channel: A grpc.Channel. + """ + self.registerClassicPhy = channel.stream_stream( + '/android.emulation.bluetooth.EmulatedBluetoothService/registerClassicPhy', + request_serializer=emulated__bluetooth__pb2.RawData.SerializeToString, + response_deserializer=emulated__bluetooth__pb2.RawData.FromString, + ) + self.registerBlePhy = channel.stream_stream( + '/android.emulation.bluetooth.EmulatedBluetoothService/registerBlePhy', + request_serializer=emulated__bluetooth__pb2.RawData.SerializeToString, + response_deserializer=emulated__bluetooth__pb2.RawData.FromString, + ) + self.registerHCIDevice = channel.stream_stream( + '/android.emulation.bluetooth.EmulatedBluetoothService/registerHCIDevice', + request_serializer=emulated__bluetooth__packets__pb2.HCIPacket.SerializeToString, + response_deserializer=emulated__bluetooth__packets__pb2.HCIPacket.FromString, + ) + self.registerGattDevice = channel.unary_unary( + '/android.emulation.bluetooth.EmulatedBluetoothService/registerGattDevice', + request_serializer=emulated__bluetooth__device__pb2.GattDevice.SerializeToString, + response_deserializer=emulated__bluetooth__pb2.RegistrationStatus.FromString, + ) + + +class EmulatedBluetoothServiceServicer(object): + """An Emulated Bluetooth Service exposes the emulated bluetooth chip from the + android emulator. It allows you to register emulated bluetooth devices and + control the packets that are exchanged between the device and the world. + + This service enables you to establish a "virtual network" of emulated + bluetooth devices that can interact with each other. + + Note: This is not yet finalized, it is likely that these definitions will + evolve. + """ + + def registerClassicPhy(self, request_iterator, context): + """Connect device to link layer. This will establish a direct connection + to the emulated bluetooth chip and configure the following: + + - Each connection creates a new device and attaches it to the link layer + - Link Layer packets are transmitted directly to the phy + + This should be used for classic connections. + + This is used to directly connect various android emulators together. + For example a wear device can connect to an android emulator through + this. + """ + context.set_code(grpc.StatusCode.UNIMPLEMENTED) + context.set_details('Method not implemented!') + raise NotImplementedError('Method not implemented!') + + def registerBlePhy(self, request_iterator, context): + """Connect device to link layer. This will establish a direct connection + to root canal and execute the following: + + - Each connection creates a new device and attaches it to the link layer + - Link Layer packets are transmitted directly to the phy + + This should be used for BLE connections. + + This is used to directly connect various android emulators together. + For example a wear device can connect to an android emulator through + this. + """ + context.set_code(grpc.StatusCode.UNIMPLEMENTED) + context.set_details('Method not implemented!') + raise NotImplementedError('Method not implemented!') + + def registerHCIDevice(self, request_iterator, context): + """Connect the device to the emulated bluetooth chip. The device will + participate in the network. You can configure the chip to scan, advertise + and setup connections with other devices that are connected to the + network. + + This is usually used when you have a need for an emulated bluetooth chip + and have a bluetooth stack that can interpret and handle the packets + correctly. + + For example the apache nimble stack can use this endpoint as the + transport layer. + """ + context.set_code(grpc.StatusCode.UNIMPLEMENTED) + context.set_details('Method not implemented!') + raise NotImplementedError('Method not implemented!') + + def registerGattDevice(self, request, context): + """Registers an emulated bluetooth device. The emulator will reach out to + the emulated device to read/write and subscribe to properties. + + The following gRPC error codes can be returned: + - FAILED_PRECONDITION (code 9): + - root canal is not available on this device + - unable to reach the endpoint for the GattDevice + - INTERNAL (code 13) if there was an internal emulator failure. + + The device will not be discoverable in case of an error. + """ + context.set_code(grpc.StatusCode.UNIMPLEMENTED) + context.set_details('Method not implemented!') + raise NotImplementedError('Method not implemented!') + + +def add_EmulatedBluetoothServiceServicer_to_server(servicer, server): + rpc_method_handlers = { + 'registerClassicPhy': grpc.stream_stream_rpc_method_handler( + servicer.registerClassicPhy, + request_deserializer=emulated__bluetooth__pb2.RawData.FromString, + response_serializer=emulated__bluetooth__pb2.RawData.SerializeToString, + ), + 'registerBlePhy': grpc.stream_stream_rpc_method_handler( + servicer.registerBlePhy, + request_deserializer=emulated__bluetooth__pb2.RawData.FromString, + response_serializer=emulated__bluetooth__pb2.RawData.SerializeToString, + ), + 'registerHCIDevice': grpc.stream_stream_rpc_method_handler( + servicer.registerHCIDevice, + request_deserializer=emulated__bluetooth__packets__pb2.HCIPacket.FromString, + response_serializer=emulated__bluetooth__packets__pb2.HCIPacket.SerializeToString, + ), + 'registerGattDevice': grpc.unary_unary_rpc_method_handler( + servicer.registerGattDevice, + request_deserializer=emulated__bluetooth__device__pb2.GattDevice.FromString, + response_serializer=emulated__bluetooth__pb2.RegistrationStatus.SerializeToString, + ), + } + generic_handler = grpc.method_handlers_generic_handler( + 'android.emulation.bluetooth.EmulatedBluetoothService', rpc_method_handlers) + server.add_generic_rpc_handlers((generic_handler,)) + + + # This class is part of an EXPERIMENTAL API. +class EmulatedBluetoothService(object): + """An Emulated Bluetooth Service exposes the emulated bluetooth chip from the + android emulator. It allows you to register emulated bluetooth devices and + control the packets that are exchanged between the device and the world. + + This service enables you to establish a "virtual network" of emulated + bluetooth devices that can interact with each other. + + Note: This is not yet finalized, it is likely that these definitions will + evolve. + """ + + @staticmethod + def registerClassicPhy(request_iterator, + target, + options=(), + channel_credentials=None, + call_credentials=None, + insecure=False, + compression=None, + wait_for_ready=None, + timeout=None, + metadata=None): + return grpc.experimental.stream_stream(request_iterator, target, '/android.emulation.bluetooth.EmulatedBluetoothService/registerClassicPhy', + emulated__bluetooth__pb2.RawData.SerializeToString, + emulated__bluetooth__pb2.RawData.FromString, + options, channel_credentials, + insecure, call_credentials, compression, wait_for_ready, timeout, metadata) + + @staticmethod + def registerBlePhy(request_iterator, + target, + options=(), + channel_credentials=None, + call_credentials=None, + insecure=False, + compression=None, + wait_for_ready=None, + timeout=None, + metadata=None): + return grpc.experimental.stream_stream(request_iterator, target, '/android.emulation.bluetooth.EmulatedBluetoothService/registerBlePhy', + emulated__bluetooth__pb2.RawData.SerializeToString, + emulated__bluetooth__pb2.RawData.FromString, + options, channel_credentials, + insecure, call_credentials, compression, wait_for_ready, timeout, metadata) + + @staticmethod + def registerHCIDevice(request_iterator, + target, + options=(), + channel_credentials=None, + call_credentials=None, + insecure=False, + compression=None, + wait_for_ready=None, + timeout=None, + metadata=None): + return grpc.experimental.stream_stream(request_iterator, target, '/android.emulation.bluetooth.EmulatedBluetoothService/registerHCIDevice', + emulated__bluetooth__packets__pb2.HCIPacket.SerializeToString, + emulated__bluetooth__packets__pb2.HCIPacket.FromString, + options, channel_credentials, + insecure, call_credentials, compression, wait_for_ready, timeout, metadata) + + @staticmethod + def registerGattDevice(request, + target, + options=(), + channel_credentials=None, + call_credentials=None, + insecure=False, + compression=None, + wait_for_ready=None, + timeout=None, + metadata=None): + return grpc.experimental.unary_unary(request, target, '/android.emulation.bluetooth.EmulatedBluetoothService/registerGattDevice', + emulated__bluetooth__device__pb2.GattDevice.SerializeToString, + emulated__bluetooth__pb2.RegistrationStatus.FromString, + options, channel_credentials, + insecure, call_credentials, compression, wait_for_ready, timeout, metadata) diff --git a/bumble/transport/grpc_protobuf/emulated_bluetooth_vhci_pb2.py b/bumble/transport/grpc_protobuf/emulated_bluetooth_vhci_pb2.py new file mode 100644 index 0000000..30a2cb6 --- /dev/null +++ b/bumble/transport/grpc_protobuf/emulated_bluetooth_vhci_pb2.py @@ -0,0 +1,27 @@ +# -*- coding: utf-8 -*- +# Generated by the protocol buffer compiler. DO NOT EDIT! +# source: emulated_bluetooth_vhci.proto +"""Generated protocol buffer code.""" +from google.protobuf.internal import builder as _builder +from google.protobuf import descriptor as _descriptor +from google.protobuf import descriptor_pool as _descriptor_pool +from google.protobuf import symbol_database as _symbol_database +# @@protoc_insertion_point(imports) + +_sym_db = _symbol_database.Default() + + +from . import emulated_bluetooth_packets_pb2 as emulated__bluetooth__packets__pb2 + + +DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile(b'\n\x1d\x65mulated_bluetooth_vhci.proto\x12\x1b\x61ndroid.emulation.bluetooth\x1a emulated_bluetooth_packets.proto2y\n\x15VhciForwardingService\x12`\n\nattachVhci\x12&.android.emulation.bluetooth.HCIPacket\x1a&.android.emulation.bluetooth.HCIPacket(\x01\x30\x01\x42J\n\x1f\x63om.android.emulation.bluetoothP\x01\xf8\x01\x01\xa2\x02\x03\x41\x45\x42\xaa\x02\x1b\x41ndroid.Emulation.Bluetoothb\x06proto3') + +_builder.BuildMessageAndEnumDescriptors(DESCRIPTOR, globals()) +_builder.BuildTopDescriptorsAndMessages(DESCRIPTOR, 'emulated_bluetooth_vhci_pb2', globals()) +if _descriptor._USE_C_DESCRIPTORS == False: + + DESCRIPTOR._options = None + DESCRIPTOR._serialized_options = b'\n\037com.android.emulation.bluetoothP\001\370\001\001\242\002\003AEB\252\002\033Android.Emulation.Bluetooth' + _VHCIFORWARDINGSERVICE._serialized_start=96 + _VHCIFORWARDINGSERVICE._serialized_end=217 +# @@protoc_insertion_point(module_scope) diff --git a/bumble/transport/grpc_protobuf/emulated_bluetooth_vhci_pb2.pyi b/bumble/transport/grpc_protobuf/emulated_bluetooth_vhci_pb2.pyi new file mode 100644 index 0000000..3311c9b --- /dev/null +++ b/bumble/transport/grpc_protobuf/emulated_bluetooth_vhci_pb2.pyi @@ -0,0 +1,5 @@ +import emulated_bluetooth_packets_pb2 as _emulated_bluetooth_packets_pb2 +from google.protobuf import descriptor as _descriptor +from typing import ClassVar as _ClassVar + +DESCRIPTOR: _descriptor.FileDescriptor diff --git a/bumble/transport/emulated_bluetooth_vhci_pb2_grpc.py b/bumble/transport/grpc_protobuf/emulated_bluetooth_vhci_pb2_grpc.py index 41f0feb..031f669 100644 --- a/bumble/transport/emulated_bluetooth_vhci_pb2_grpc.py +++ b/bumble/transport/grpc_protobuf/emulated_bluetooth_vhci_pb2_grpc.py @@ -1,17 +1,3 @@ -# 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. - # Generated by the gRPC Python protocol compiler plugin. DO NOT EDIT! """Client and server classes corresponding to protobuf-defined services.""" import grpc @@ -35,10 +21,10 @@ class VhciForwardingServiceStub(object): channel: A grpc.Channel. """ self.attachVhci = channel.stream_stream( - '/android.emulation.bluetooth.VhciForwardingService/attachVhci', - request_serializer=emulated__bluetooth__packets__pb2.HCIPacket.SerializeToString, - response_deserializer=emulated__bluetooth__packets__pb2.HCIPacket.FromString, - ) + '/android.emulation.bluetooth.VhciForwardingService/attachVhci', + request_serializer=emulated__bluetooth__packets__pb2.HCIPacket.SerializeToString, + response_deserializer=emulated__bluetooth__packets__pb2.HCIPacket.FromString, + ) class VhciForwardingServiceServicer(object): @@ -75,19 +61,18 @@ class VhciForwardingServiceServicer(object): def add_VhciForwardingServiceServicer_to_server(servicer, server): rpc_method_handlers = { - 'attachVhci': grpc.stream_stream_rpc_method_handler( - servicer.attachVhci, - request_deserializer=emulated__bluetooth__packets__pb2.HCIPacket.FromString, - response_serializer=emulated__bluetooth__packets__pb2.HCIPacket.SerializeToString, - ), + 'attachVhci': grpc.stream_stream_rpc_method_handler( + servicer.attachVhci, + request_deserializer=emulated__bluetooth__packets__pb2.HCIPacket.FromString, + response_serializer=emulated__bluetooth__packets__pb2.HCIPacket.SerializeToString, + ), } generic_handler = grpc.method_handlers_generic_handler( - 'android.emulation.bluetooth.VhciForwardingService', rpc_method_handlers - ) + 'android.emulation.bluetooth.VhciForwardingService', rpc_method_handlers) server.add_generic_rpc_handlers((generic_handler,)) -# This class is part of an EXPERIMENTAL API. + # This class is part of an EXPERIMENTAL API. class VhciForwardingService(object): """This is a service which allows you to directly intercept the VHCI packets that are coming and going to the device before they are delivered to @@ -98,30 +83,18 @@ class VhciForwardingService(object): """ @staticmethod - def attachVhci( - request_iterator, - target, - options=(), - channel_credentials=None, - call_credentials=None, - insecure=False, - compression=None, - wait_for_ready=None, - timeout=None, - metadata=None, - ): - return grpc.experimental.stream_stream( - request_iterator, + def attachVhci(request_iterator, target, - '/android.emulation.bluetooth.VhciForwardingService/attachVhci', + options=(), + channel_credentials=None, + call_credentials=None, + insecure=False, + compression=None, + wait_for_ready=None, + timeout=None, + metadata=None): + return grpc.experimental.stream_stream(request_iterator, target, '/android.emulation.bluetooth.VhciForwardingService/attachVhci', emulated__bluetooth__packets__pb2.HCIPacket.SerializeToString, emulated__bluetooth__packets__pb2.HCIPacket.FromString, - options, - channel_credentials, - insecure, - call_credentials, - compression, - wait_for_ready, - timeout, - metadata, - ) + options, channel_credentials, + insecure, call_credentials, compression, wait_for_ready, timeout, metadata) diff --git a/bumble/transport/grpc_protobuf/grpc_endpoint_description_pb2.py b/bumble/transport/grpc_protobuf/grpc_endpoint_description_pb2.py new file mode 100644 index 0000000..00d404c --- /dev/null +++ b/bumble/transport/grpc_protobuf/grpc_endpoint_description_pb2.py @@ -0,0 +1,30 @@ +# -*- coding: utf-8 -*- +# Generated by the protocol buffer compiler. DO NOT EDIT! +# source: grpc_endpoint_description.proto +"""Generated protocol buffer code.""" +from google.protobuf.internal import builder as _builder +from google.protobuf import descriptor as _descriptor +from google.protobuf import descriptor_pool as _descriptor_pool +from google.protobuf import symbol_database as _symbol_database +# @@protoc_insertion_point(imports) + +_sym_db = _symbol_database.Default() + + + + +DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile(b'\n\x1fgrpc_endpoint_description.proto\x12\x18\x61ndroid.emulation.remote\"V\n\x0b\x43redentials\x12\x16\n\x0epem_root_certs\x18\x01 \x01(\t\x12\x17\n\x0fpem_private_key\x18\x02 \x01(\t\x12\x16\n\x0epem_cert_chain\x18\x03 \x01(\t\"$\n\x06Header\x12\x0b\n\x03key\x18\x01 \x01(\t\x12\r\n\x05value\x18\x02 \x01(\t\"\x96\x01\n\x08\x45ndpoint\x12\x0e\n\x06target\x18\x01 \x01(\t\x12>\n\x0ftls_credentials\x18\x02 \x01(\x0b\x32%.android.emulation.remote.Credentials\x12:\n\x10required_headers\x18\x03 \x03(\x0b\x32 .android.emulation.remote.HeaderB \n\x1c\x63om.android.emulation.remoteP\x01\x62\x06proto3') + +_builder.BuildMessageAndEnumDescriptors(DESCRIPTOR, globals()) +_builder.BuildTopDescriptorsAndMessages(DESCRIPTOR, 'grpc_endpoint_description_pb2', globals()) +if _descriptor._USE_C_DESCRIPTORS == False: + + DESCRIPTOR._options = None + DESCRIPTOR._serialized_options = b'\n\034com.android.emulation.remoteP\001' + _CREDENTIALS._serialized_start=61 + _CREDENTIALS._serialized_end=147 + _HEADER._serialized_start=149 + _HEADER._serialized_end=185 + _ENDPOINT._serialized_start=188 + _ENDPOINT._serialized_end=338 +# @@protoc_insertion_point(module_scope) diff --git a/bumble/transport/grpc_protobuf/grpc_endpoint_description_pb2.pyi b/bumble/transport/grpc_protobuf/grpc_endpoint_description_pb2.pyi new file mode 100644 index 0000000..3b81215 --- /dev/null +++ b/bumble/transport/grpc_protobuf/grpc_endpoint_description_pb2.pyi @@ -0,0 +1,34 @@ +from google.protobuf.internal import containers as _containers +from google.protobuf import descriptor as _descriptor +from google.protobuf import message as _message +from typing import ClassVar as _ClassVar, Iterable as _Iterable, Mapping as _Mapping, Optional as _Optional, Union as _Union + +DESCRIPTOR: _descriptor.FileDescriptor + +class Credentials(_message.Message): + __slots__ = ["pem_cert_chain", "pem_private_key", "pem_root_certs"] + PEM_CERT_CHAIN_FIELD_NUMBER: _ClassVar[int] + PEM_PRIVATE_KEY_FIELD_NUMBER: _ClassVar[int] + PEM_ROOT_CERTS_FIELD_NUMBER: _ClassVar[int] + pem_cert_chain: str + pem_private_key: str + pem_root_certs: str + def __init__(self, pem_root_certs: _Optional[str] = ..., pem_private_key: _Optional[str] = ..., pem_cert_chain: _Optional[str] = ...) -> None: ... + +class Endpoint(_message.Message): + __slots__ = ["required_headers", "target", "tls_credentials"] + REQUIRED_HEADERS_FIELD_NUMBER: _ClassVar[int] + TARGET_FIELD_NUMBER: _ClassVar[int] + TLS_CREDENTIALS_FIELD_NUMBER: _ClassVar[int] + required_headers: _containers.RepeatedCompositeFieldContainer[Header] + target: str + tls_credentials: Credentials + def __init__(self, target: _Optional[str] = ..., tls_credentials: _Optional[_Union[Credentials, _Mapping]] = ..., required_headers: _Optional[_Iterable[_Union[Header, _Mapping]]] = ...) -> None: ... + +class Header(_message.Message): + __slots__ = ["key", "value"] + KEY_FIELD_NUMBER: _ClassVar[int] + VALUE_FIELD_NUMBER: _ClassVar[int] + key: str + value: str + def __init__(self, key: _Optional[str] = ..., value: _Optional[str] = ...) -> None: ... diff --git a/bumble/transport/grpc_protobuf/grpc_endpoint_description_pb2_grpc.py b/bumble/transport/grpc_protobuf/grpc_endpoint_description_pb2_grpc.py new file mode 100644 index 0000000..2daafff --- /dev/null +++ b/bumble/transport/grpc_protobuf/grpc_endpoint_description_pb2_grpc.py @@ -0,0 +1,4 @@ +# Generated by the gRPC Python protocol compiler plugin. DO NOT EDIT! +"""Client and server classes corresponding to protobuf-defined services.""" +import grpc + diff --git a/bumble/transport/grpc_protobuf/hci_packet_pb2.py b/bumble/transport/grpc_protobuf/hci_packet_pb2.py new file mode 100644 index 0000000..ef014c4 --- /dev/null +++ b/bumble/transport/grpc_protobuf/hci_packet_pb2.py @@ -0,0 +1,28 @@ +# -*- coding: utf-8 -*- +# Generated by the protocol buffer compiler. DO NOT EDIT! +# source: hci_packet.proto +"""Generated protocol buffer code.""" +from google.protobuf.internal import builder as _builder +from google.protobuf import descriptor as _descriptor +from google.protobuf import descriptor_pool as _descriptor_pool +from google.protobuf import symbol_database as _symbol_database +# @@protoc_insertion_point(imports) + +_sym_db = _symbol_database.Default() + + + + +DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile(b'\n\x10hci_packet.proto\x12\rnetsim.packet\"\xb2\x01\n\tHCIPacket\x12\x38\n\x0bpacket_type\x18\x01 \x01(\x0e\x32#.netsim.packet.HCIPacket.PacketType\x12\x0e\n\x06packet\x18\x02 \x01(\x0c\"[\n\nPacketType\x12\x1a\n\x16HCI_PACKET_UNSPECIFIED\x10\x00\x12\x0b\n\x07\x43OMMAND\x10\x01\x12\x07\n\x03\x41\x43L\x10\x02\x12\x07\n\x03SCO\x10\x03\x12\t\n\x05\x45VENT\x10\x04\x12\x07\n\x03ISO\x10\x05\x42J\n\x1f\x63om.android.emulation.bluetoothP\x01\xf8\x01\x01\xa2\x02\x03\x41\x45\x42\xaa\x02\x1b\x41ndroid.Emulation.Bluetoothb\x06proto3') + +_builder.BuildMessageAndEnumDescriptors(DESCRIPTOR, globals()) +_builder.BuildTopDescriptorsAndMessages(DESCRIPTOR, 'hci_packet_pb2', globals()) +if _descriptor._USE_C_DESCRIPTORS == False: + + DESCRIPTOR._options = None + DESCRIPTOR._serialized_options = b'\n\037com.android.emulation.bluetoothP\001\370\001\001\242\002\003AEB\252\002\033Android.Emulation.Bluetooth' + _HCIPACKET._serialized_start=36 + _HCIPACKET._serialized_end=214 + _HCIPACKET_PACKETTYPE._serialized_start=123 + _HCIPACKET_PACKETTYPE._serialized_end=214 +# @@protoc_insertion_point(module_scope) diff --git a/bumble/transport/grpc_protobuf/hci_packet_pb2.pyi b/bumble/transport/grpc_protobuf/hci_packet_pb2.pyi new file mode 100644 index 0000000..04bb972 --- /dev/null +++ b/bumble/transport/grpc_protobuf/hci_packet_pb2.pyi @@ -0,0 +1,22 @@ +from google.protobuf.internal import enum_type_wrapper as _enum_type_wrapper +from google.protobuf import descriptor as _descriptor +from google.protobuf import message as _message +from typing import ClassVar as _ClassVar, Optional as _Optional, Union as _Union + +DESCRIPTOR: _descriptor.FileDescriptor + +class HCIPacket(_message.Message): + __slots__ = ["packet", "packet_type"] + class PacketType(int, metaclass=_enum_type_wrapper.EnumTypeWrapper): + __slots__ = [] + ACL: HCIPacket.PacketType + COMMAND: HCIPacket.PacketType + EVENT: HCIPacket.PacketType + HCI_PACKET_UNSPECIFIED: HCIPacket.PacketType + ISO: HCIPacket.PacketType + PACKET_FIELD_NUMBER: _ClassVar[int] + PACKET_TYPE_FIELD_NUMBER: _ClassVar[int] + SCO: HCIPacket.PacketType + packet: bytes + packet_type: HCIPacket.PacketType + def __init__(self, packet_type: _Optional[_Union[HCIPacket.PacketType, str]] = ..., packet: _Optional[bytes] = ...) -> None: ... diff --git a/bumble/transport/grpc_protobuf/hci_packet_pb2_grpc.py b/bumble/transport/grpc_protobuf/hci_packet_pb2_grpc.py new file mode 100644 index 0000000..2daafff --- /dev/null +++ b/bumble/transport/grpc_protobuf/hci_packet_pb2_grpc.py @@ -0,0 +1,4 @@ +# Generated by the gRPC Python protocol compiler plugin. DO NOT EDIT! +"""Client and server classes corresponding to protobuf-defined services.""" +import grpc + diff --git a/bumble/transport/grpc_protobuf/packet_streamer_pb2.py b/bumble/transport/grpc_protobuf/packet_streamer_pb2.py new file mode 100644 index 0000000..ea07940 --- /dev/null +++ b/bumble/transport/grpc_protobuf/packet_streamer_pb2.py @@ -0,0 +1,31 @@ +# -*- coding: utf-8 -*- +# Generated by the protocol buffer compiler. DO NOT EDIT! +# source: packet_streamer.proto +"""Generated protocol buffer code.""" +from google.protobuf.internal import builder as _builder +from google.protobuf import descriptor as _descriptor +from google.protobuf import descriptor_pool as _descriptor_pool +from google.protobuf import symbol_database as _symbol_database +# @@protoc_insertion_point(imports) + +_sym_db = _symbol_database.Default() + + +from . import hci_packet_pb2 as hci__packet__pb2 +from . import startup_pb2 as startup__pb2 + + +DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile(b'\n\x15packet_streamer.proto\x12\rnetsim.packet\x1a\x10hci_packet.proto\x1a\rstartup.proto\"\x93\x01\n\rPacketRequest\x12\x30\n\x0cinitial_info\x18\x01 \x01(\x0b\x32\x18.netsim.startup.ChipInfoH\x00\x12.\n\nhci_packet\x18\x02 \x01(\x0b\x32\x18.netsim.packet.HCIPacketH\x00\x12\x10\n\x06packet\x18\x03 \x01(\x0cH\x00\x42\x0e\n\x0crequest_type\"t\n\x0ePacketResponse\x12\x0f\n\x05\x65rror\x18\x01 \x01(\tH\x00\x12.\n\nhci_packet\x18\x02 \x01(\x0b\x32\x18.netsim.packet.HCIPacketH\x00\x12\x10\n\x06packet\x18\x03 \x01(\x0cH\x00\x42\x0f\n\rresponse_type2b\n\x0ePacketStreamer\x12P\n\rStreamPackets\x12\x1c.netsim.packet.PacketRequest\x1a\x1d.netsim.packet.PacketResponse(\x01\x30\x01\x62\x06proto3') + +_builder.BuildMessageAndEnumDescriptors(DESCRIPTOR, globals()) +_builder.BuildTopDescriptorsAndMessages(DESCRIPTOR, 'packet_streamer_pb2', globals()) +if _descriptor._USE_C_DESCRIPTORS == False: + + DESCRIPTOR._options = None + _PACKETREQUEST._serialized_start=74 + _PACKETREQUEST._serialized_end=221 + _PACKETRESPONSE._serialized_start=223 + _PACKETRESPONSE._serialized_end=339 + _PACKETSTREAMER._serialized_start=341 + _PACKETSTREAMER._serialized_end=439 +# @@protoc_insertion_point(module_scope) diff --git a/bumble/transport/grpc_protobuf/packet_streamer_pb2.pyi b/bumble/transport/grpc_protobuf/packet_streamer_pb2.pyi new file mode 100644 index 0000000..d867613 --- /dev/null +++ b/bumble/transport/grpc_protobuf/packet_streamer_pb2.pyi @@ -0,0 +1,27 @@ +from . import hci_packet_pb2 as _hci_packet_pb2 +from . import startup_pb2 as _startup_pb2 +from google.protobuf import descriptor as _descriptor +from google.protobuf import message as _message +from typing import ClassVar as _ClassVar, Mapping as _Mapping, Optional as _Optional, Union as _Union + +DESCRIPTOR: _descriptor.FileDescriptor + +class PacketRequest(_message.Message): + __slots__ = ["hci_packet", "initial_info", "packet"] + HCI_PACKET_FIELD_NUMBER: _ClassVar[int] + INITIAL_INFO_FIELD_NUMBER: _ClassVar[int] + PACKET_FIELD_NUMBER: _ClassVar[int] + hci_packet: _hci_packet_pb2.HCIPacket + initial_info: _startup_pb2.ChipInfo + packet: bytes + def __init__(self, initial_info: _Optional[_Union[_startup_pb2.ChipInfo, _Mapping]] = ..., hci_packet: _Optional[_Union[_hci_packet_pb2.HCIPacket, _Mapping]] = ..., packet: _Optional[bytes] = ...) -> None: ... + +class PacketResponse(_message.Message): + __slots__ = ["error", "hci_packet", "packet"] + ERROR_FIELD_NUMBER: _ClassVar[int] + HCI_PACKET_FIELD_NUMBER: _ClassVar[int] + PACKET_FIELD_NUMBER: _ClassVar[int] + error: str + hci_packet: _hci_packet_pb2.HCIPacket + packet: bytes + def __init__(self, error: _Optional[str] = ..., hci_packet: _Optional[_Union[_hci_packet_pb2.HCIPacket, _Mapping]] = ..., packet: _Optional[bytes] = ...) -> None: ... diff --git a/bumble/transport/grpc_protobuf/packet_streamer_pb2_grpc.py b/bumble/transport/grpc_protobuf/packet_streamer_pb2_grpc.py new file mode 100644 index 0000000..45ab653 --- /dev/null +++ b/bumble/transport/grpc_protobuf/packet_streamer_pb2_grpc.py @@ -0,0 +1,109 @@ +# Generated by the gRPC Python protocol compiler plugin. DO NOT EDIT! +"""Client and server classes corresponding to protobuf-defined services.""" +import grpc + +from . import packet_streamer_pb2 as packet__streamer__pb2 + + +class PacketStreamerStub(object): + """* + This is the packet service for the network simulator. + + Android Virtual Devices (AVDs) and accessory devices use this service to + connect to the network simulator and pass packets back and forth. + + AVDs running in a guest VM are built with virtual controllers for each radio + chip. These controllers route chip requests to host emulators (qemu and + crosvm) using virtio and from there they are forwarded to this gRpc service. + + This setup provides a transparent radio environment across AVDs and + accessories because the network simulator contains libraries to emulate + Bluetooth, 80211MAC, UWB, and Rtt chips. + + """ + + def __init__(self, channel): + """Constructor. + + Args: + channel: A grpc.Channel. + """ + self.StreamPackets = channel.stream_stream( + '/netsim.packet.PacketStreamer/StreamPackets', + request_serializer=packet__streamer__pb2.PacketRequest.SerializeToString, + response_deserializer=packet__streamer__pb2.PacketResponse.FromString, + ) + + +class PacketStreamerServicer(object): + """* + This is the packet service for the network simulator. + + Android Virtual Devices (AVDs) and accessory devices use this service to + connect to the network simulator and pass packets back and forth. + + AVDs running in a guest VM are built with virtual controllers for each radio + chip. These controllers route chip requests to host emulators (qemu and + crosvm) using virtio and from there they are forwarded to this gRpc service. + + This setup provides a transparent radio environment across AVDs and + accessories because the network simulator contains libraries to emulate + Bluetooth, 80211MAC, UWB, and Rtt chips. + + """ + + def StreamPackets(self, request_iterator, context): + """Attach a virtual radio controller to the network simulation. + """ + context.set_code(grpc.StatusCode.UNIMPLEMENTED) + context.set_details('Method not implemented!') + raise NotImplementedError('Method not implemented!') + + +def add_PacketStreamerServicer_to_server(servicer, server): + rpc_method_handlers = { + 'StreamPackets': grpc.stream_stream_rpc_method_handler( + servicer.StreamPackets, + request_deserializer=packet__streamer__pb2.PacketRequest.FromString, + response_serializer=packet__streamer__pb2.PacketResponse.SerializeToString, + ), + } + generic_handler = grpc.method_handlers_generic_handler( + 'netsim.packet.PacketStreamer', rpc_method_handlers) + server.add_generic_rpc_handlers((generic_handler,)) + + + # This class is part of an EXPERIMENTAL API. +class PacketStreamer(object): + """* + This is the packet service for the network simulator. + + Android Virtual Devices (AVDs) and accessory devices use this service to + connect to the network simulator and pass packets back and forth. + + AVDs running in a guest VM are built with virtual controllers for each radio + chip. These controllers route chip requests to host emulators (qemu and + crosvm) using virtio and from there they are forwarded to this gRpc service. + + This setup provides a transparent radio environment across AVDs and + accessories because the network simulator contains libraries to emulate + Bluetooth, 80211MAC, UWB, and Rtt chips. + + """ + + @staticmethod + def StreamPackets(request_iterator, + target, + options=(), + channel_credentials=None, + call_credentials=None, + insecure=False, + compression=None, + wait_for_ready=None, + timeout=None, + metadata=None): + return grpc.experimental.stream_stream(request_iterator, target, '/netsim.packet.PacketStreamer/StreamPackets', + packet__streamer__pb2.PacketRequest.SerializeToString, + packet__streamer__pb2.PacketResponse.FromString, + options, channel_credentials, + insecure, call_credentials, compression, wait_for_ready, timeout, metadata) diff --git a/bumble/transport/grpc_protobuf/startup_pb2.py b/bumble/transport/grpc_protobuf/startup_pb2.py new file mode 100644 index 0000000..532ac0e --- /dev/null +++ b/bumble/transport/grpc_protobuf/startup_pb2.py @@ -0,0 +1,32 @@ +# -*- coding: utf-8 -*- +# Generated by the protocol buffer compiler. DO NOT EDIT! +# source: startup.proto +"""Generated protocol buffer code.""" +from google.protobuf.internal import builder as _builder +from google.protobuf import descriptor as _descriptor +from google.protobuf import descriptor_pool as _descriptor_pool +from google.protobuf import symbol_database as _symbol_database +# @@protoc_insertion_point(imports) + +_sym_db = _symbol_database.Default() + + +from . import common_pb2 as common__pb2 + + +DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile(b'\n\rstartup.proto\x12\x0enetsim.startup\x1a\x0c\x63ommon.proto\"\x7f\n\x0bStartupInfo\x12\x33\n\x07\x64\x65vices\x18\x01 \x03(\x0b\x32\".netsim.startup.StartupInfo.Device\x1a;\n\x06\x44\x65vice\x12\x0c\n\x04name\x18\x01 \x01(\t\x12#\n\x05\x63hips\x18\x02 \x03(\x0b\x32\x14.netsim.startup.Chip\"<\n\x08\x43hipInfo\x12\x0c\n\x04name\x18\x01 \x01(\t\x12\"\n\x04\x63hip\x18\x02 \x01(\x0b\x32\x14.netsim.startup.Chip\"\x96\x01\n\x04\x43hip\x12%\n\x04kind\x18\x01 \x01(\x0e\x32\x17.netsim.common.ChipKind\x12\n\n\x02id\x18\x02 \x01(\t\x12\x14\n\x0cmanufacturer\x18\x03 \x01(\t\x12\x14\n\x0cproduct_name\x18\x04 \x01(\t\x12\r\n\x05\x66\x64_in\x18\x05 \x01(\x05\x12\x0e\n\x06\x66\x64_out\x18\x06 \x01(\x05\x12\x10\n\x08loopback\x18\x07 \x01(\x08\x62\x06proto3') + +_builder.BuildMessageAndEnumDescriptors(DESCRIPTOR, globals()) +_builder.BuildTopDescriptorsAndMessages(DESCRIPTOR, 'startup_pb2', globals()) +if _descriptor._USE_C_DESCRIPTORS == False: + + DESCRIPTOR._options = None + _STARTUPINFO._serialized_start=47 + _STARTUPINFO._serialized_end=174 + _STARTUPINFO_DEVICE._serialized_start=115 + _STARTUPINFO_DEVICE._serialized_end=174 + _CHIPINFO._serialized_start=176 + _CHIPINFO._serialized_end=236 + _CHIP._serialized_start=239 + _CHIP._serialized_end=389 +# @@protoc_insertion_point(module_scope) diff --git a/bumble/transport/grpc_protobuf/startup_pb2.pyi b/bumble/transport/grpc_protobuf/startup_pb2.pyi new file mode 100644 index 0000000..604d915 --- /dev/null +++ b/bumble/transport/grpc_protobuf/startup_pb2.pyi @@ -0,0 +1,46 @@ +from . import common_pb2 as _common_pb2 +from google.protobuf.internal import containers as _containers +from google.protobuf import descriptor as _descriptor +from google.protobuf import message as _message +from typing import ClassVar as _ClassVar, Iterable as _Iterable, Mapping as _Mapping, Optional as _Optional, Union as _Union + +DESCRIPTOR: _descriptor.FileDescriptor + +class Chip(_message.Message): + __slots__ = ["fd_in", "fd_out", "id", "kind", "loopback", "manufacturer", "product_name"] + FD_IN_FIELD_NUMBER: _ClassVar[int] + FD_OUT_FIELD_NUMBER: _ClassVar[int] + ID_FIELD_NUMBER: _ClassVar[int] + KIND_FIELD_NUMBER: _ClassVar[int] + LOOPBACK_FIELD_NUMBER: _ClassVar[int] + MANUFACTURER_FIELD_NUMBER: _ClassVar[int] + PRODUCT_NAME_FIELD_NUMBER: _ClassVar[int] + fd_in: int + fd_out: int + id: str + kind: _common_pb2.ChipKind + loopback: bool + manufacturer: str + product_name: str + def __init__(self, kind: _Optional[_Union[_common_pb2.ChipKind, str]] = ..., id: _Optional[str] = ..., manufacturer: _Optional[str] = ..., product_name: _Optional[str] = ..., fd_in: _Optional[int] = ..., fd_out: _Optional[int] = ..., loopback: bool = ...) -> None: ... + +class ChipInfo(_message.Message): + __slots__ = ["chip", "name"] + CHIP_FIELD_NUMBER: _ClassVar[int] + NAME_FIELD_NUMBER: _ClassVar[int] + chip: Chip + name: str + def __init__(self, name: _Optional[str] = ..., chip: _Optional[_Union[Chip, _Mapping]] = ...) -> None: ... + +class StartupInfo(_message.Message): + __slots__ = ["devices"] + class Device(_message.Message): + __slots__ = ["chips", "name"] + CHIPS_FIELD_NUMBER: _ClassVar[int] + NAME_FIELD_NUMBER: _ClassVar[int] + chips: _containers.RepeatedCompositeFieldContainer[Chip] + name: str + def __init__(self, name: _Optional[str] = ..., chips: _Optional[_Iterable[_Union[Chip, _Mapping]]] = ...) -> None: ... + DEVICES_FIELD_NUMBER: _ClassVar[int] + devices: _containers.RepeatedCompositeFieldContainer[StartupInfo.Device] + def __init__(self, devices: _Optional[_Iterable[_Union[StartupInfo.Device, _Mapping]]] = ...) -> None: ... diff --git a/bumble/transport/grpc_protobuf/startup_pb2_grpc.py b/bumble/transport/grpc_protobuf/startup_pb2_grpc.py new file mode 100644 index 0000000..2daafff --- /dev/null +++ b/bumble/transport/grpc_protobuf/startup_pb2_grpc.py @@ -0,0 +1,4 @@ +# Generated by the gRPC Python protocol compiler plugin. DO NOT EDIT! +"""Client and server classes corresponding to protobuf-defined services.""" +import grpc + diff --git a/docs/mkdocs/requirements.txt b/docs/mkdocs/requirements.txt index 8fc37dd..a241683 100644 --- a/docs/mkdocs/requirements.txt +++ b/docs/mkdocs/requirements.txt @@ -2,5 +2,5 @@ mkdocs == 1.4.0 mkdocs-material == 8.5.6 mkdocs-material-extensions == 1.0.3 -pymdown-extensions == 9.6 +pymdown-extensions == 10.0 mkdocstrings-python == 0.7.1 diff --git a/docs/mkdocs/src/platforms/android.md b/docs/mkdocs/src/platforms/android.md index cbb62db..4b08d88 100644 --- a/docs/mkdocs/src/platforms/android.md +++ b/docs/mkdocs/src/platforms/android.md @@ -9,19 +9,20 @@ The two main use cases are: * Connecting the Bumble host stack to the Android emulator's virtual controller. * Using Bumble as an HCI bridge to connect the Android emulator to a physical - Bluetooth controller, such as a USB dongle + Bluetooth controller, such as a USB dongle, or other HCI transport. !!! warning Bluetooth support in the Android emulator is a recent feature that may still be evolving. The information contained here be somewhat out of sync with the version of the emulator you are using. - You will need version 31.3.8.0 or later. + You will need version 33.1.4.0 or later. The Android emulator supports Bluetooth in two ways: either by exposing virtual Bluetooth controllers to which you can connect a virtual Bluetooth host stack, or -by exposing an way to connect your own virtual controller to the Android Bluetooth +by exposing a way to connect your own virtual controller to the Android Bluetooth stack via a virtual HCI interface. -Both ways are controlled via gRPC requests to the Android emulator. +Both ways are controlled via gRPC requests to the Android emulator controller and/or +from the Android emulator. ## Launching the Emulator @@ -33,48 +34,82 @@ the command line. For details on how to launch the Android emulator from the command line, visit [this Android Studio user guide page](https://developer.android.com/studio/run/emulator-commandline) -The `-grpc <port>` command line option may be used to select a gRPC port other than the default. +The `-packet-streamer-endpoint <endpoint>` command line option may be used to enable +Bluetooth emulation and tell the emulator which virtual controller to connect to. -## Connecting to Root Canal +## Connecting to Netsim -The Android emulator's virtual Bluetooth controller is called **Root Canal**. -Multiple instances of Root Canal virtual controllers can be instantiated, they -communicate link layer packets between them, thus creating a virtual radio network. -Configuring a Bumble Device instance to use Root Canal as a virtual controller +If the emulator doesn't have Bluetooth emulation enabled by default, use the +`-packet-streamer-endpoint default` option to tell it to connect to Netsim. +If Netsim is not running, the emulator will start it automatically. + +The Android emulator's virtual Bluetooth controller is called **Netsim**. +Netsim runs as a background process and allows multiple clients to connect to it, +each connecting to its own virtual controller instance hosted by Netsim. All the +clients connected to the same Netsim process can then "talk" to each other over a +virtual radio link layer. +Netsim supports other wireless protocols than Bluetooth, but the relevant part here +is Bluetooth. The virtual Bluetooth controller used by Netsim is sometimes referred to +as **Root Canal**. + +Configuring a Bumble Device instance to use netsim as a virtual controller allows that virtual device to communicate with the Android Bluetooth stack, and through it with Android applications as well as system-managed profiles. -To connect a Bumble host stack to a Root Canal virtual controller instance, use -the bumble `android-emulator` transport in `host` mode (the default). +To connect a Bumble host stack to a netsim virtual controller instance, use +the Bumble `android-netsim` transport in `host` mode (the default). + +!!! example "Run the example GATT server connected to the emulator via Netsim" + ``` shell + $ python run_gatt_server.py device1.json android-netsim + ``` -!!! example "Run the example GATT server connected to the emulator" +By default, the Bumble `android-netsim` transport will try to automatically discover +the port number on which the netsim process is exposing its gRPC server interface. If +that discovery process fails, or if you want to specify the interface manually, you +can pass a `hostname` and `port` as parameters to the transport, as: `android-netsim:<host>:<port>`. + +!!! example "Run the example GATT server connected to the emulator via Netsim on a localhost, port 8877" ``` shell - $ python run_gatt_server.py device1.json android-emulator + $ python run_gatt_server.py device1.json android-netsim:localhost:8877 ``` +### Multiple Instances + +If you want to connect multiple Bumble devices to netsim, it may be useful to give each one +a netsim controller with a specific name. This can be done using the `name=<name>` transport option. +For example: `android-netsim:localhost:8877,name=bumble1` + ## Connecting a Custom Virtual Controller This is an advanced use case, which may not be officially supported, but should work in recent versions of the emulator. -You will likely need to start the emulator from the command line, in order to specify the `-forward-vhci` option (unless the emulator offers a way to control that feature from a user/ui menu). -!!! example "Launch the emulator with VHCI forwarding" - In this example, we launch an emulator AVD named "Tiramisu" +The first step is to run the Bumble HCI bridge, specifying netsim as the "host" end of the +bridge, and another controller (typically a USB Bluetooth dongle, but any other supported +transport can work as well) as the "controller" end of the bridge. + +To connect a virtual controller to the Android Bluetooth stack, use the bumble `android-netsim` transport in `controller` mode. For example, with port number 8877, the transport name would be: `android-netsim:_:8877,mode=controller`. + +!!! example "Connect the Android emulator to the first USB Bluetooth dongle, using the `hci_bridge` application" ```shell - $ emulator -forward-vhci -avd Tiramisu + $ bumble-hci-bridge android-netsim:_:8877,mode=controller usb:0 ``` -!!! tip - Attaching a virtual controller use the VHCI forwarder while the Android Bluetooth stack - is running isn't supported. So you need to disable Bluetooth in your running Android guest - before attaching the virtual controller, then re-enable it once attached. +Then, you can start the emulator and tell it to connect to this bridge, instead of netsim. +You will likely need to start the emulator from the command line, in order to specify the `-packet-streamer-endpoint <hostname>:<port>` option (unless the emulator offers a way to control that feature from a user/ui menu). -To connect a virtual controller to the Android Bluetooth stack, use the bumble `android-emulator` transport in `controller` mode. For example, using the default gRPC port, the transport name would be: `android-emulator:mode=controller`. - -!!! example "Connect the Android emulator to the first USB Bluetooth dongle, using the `hci_bridge` application" +!!! example "Launch the emulator with a netsim replacement" + In this example, we launch an emulator AVD named "Tiramisu", with a Bumble HCI bridge running + on port 8877. ```shell - $ bumble-hci-bridge android-emulator:mode=controller usb:0 + $ emulator -packet-streamer-endpoint localhost:8877 -avd Tiramisu ``` +!!! tip + Attaching a virtual controller while the Android Bluetooth stack is running may not be well supported. So you may need to disable Bluetooth in your running Android guest + before attaching the virtual controller, then re-enable it once attached. + + ## Other Tools The `show` application that's included with Bumble can be used to parse and pretty-print the HCI packets diff --git a/docs/mkdocs/src/transports/android_emulator.md b/docs/mkdocs/src/transports/android_emulator.md index ead71b8..974ba4f 100644 --- a/docs/mkdocs/src/transports/android_emulator.md +++ b/docs/mkdocs/src/transports/android_emulator.md @@ -1,22 +1,41 @@ ANDROID EMULATOR TRANSPORT ========================== -The Android emulator transport either connects, as a host, to a "Root Canal" virtual controller -("host" mode), or attaches a virtual controller to the Android Bluetooth host stack ("controller" mode). +!!! warning + Bluetooth support in the Android emulator has recently changed. The older mode, using + the `android-emulator` transport name with Bumble, while still implemented, is now + obsolete, and may not be supported by recent versions of the emulator. + Use the `android-netsim` transport name instead. + + +The Android "netsim" transport either connects, as a host, to a **Netsim** virtual controller +("host" mode), or acts as a virtual controller itself ("controller" mode) accepting host +connections. ## Moniker -The moniker syntax for an Android Emulator transport is: `android-emulator:[mode=<host|controller>][<hostname>:<port>]`, where -the `mode` parameter can specify running as a host or a controller, and `<hostname>:<port>` can specify a host name (or IP address) and TCP port number on which to reach the gRPC server for the emulator. -Both the `mode=<host|controller>` and `<hostname>:<port>` parameters are optional (so the moniker `android-emulator` by itself is a valid moniker, which will create a transport in `host` mode, connected to `localhost` on the default gRPC port for the emulator). +The moniker syntax for an Android Emulator "netsim" transport is: `android-netsim:[<host>:<port>][<options>]`, +where `<options>` is a ','-separated list of `<name>=<value>` pairs`. +The `mode` parameter name can specify running as a host or a controller, and `<hostname>:<port>` can specify a host name (or IP address) and TCP port number on which to reach the gRPC server for the emulator (in "host" mode), or to accept gRPC connections (in "controller" mode). +Both the `mode=<host|controller>` and `<hostname>:<port>` parameters are optional (so the moniker `android-netsim` by itself is a valid moniker, which will create a transport in `host` mode, connected to `localhost` on the default gRPC port for the Netsim background process). + +!!! example Example + `android-netsim` + connect as a host to Netsim on the gRPC port discovered automatically. !!! example Example - `android-emulator` - connect as a host to the emulator on localhost:8554 + `android-netsim:_:8555,mode=controller` + Run as a controller, accepting gRPC connection on port 8555. !!! example Example - `android-emulator:mode=controller` - connect as a controller to the emulator on localhost:8554 + `android-netsim:localhost:8555` + connect as a host to Netsim on localhost:8555 !!! example Example - `android-emulator:localhost:8555` - connect as a host to the emulator on localhost:8555 + `android-netsim:localhost:8555` + connect as a host to Netsim on localhost:8555 + +!!! example Example + `android-netsim:name=bumble1234` + connect as a host to Netsim on the discovered gRPC port, using `bumble1234` as the + controller instance name. + diff --git a/docs/mkdocs/src/transports/index.md b/docs/mkdocs/src/transports/index.md index 70827c1..feb57e5 100644 --- a/docs/mkdocs/src/transports/index.md +++ b/docs/mkdocs/src/transports/index.md @@ -16,5 +16,6 @@ Several types of transports are supported: * [PTY](pty.md): a PTY (pseudo terminal) is used to send/receive HCI packets. This is convenient to expose a virtual controller as if it were an HCI UART * [VHCI](vhci.md): used to attach a virtual controller to a Bluetooth stack on platforms that support it. * [HCI Socket](hci_socket.md): an HCI socket, on platforms that support it, to send/receive HCI packets to/from an HCI controller managed by the OS. - * [Android Emulator](android_emulator.md): a gRPC connection to an Android emulator is used to setup either an HCI interface to the emulator's "Root Canal" virtual controller, or attach a virtual controller to the Android Bluetooth host stack. + * [Android Emulator](android_emulator.md): a gRPC connection to the Android emulator's "netsim" + virtual controller, or from the Android emulator, is used to setup either an HCI interface to the emulator's "netsim" virtual controller, or serve as a virtual controller for the Android Bluetooth host stack. * [File](file.md): HCI packets are read/written to a file-like node in the filesystem. diff --git a/examples/a2dp_sink1.json b/examples/a2dp_sink1.json index 61ce80d..8603194 100644 --- a/examples/a2dp_sink1.json +++ b/examples/a2dp_sink1.json @@ -1,5 +1,6 @@ { "name": "Bumble Speaker", + "address": "F0:F1:F2:F3:F4:F5", "class_of_device": 2360324, "keystore": "JsonKeyStore" } diff --git a/examples/hfp_handsfree.json b/examples/hfp_handsfree.json index 5d46a80..50d7841 100644 --- a/examples/hfp_handsfree.json +++ b/examples/hfp_handsfree.json @@ -1,4 +1,6 @@ { "name": "Bumble Hands-Free", - "class_of_device": 2360324 + "class_of_device": 2360324, + "keystore": "JsonKeyStore", + "le_enabled": false } diff --git a/examples/keyboard.py b/examples/keyboard.py index 16dbeb6..314a805 100644 --- a/examples/keyboard.py +++ b/examples/keyboard.py @@ -209,7 +209,7 @@ async def keyboard_host(device, peer_address): return for i, characteristic in enumerate(report_characteristics): print(color('REPORT:', 'yellow'), characteristic) - if characteristic.properties & Characteristic.NOTIFY: + if characteristic.properties & Characteristic.Properties.NOTIFY: await peer.discover_descriptors(characteristic) report_reference_descriptor = characteristic.get_descriptor( GATT_REPORT_REFERENCE_DESCRIPTOR @@ -241,7 +241,9 @@ async def keyboard_device(device, command): # Create an 'input report' characteristic to send keyboard reports to the host input_report_characteristic = Characteristic( GATT_REPORT_CHARACTERISTIC, - Characteristic.READ | Characteristic.WRITE | Characteristic.NOTIFY, + Characteristic.Properties.READ + | Characteristic.Properties.WRITE + | Characteristic.Properties.NOTIFY, Characteristic.READABLE | Characteristic.WRITEABLE, bytes([0, 0, 0, 0, 0, 0, 0, 0]), [ @@ -256,8 +258,8 @@ async def keyboard_device(device, command): # Create an 'output report' characteristic to receive keyboard reports from the host output_report_characteristic = Characteristic( GATT_REPORT_CHARACTERISTIC, - Characteristic.READ - | Characteristic.WRITE + Characteristic.Properties.READ + | Characteristic.Properties.WRITE | Characteristic.WRITE_WITHOUT_RESPONSE, Characteristic.READABLE | Characteristic.WRITEABLE, bytes([0]), @@ -278,7 +280,7 @@ async def keyboard_device(device, command): [ Characteristic( GATT_MANUFACTURER_NAME_STRING_CHARACTERISTIC, - Characteristic.READ, + Characteristic.Properties.READ, Characteristic.READABLE, 'Bumble', ) @@ -289,13 +291,13 @@ async def keyboard_device(device, command): [ Characteristic( GATT_PROTOCOL_MODE_CHARACTERISTIC, - Characteristic.READ, + Characteristic.Properties.READ, Characteristic.READABLE, bytes([HID_REPORT_PROTOCOL]), ), Characteristic( GATT_HID_INFORMATION_CHARACTERISTIC, - Characteristic.READ, + Characteristic.Properties.READ, Characteristic.READABLE, # bcdHID=1.1, bCountryCode=0x00, # Flags=RemoteWake|NormallyConnectable @@ -309,7 +311,7 @@ async def keyboard_device(device, command): ), Characteristic( GATT_REPORT_MAP_CHARACTERISTIC, - Characteristic.READ, + Characteristic.Properties.READ, Characteristic.READABLE, HID_KEYBOARD_REPORT_MAP, ), @@ -322,7 +324,7 @@ async def keyboard_device(device, command): [ Characteristic( GATT_BATTERY_LEVEL_CHARACTERISTIC, - Characteristic.READ, + Characteristic.Properties.READ, Characteristic.READABLE, bytes([100]), ) diff --git a/examples/run_asha_sink.py b/examples/run_asha_sink.py index 3b8083c..3e4955d 100644 --- a/examples/run_asha_sink.py +++ b/examples/run_asha_sink.py @@ -101,7 +101,7 @@ async def main(): # Add the ASHA service to the GATT server read_only_properties_characteristic = Characteristic( ASHA_READ_ONLY_PROPERTIES_CHARACTERISTIC, - Characteristic.READ, + Characteristic.Properties.READ, Characteristic.READABLE, bytes( [ @@ -127,13 +127,13 @@ async def main(): ) audio_control_point_characteristic = Characteristic( ASHA_AUDIO_CONTROL_POINT_CHARACTERISTIC, - Characteristic.WRITE | Characteristic.WRITE_WITHOUT_RESPONSE, + Characteristic.Properties.WRITE | Characteristic.WRITE_WITHOUT_RESPONSE, Characteristic.WRITEABLE, CharacteristicValue(write=on_audio_control_point_write), ) audio_status_characteristic = Characteristic( ASHA_AUDIO_STATUS_CHARACTERISTIC, - Characteristic.READ | Characteristic.NOTIFY, + Characteristic.Properties.READ | Characteristic.Properties.NOTIFY, Characteristic.READABLE, bytes([0]), ) @@ -145,7 +145,7 @@ async def main(): ) le_psm_out_characteristic = Characteristic( ASHA_LE_PSM_OUT_CHARACTERISTIC, - Characteristic.READ, + Characteristic.Properties.READ, Characteristic.READABLE, struct.pack('<H', psm), ) diff --git a/examples/run_controller.py b/examples/run_controller.py index 885b96d..596ac8b 100644 --- a/examples/run_controller.py +++ b/examples/run_controller.py @@ -80,7 +80,7 @@ async def main(): ) manufacturer_name_characteristic = Characteristic( GATT_MANUFACTURER_NAME_STRING_CHARACTERISTIC, - Characteristic.READ, + Characteristic.Properties.READ, Characteristic.READABLE, "Fitbit", [descriptor], diff --git a/examples/run_gatt_client_and_server.py b/examples/run_gatt_client_and_server.py index f3df733..609fe18 100644 --- a/examples/run_gatt_client_and_server.py +++ b/examples/run_gatt_client_and_server.py @@ -70,7 +70,7 @@ async def main(): ) manufacturer_name_characteristic = Characteristic( GATT_MANUFACTURER_NAME_STRING_CHARACTERISTIC, - Characteristic.READ, + Characteristic.Properties.READ, Characteristic.READABLE, "Fitbit", [descriptor], diff --git a/examples/run_gatt_server.py b/examples/run_gatt_server.py index 041b440..46d42a2 100644 --- a/examples/run_gatt_server.py +++ b/examples/run_gatt_server.py @@ -96,7 +96,7 @@ async def main(): ) manufacturer_name_characteristic = Characteristic( GATT_MANUFACTURER_NAME_STRING_CHARACTERISTIC, - Characteristic.READ, + Characteristic.Properties.READ, Characteristic.READABLE, 'Fitbit', [descriptor], @@ -109,13 +109,13 @@ async def main(): [ Characteristic( 'D901B45B-4916-412E-ACCA-376ECB603B2C', - Characteristic.READ | Characteristic.WRITE, + Characteristic.Properties.READ | Characteristic.Properties.WRITE, Characteristic.READABLE | Characteristic.WRITEABLE, CharacteristicValue(read=my_custom_read, write=my_custom_write), ), Characteristic( '552957FB-CF1F-4A31-9535-E78847E1A714', - Characteristic.READ | Characteristic.WRITE, + Characteristic.Properties.READ | Characteristic.Properties.WRITE, Characteristic.READABLE | Characteristic.WRITEABLE, CharacteristicValue( read=my_custom_read_with_error, write=my_custom_write_with_error @@ -123,7 +123,7 @@ async def main(): ), Characteristic( '486F64C6-4B5F-4B3B-8AFF-EDE134A8446A', - Characteristic.READ | Characteristic.NOTIFY, + Characteristic.Properties.READ | Characteristic.Properties.NOTIFY, Characteristic.READABLE, 'hello', ), diff --git a/examples/run_notifier.py b/examples/run_notifier.py index 673286b..5f6def3 100644 --- a/examples/run_notifier.py +++ b/examples/run_notifier.py @@ -74,19 +74,21 @@ async def main(): # Add a few entries to the device's GATT server characteristic1 = Characteristic( '486F64C6-4B5F-4B3B-8AFF-EDE134A8446A', - Characteristic.READ | Characteristic.NOTIFY, + Characteristic.Properties.READ | Characteristic.Properties.NOTIFY, Characteristic.READABLE, bytes([0x40]), ) characteristic2 = Characteristic( '8EBDEBAE-0017-418E-8D3B-3A3809492165', - Characteristic.READ | Characteristic.INDICATE, + Characteristic.Properties.READ | Characteristic.Properties.INDICATE, Characteristic.READABLE, bytes([0x41]), ) characteristic3 = Characteristic( '8EBDEBAE-0017-418E-8D3B-3A3809492165', - Characteristic.READ | Characteristic.NOTIFY | Characteristic.INDICATE, + Characteristic.Properties.READ + | Characteristic.Properties.NOTIFY + | Characteristic.Properties.INDICATE, Characteristic.READABLE, bytes([0x42]), ) diff --git a/pyproject.toml b/pyproject.toml index ff9588c..8662723 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -13,6 +13,9 @@ testpaths = [ [tool.pylint.master] init-hook = 'import sys; sys.path.append(".")' +ignore-paths = [ + '.*_pb2(_grpc)?.py' +] [tool.pylint.messages_control] max-line-length = "88" @@ -37,26 +40,25 @@ disable = [ "too-many-statements", ] -ignore = [ - "emulated_bluetooth_pb2.py", - "emulated_bluetooth_pb2_grpc.py", - "emulated_bluetooth_vhci_pb2_grpc.py", - "emulated_bluetooth_packets_pb2.py", - "emulated_bluetooth_vhci_pb2.py" -] +[tool.pylint.main] +ignore="pandora" # FIXME: pylint does not support stubs yet: [tool.pylint.typecheck] signature-mutators="AsyncRunner.run_in_task" [tool.black] skip-string-normalization = true +extend-exclude = ''' +( + .*_pb2(_grpc)?.py # exclude autogenerated Protocol Buffer files anywhere in the project +) +''' -[[tool.mypy.overrides]] -module = "bumble.transport.emulated_bluetooth_pb2_grpc" -ignore_missing_imports = true +[tool.mypy] +exclude = ['bumble/transport/grpc_protobuf'] [[tool.mypy.overrides]] -module = "bumble.transport.emulated_bluetooth_packets_pb2" +module = "bumble.transport.grpc_protobuf.*" ignore_errors = true [[tool.mypy.overrides]] @@ -64,18 +66,10 @@ module = "aioconsole.*" ignore_missing_imports = true [[tool.mypy.overrides]] -module = "colors.*" -ignore_missing_imports = true - -[[tool.mypy.overrides]] module = "construct.*" ignore_missing_imports = true [[tool.mypy.overrides]] -module = "emulated_bluetooth_packets_pb2.*" -ignore_missing_imports = true - -[[tool.mypy.overrides]] module = "grpc.*" ignore_missing_imports = true diff --git a/scripts/process_android_emulator_protos.sh b/scripts/process_android_emulator_protos.sh index e60e4cf..d5070df 100644 --- a/scripts/process_android_emulator_protos.sh +++ b/scripts/process_android_emulator_protos.sh @@ -1,27 +1,16 @@ -# Invoke this script with an argument pointing to where the Android emulator .proto files are. -# The .proto files should be slightly modified from their original version (as distributed with -# the Android emulator): -# --> Remove unused types/methods from emulated_bluetooth.proto +# Invoke this script with an argument pointing to where the Android emulator .proto files are +# (for example, ~/Library/Android/sdk/emulator/lib on a mac, or +# $AOSP/external/qemu/android/android-grpc/python/aemu-grpc/src/aemu/proto from the AOSP sources) +PROTOC_OUT=bumble/transport/grpc_protobuf -PROTOC_OUT=bumble/transport -LICENSE_FILE_INPUT=bumble/transport/android_emulator.py - -proto_files=(emulated_bluetooth.proto emulated_bluetooth_vhci.proto emulated_bluetooth_packets.proto) +proto_files=(emulated_bluetooth.proto emulated_bluetooth_vhci.proto emulated_bluetooth_packets.proto emulated_bluetooth_device.proto grpc_endpoint_description.proto) for proto_file in "${proto_files[@]}" do python -m grpc_tools.protoc -I$1 --proto_path=bumble/transport --python_out=$PROTOC_OUT --pyi_out=$PROTOC_OUT --grpc_python_out=$PROTOC_OUT $1/$proto_file done -python_files=(emulated_bluetooth_pb2.py emulated_bluetooth_pb2_grpc.py emulated_bluetooth_packets_pb2.py emulated_bluetooth_packets_pb2_grpc.py emulated_bluetooth_vhci_pb2_grpc.py emulated_bluetooth_vhci_pb2.py) +python_files=(emulated_bluetooth_pb2_grpc.py emulated_bluetooth_pb2.py emulated_bluetooth_packets_pb2.py emulated_bluetooth_vhci_pb2_grpc.py emulated_bluetooth_vhci_pb2.py emulated_bluetooth_device_pb2.py grpc_endpoint_description_pb2.py) for python_file in "${python_files[@]}" do - sed -i '' 's/^import .*_pb2 as/from . &/' $PROTOC_OUT/$python_file -done - -stub_files=(emulated_bluetooth_pb2.pyi emulated_bluetooth_packets_pb2.pyi emulated_bluetooth_vhci_pb2.pyi) -for source_file in "${python_files[@]}" "${stub_files[@]}" -do - head -14 $LICENSE_FILE_INPUT > $PROTOC_OUT/${source_file}.lic - cat $PROTOC_OUT/$source_file >> $PROTOC_OUT/${source_file}.lic - mv $PROTOC_OUT/${source_file}.lic $PROTOC_OUT/$source_file + sed -i 's/^import .*_pb2 as/from . \0/' $PROTOC_OUT/$python_file done
\ No newline at end of file diff --git a/scripts/process_android_netsim_protos.sh b/scripts/process_android_netsim_protos.sh new file mode 100644 index 0000000..5a26602 --- /dev/null +++ b/scripts/process_android_netsim_protos.sh @@ -0,0 +1,14 @@ +# Invoke this script with an argument pointing to where the AOSP `tools/netsim/src/proto` is +PROTOC_OUT=bumble/transport/grpc_protobuf + +proto_files=(common.proto packet_streamer.proto hci_packet.proto startup.proto) +for proto_file in "${proto_files[@]}" +do + python -m grpc_tools.protoc -I$1 --proto_path=bumble/transport --python_out=$PROTOC_OUT --pyi_out=$PROTOC_OUT --grpc_python_out=$PROTOC_OUT $1/$proto_file +done + +python_files=(packet_streamer_pb2_grpc.py packet_streamer_pb2.py hci_packet_pb2.py startup_pb2.py) +for python_file in "${python_files[@]}" +do + sed -i 's/^import .*_pb2 as/from . \0/' $PROTOC_OUT/$python_file +done @@ -24,7 +24,7 @@ url = https://github.com/google/bumble [options] python_requires = >=3.8 -packages = bumble, bumble.transport, bumble.profiles, bumble.apps, bumble.apps.link_relay +packages = bumble, bumble.transport, bumble.profiles, bumble.apps, bumble.apps.link_relay, bumble.pandora package_dir = bumble = bumble bumble.apps = apps @@ -33,7 +33,7 @@ install_requires = appdirs >= 1.4 click >= 7.1.2; platform_system!='Emscripten' cryptography == 35; platform_system!='Emscripten' - grpcio >= 1.46; platform_system!='Emscripten' + grpcio == 1.51.1; platform_system!='Emscripten' libusb1 >= 2.0.1; platform_system!='Emscripten' libusb-package == 1.0.26.1; platform_system!='Emscripten' prompt_toolkit >= 3.0.16; platform_system!='Emscripten' @@ -43,6 +43,9 @@ install_requires = pyserial >= 3.5; platform_system!='Emscripten' pyusb >= 1.2; platform_system!='Emscripten' websockets >= 8.1; platform_system!='Emscripten' + prettytable >= 3.6.0 + humanize >= 4.6.0 + bt-test-interfaces >= 0.0.2 [options.entry_points] console_scripts = @@ -58,6 +61,7 @@ console_scripts = bumble-usb-probe = bumble.apps.usb_probe:main bumble-link-relay = bumble.apps.link_relay.link_relay:main bumble-bench = bumble.apps.bench:main + bumble-pandora-server = bumble.apps.pandora_server:main [options.package_data] * = py.typed, *.pyi @@ -72,8 +76,9 @@ test = coverage >= 6.4 development = black == 22.10 + grpcio-tools >= 1.51.1 invoke >= 1.7.3 - mypy == 1.1.1 + mypy == 1.2.0 nox >= 2022 pylint == 2.15.8 types-appdirs >= 1.4.3 diff --git a/tests/core_test.py b/tests/core_test.py index 7ee2dfd..6c9d0c3 100644 --- a/tests/core_test.py +++ b/tests/core_test.py @@ -15,7 +15,7 @@ # ----------------------------------------------------------------------------- # Imports # ----------------------------------------------------------------------------- -from bumble.core import AdvertisingData, get_dict_key_by_value +from bumble.core import AdvertisingData, UUID, get_dict_key_by_value # ----------------------------------------------------------------------------- def test_ad_data(): @@ -50,5 +50,23 @@ def test_get_dict_key_by_value(): # ----------------------------------------------------------------------------- +def test_uuid_to_hex_str() -> None: + assert UUID("b5ea").to_hex_str() == "B5EA" + assert UUID("df5ce654").to_hex_str() == "DF5CE654" + assert ( + UUID("df5ce654-e059-11ed-b5ea-0242ac120002").to_hex_str() + == "DF5CE654E05911EDB5EA0242AC120002" + ) + assert UUID("b5ea").to_hex_str('-') == "B5EA" + assert UUID("df5ce654").to_hex_str('-') == "DF5CE654" + assert ( + UUID("df5ce654-e059-11ed-b5ea-0242ac120002").to_hex_str('-') + == "DF5CE654-E059-11ED-B5EA-0242AC120002" + ) + + +# ----------------------------------------------------------------------------- if __name__ == '__main__': test_ad_data() + test_get_dict_key_by_value() + test_uuid_to_hex_str() diff --git a/tests/gatt_test.py b/tests/gatt_test.py index 70bbdb8..0652197 100644 --- a/tests/gatt_test.py +++ b/tests/gatt_test.py @@ -23,6 +23,7 @@ import pytest from bumble.controller import Controller from bumble.gatt_client import CharacteristicProxy +from bumble.gatt_server import Server from bumble.link import LocalLink from bumble.device import Device, Peer from bumble.host import Host @@ -114,7 +115,7 @@ async def test_characteristic_encoding(): c = Foo( GATT_BATTERY_LEVEL_CHARACTERISTIC, - Characteristic.READ, + Characteristic.Properties.READ, Characteristic.READABLE, 123, ) @@ -143,7 +144,9 @@ async def test_characteristic_encoding(): characteristic = Characteristic( 'FDB159DB-036C-49E3-B3DB-6325AC750806', - Characteristic.READ | Characteristic.WRITE | Characteristic.NOTIFY, + Characteristic.Properties.READ + | Characteristic.Properties.WRITE + | Characteristic.Properties.NOTIFY, Characteristic.READABLE | Characteristic.WRITEABLE, bytes([123]), ) @@ -239,7 +242,9 @@ async def test_attribute_getters(): characteristic_uuid = UUID('FDB159DB-036C-49E3-B3DB-6325AC750806') characteristic = Characteristic( characteristic_uuid, - Characteristic.READ | Characteristic.WRITE | Characteristic.NOTIFY, + Characteristic.Properties.READ + | Characteristic.Properties.WRITE + | Characteristic.Properties.NOTIFY, Characteristic.READABLE | Characteristic.WRITEABLE, bytes([123]), ) @@ -284,7 +289,7 @@ def test_CharacteristicAdapter(): v = bytes([1, 2, 3]) c = Characteristic( GATT_BATTERY_LEVEL_CHARACTERISTIC, - Characteristic.READ, + Characteristic.Properties.READ, Characteristic.READABLE, v, ) @@ -420,7 +425,7 @@ async def test_read_write(): characteristic1 = Characteristic( 'FDB159DB-036C-49E3-B3DB-6325AC750806', - Characteristic.READ | Characteristic.WRITE, + Characteristic.Properties.READ | Characteristic.Properties.WRITE, Characteristic.READABLE | Characteristic.WRITEABLE, ) @@ -437,7 +442,7 @@ async def test_read_write(): characteristic2 = Characteristic( '66DE9057-C848-4ACA-B993-D675644EBB85', - Characteristic.READ | Characteristic.WRITE, + Characteristic.Properties.READ | Characteristic.Properties.WRITE, Characteristic.READABLE | Characteristic.WRITEABLE, CharacteristicValue( read=on_characteristic2_read, write=on_characteristic2_write @@ -500,7 +505,7 @@ async def test_read_write2(): v = bytes([0x11, 0x22, 0x33, 0x44]) characteristic1 = Characteristic( 'FDB159DB-036C-49E3-B3DB-6325AC750806', - Characteristic.READ | Characteristic.WRITE, + Characteristic.Properties.READ | Characteristic.Properties.WRITE, Characteristic.READABLE | Characteristic.WRITEABLE, value=v, ) @@ -544,7 +549,7 @@ async def test_subscribe_notify(): characteristic1 = Characteristic( 'FDB159DB-036C-49E3-B3DB-6325AC750806', - Characteristic.READ | Characteristic.NOTIFY, + Characteristic.Properties.READ | Characteristic.Properties.NOTIFY, Characteristic.READABLE, bytes([1, 2, 3]), ) @@ -560,7 +565,7 @@ async def test_subscribe_notify(): characteristic2 = Characteristic( '66DE9057-C848-4ACA-B993-D675644EBB85', - Characteristic.READ | Characteristic.INDICATE, + Characteristic.Properties.READ | Characteristic.Properties.INDICATE, Characteristic.READABLE, bytes([4, 5, 6]), ) @@ -576,7 +581,9 @@ async def test_subscribe_notify(): characteristic3 = Characteristic( 'AB5E639C-40C1-4238-B9CB-AF41F8B806E4', - Characteristic.READ | Characteristic.NOTIFY | Characteristic.INDICATE, + Characteristic.Properties.READ + | Characteristic.Properties.NOTIFY + | Characteristic.Properties.INDICATE, Characteristic.READABLE, bytes([7, 8, 9]), ) @@ -796,32 +803,46 @@ async def test_mtu_exchange(): # ----------------------------------------------------------------------------- def test_char_property_to_string(): # single - assert Characteristic.property_name(0x01) == "BROADCAST" - assert Characteristic.property_name(Characteristic.BROADCAST) == "BROADCAST" + assert str(Characteristic.Properties(0x01)) == "Properties.BROADCAST" + assert str(Characteristic.Properties.BROADCAST) == "Properties.BROADCAST" # double - assert Characteristic.properties_as_string(0x03) == "BROADCAST,READ" + assert str(Characteristic.Properties(0x03)) == "Properties.READ|BROADCAST" assert ( - Characteristic.properties_as_string( - Characteristic.BROADCAST | Characteristic.READ - ) - == "BROADCAST,READ" + str(Characteristic.Properties.BROADCAST | Characteristic.Properties.READ) + == "Properties.READ|BROADCAST" ) # ----------------------------------------------------------------------------- -def test_char_property_string_to_type(): +def test_characteristic_property_from_string(): # single - assert Characteristic.string_to_properties("BROADCAST") == Characteristic.BROADCAST + assert ( + Characteristic.Properties.from_string("BROADCAST") + == Characteristic.Properties.BROADCAST + ) # double assert ( - Characteristic.string_to_properties("BROADCAST,READ") - == Characteristic.BROADCAST | Characteristic.READ + Characteristic.Properties.from_string("BROADCAST,READ") + == Characteristic.Properties.BROADCAST | Characteristic.Properties.READ ) assert ( - Characteristic.string_to_properties("READ,BROADCAST") - == Characteristic.BROADCAST | Characteristic.READ + Characteristic.Properties.from_string("READ,BROADCAST") + == Characteristic.Properties.BROADCAST | Characteristic.Properties.READ + ) + + +# ----------------------------------------------------------------------------- +def test_characteristic_property_from_string_assert(): + with pytest.raises(TypeError) as e_info: + Characteristic.Properties.from_string("BROADCAST,HELLO") + + assert ( + str(e_info.value) + == """Characteristic.Properties::from_string() error: +Expected a string containing any of the keys, separated by commas: BROADCAST,READ,WRITE_WITHOUT_RESPONSE,WRITE,NOTIFY,INDICATE,AUTHENTICATED_SIGNED_WRITES,EXTENDED_PROPERTIES +Got: BROADCAST,HELLO""" ) @@ -832,7 +853,9 @@ async def test_server_string(): characteristic = Characteristic( 'FDB159DB-036C-49E3-B3DB-6325AC750806', - Characteristic.READ | Characteristic.WRITE | Characteristic.NOTIFY, + Characteristic.Properties.READ + | Characteristic.Properties.WRITE + | Characteristic.Properties.NOTIFY, Characteristic.READABLE | Characteristic.WRITEABLE, bytes([123]), ) @@ -843,13 +866,13 @@ async def test_server_string(): assert ( str(server.gatt_server) == """Service(handle=0x0001, end=0x0005, uuid=UUID-16:1800 (Generic Access)) -CharacteristicDeclaration(handle=0x0002, value_handle=0x0003, uuid=UUID-16:2A00 (Device Name), properties=READ) -Characteristic(handle=0x0003, end=0x0003, uuid=UUID-16:2A00 (Device Name), properties=READ) -CharacteristicDeclaration(handle=0x0004, value_handle=0x0005, uuid=UUID-16:2A01 (Appearance), properties=READ) -Characteristic(handle=0x0005, end=0x0005, uuid=UUID-16:2A01 (Appearance), properties=READ) +CharacteristicDeclaration(handle=0x0002, value_handle=0x0003, uuid=UUID-16:2A00 (Device Name), Properties.READ) +Characteristic(handle=0x0003, end=0x0003, uuid=UUID-16:2A00 (Device Name), Properties.READ) +CharacteristicDeclaration(handle=0x0004, value_handle=0x0005, uuid=UUID-16:2A01 (Appearance), Properties.READ) +Characteristic(handle=0x0005, end=0x0005, uuid=UUID-16:2A01 (Appearance), Properties.READ) Service(handle=0x0006, end=0x0009, uuid=3A657F47-D34F-46B3-B1EC-698E29B6B829) -CharacteristicDeclaration(handle=0x0007, value_handle=0x0008, uuid=FDB159DB-036C-49E3-B3DB-6325AC750806, properties=READ,WRITE,NOTIFY) -Characteristic(handle=0x0008, end=0x0009, uuid=FDB159DB-036C-49E3-B3DB-6325AC750806, properties=READ,WRITE,NOTIFY) +CharacteristicDeclaration(handle=0x0007, value_handle=0x0008, uuid=FDB159DB-036C-49E3-B3DB-6325AC750806, Properties.NOTIFY|WRITE|READ) +Characteristic(handle=0x0008, end=0x0009, uuid=FDB159DB-036C-49E3-B3DB-6325AC750806, Properties.NOTIFY|WRITE|READ) Descriptor(handle=0x0009, type=UUID-16:2902 (Client Characteristic Configuration), value=0000)""" ) @@ -871,22 +894,132 @@ def test_attribute_string_to_permissions(): # ----------------------------------------------------------------------------- -def test_charracteristic_permissions(): +def test_characteristic_permissions(): characteristic = Characteristic( 'FDB159DB-036C-49E3-B3DB-6325AC750806', - Characteristic.READ | Characteristic.WRITE | Characteristic.NOTIFY, + Characteristic.Properties.READ + | Characteristic.Properties.WRITE + | Characteristic.Properties.NOTIFY, 'READABLE,WRITEABLE', ) assert characteristic.permissions == 3 # ----------------------------------------------------------------------------- +def test_characteristic_has_properties(): + characteristic = Characteristic( + 'FDB159DB-036C-49E3-B3DB-6325AC750806', + Characteristic.Properties.READ + | Characteristic.Properties.WRITE + | Characteristic.Properties.NOTIFY, + 'READABLE,WRITEABLE', + ) + assert characteristic.has_properties(Characteristic.Properties.READ) + assert characteristic.has_properties( + Characteristic.Properties.READ | Characteristic.Properties.WRITE + ) + assert not characteristic.has_properties( + Characteristic.Properties.READ + | Characteristic.Properties.WRITE + | Characteristic.Properties.INDICATE + ) + assert not characteristic.has_properties(Characteristic.Properties.INDICATE) + + +# ----------------------------------------------------------------------------- def test_descriptor_permissions(): descriptor = Descriptor('2902', 'READABLE,WRITEABLE') assert descriptor.permissions == 3 # ----------------------------------------------------------------------------- +def test_get_attribute_group(): + device = Device() + + # add some services / characteristics to the gatt server + characteristic1 = Characteristic( + '1111', + Characteristic.READ | Characteristic.WRITE | Characteristic.NOTIFY, + Characteristic.READABLE | Characteristic.WRITEABLE, + bytes([123]), + ) + characteristic2 = Characteristic( + '2222', + Characteristic.READ | Characteristic.WRITE | Characteristic.NOTIFY, + Characteristic.READABLE | Characteristic.WRITEABLE, + bytes([123]), + ) + services = [Service('1212', [characteristic1]), Service('3233', [characteristic2])] + device.gatt_server.add_services(services) + + # get the handles from gatt server + characteristic_attributes1 = device.gatt_server.get_characteristic_attributes( + UUID('1212'), UUID('1111') + ) + assert characteristic_attributes1 is not None + characteristic_attributes2 = device.gatt_server.get_characteristic_attributes( + UUID('3233'), UUID('2222') + ) + assert characteristic_attributes2 is not None + descriptor1 = device.gatt_server.get_descriptor_attribute( + UUID('1212'), UUID('1111'), UUID('2902') + ) + assert descriptor1 is not None + descriptor2 = device.gatt_server.get_descriptor_attribute( + UUID('3233'), UUID('2222'), UUID('2902') + ) + assert descriptor2 is not None + + # confirm the handles map back to the service + assert ( + UUID('1212') + == device.gatt_server.get_attribute_group( + characteristic_attributes1[0].handle, Service + ).uuid + ) + assert ( + UUID('1212') + == device.gatt_server.get_attribute_group( + characteristic_attributes1[1].handle, Service + ).uuid + ) + assert ( + UUID('1212') + == device.gatt_server.get_attribute_group(descriptor1.handle, Service).uuid + ) + assert ( + UUID('3233') + == device.gatt_server.get_attribute_group( + characteristic_attributes2[0].handle, Service + ).uuid + ) + assert ( + UUID('3233') + == device.gatt_server.get_attribute_group( + characteristic_attributes2[1].handle, Service + ).uuid + ) + assert ( + UUID('3233') + == device.gatt_server.get_attribute_group(descriptor2.handle, Service).uuid + ) + + # confirm the handles map back to the characteristic + assert ( + UUID('1111') + == device.gatt_server.get_attribute_group( + descriptor1.handle, Characteristic + ).uuid + ) + assert ( + UUID('2222') + == device.gatt_server.get_attribute_group( + descriptor2.handle, Characteristic + ).uuid + ) + + +# ----------------------------------------------------------------------------- if __name__ == '__main__': logging.basicConfig(level=os.environ.get('BUMBLE_LOGLEVEL', 'INFO').upper()) test_UUID() diff --git a/tests/self_test.py b/tests/self_test.py index d6b16ec..1a1a474 100644 --- a/tests/self_test.py +++ b/tests/self_test.py @@ -28,9 +28,8 @@ from bumble.device import Device, Peer from bumble.host import Host from bumble.gatt import Service, Characteristic from bumble.transport import AsyncPipeSink +from bumble.pairing import PairingConfig, PairingDelegate from bumble.smp import ( - PairingConfig, - PairingDelegate, SMP_PAIRING_NOT_SUPPORTED_ERROR, SMP_CONFIRM_VALUE_FAILED_ERROR, ) @@ -163,32 +162,37 @@ async def test_self_gatt(): # Add some GATT characteristics to device 1 c1 = Characteristic( '3A143AD7-D4A7-436B-97D6-5B62C315E833', - Characteristic.READ, + Characteristic.Properties.READ, Characteristic.READABLE, bytes([1, 2, 3]), ) c2 = Characteristic( '9557CCE2-DB37-46EB-94C4-50AE5B9CB0F8', - Characteristic.READ | Characteristic.WRITE, + Characteristic.Properties.READ | Characteristic.Properties.WRITE, Characteristic.READABLE | Characteristic.WRITEABLE, bytes([4, 5, 6]), ) c3 = Characteristic( '84FC1A2E-C52D-4A2D-B8C3-8855BAB86638', - Characteristic.READ | Characteristic.WRITE_WITHOUT_RESPONSE, + Characteristic.Properties.READ + | Characteristic.Properties.WRITE_WITHOUT_RESPONSE, Characteristic.READABLE | Characteristic.WRITEABLE, bytes([7, 8, 9]), ) c4 = Characteristic( '84FC1A2E-C52D-4A2D-B8C3-8855BAB86638', - Characteristic.READ | Characteristic.NOTIFY | Characteristic.INDICATE, + Characteristic.Properties.READ + | Characteristic.Properties.NOTIFY + | Characteristic.Properties.INDICATE, Characteristic.READABLE, bytes([1, 1, 1]), ) s1 = Service('8140E247-04F0-42C1-BC34-534C344DAFCA', [c1, c2, c3]) s2 = Service('97210A0F-1875-4D05-9E5D-326EB171257A', [c4]) - two_devices.devices[1].add_services([s1, s2]) + s3 = Service('1853', []) + s4 = Service('3A12C182-14E2-4FE0-8C5B-65D7C569F9DB', [], included_services=[s2, s3]) + two_devices.devices[1].add_services([s1, s2, s4]) # Start await two_devices.devices[0].power_on() @@ -223,6 +227,13 @@ async def test_self_gatt(): assert result is not None assert result == c1.value + result = await peer.discover_service(s4.uuid) + assert len(result) == 1 + result = await peer.discover_included_services(result[0]) + assert len(result) == 2 + # Service UUID is only present when the UUID is 16-bit Bluetooth UUID + assert result[1].uuid.to_bytes() == s3.uuid.to_bytes() + # ----------------------------------------------------------------------------- @pytest.mark.asyncio @@ -234,7 +245,7 @@ async def test_self_gatt_long_read(): characteristics = [ Characteristic( f'3A143AD7-D4A7-436B-97D6-5B62C315{i:04X}', - Characteristic.READ, + Characteristic.Properties.READ, Characteristic.READABLE, bytes([x & 255 for x in range(i)]), ) @@ -259,7 +270,7 @@ async def test_self_gatt_long_read(): found_service = result[0] found_characteristics = await found_service.discover_characteristics() assert len(found_characteristics) == 513 - for (i, characteristic) in enumerate(found_characteristics): + for i, characteristic in enumerate(found_characteristics): value = await characteristic.read_value() assert value == characteristics[i].value @@ -314,11 +325,11 @@ async def _test_self_smp_with_configs(pairing_config1, pairing_config2): # ----------------------------------------------------------------------------- IO_CAP = [ - PairingDelegate.NO_OUTPUT_NO_INPUT, - PairingDelegate.KEYBOARD_INPUT_ONLY, - PairingDelegate.DISPLAY_OUTPUT_ONLY, - PairingDelegate.DISPLAY_OUTPUT_AND_YES_NO_INPUT, - PairingDelegate.DISPLAY_OUTPUT_AND_KEYBOARD_INPUT, + PairingDelegate.IoCapability.NO_OUTPUT_NO_INPUT, + PairingDelegate.IoCapability.KEYBOARD_INPUT_ONLY, + PairingDelegate.IoCapability.DISPLAY_OUTPUT_ONLY, + PairingDelegate.IoCapability.DISPLAY_OUTPUT_AND_YES_NO_INPUT, + PairingDelegate.IoCapability.DISPLAY_OUTPUT_AND_KEYBOARD_INPUT, ] SC = [False, True] MITM = [False, True] @@ -332,7 +343,10 @@ KEY_DIST = range(16) itertools.chain( itertools.product([IO_CAP], SC, MITM, [15]), itertools.product( - [[PairingDelegate.DISPLAY_OUTPUT_AND_KEYBOARD_INPUT]], SC, MITM, KEY_DIST + [[PairingDelegate.IoCapability.DISPLAY_OUTPUT_AND_KEYBOARD_INPUT]], + SC, + MITM, + KEY_DIST, ), ), ) @@ -375,7 +389,7 @@ async def test_self_smp(io_caps, sc, mitm, key_dist): else: if ( self.peer_delegate.io_capability - == PairingDelegate.KEYBOARD_INPUT_ONLY + == PairingDelegate.IoCapability.KEYBOARD_INPUT_ONLY ): peer_number = 6789 else: @@ -418,7 +432,7 @@ async def test_self_smp(io_caps, sc, mitm, key_dist): async def test_self_smp_reject(): class RejectingDelegate(PairingDelegate): def __init__(self): - super().__init__(PairingDelegate.NO_OUTPUT_NO_INPUT) + super().__init__(PairingDelegate.IoCapability.NO_OUTPUT_NO_INPUT) async def accept(self): return False @@ -439,12 +453,14 @@ async def test_self_smp_reject(): async def test_self_smp_wrong_pin(): class WrongPinDelegate(PairingDelegate): def __init__(self): - super().__init__(PairingDelegate.DISPLAY_OUTPUT_AND_KEYBOARD_INPUT) + super().__init__( + PairingDelegate.IoCapability.DISPLAY_OUTPUT_AND_KEYBOARD_INPUT + ) async def compare_numbers(self, number, digits): return False - wrong_pin_pairing_config = PairingConfig(delegate=WrongPinDelegate()) + wrong_pin_pairing_config = PairingConfig(mitm=True, delegate=WrongPinDelegate()) paired = False try: await _test_self_smp_with_configs( |