mirror of
https://github.com/google/bumble.git
synced 2026-04-16 00:25:31 +00:00
Compare commits
21 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f3bfbab44d | ||
|
|
afcce0d6c8 | ||
|
|
4bd8c24f54 | ||
|
|
8d09693654 | ||
|
|
7d7534928f | ||
|
|
e9bf5757c4 | ||
|
|
f9f694dfcf | ||
|
|
022c23500a | ||
|
|
5d4f811a65 | ||
|
|
3c81b248a3 | ||
|
|
fdee5ecf70 | ||
|
|
29bd693bab | ||
|
|
30934969b8 | ||
|
|
4a333b6c0f | ||
|
|
dad7957d92 | ||
|
|
4ffc14482f | ||
|
|
63794981b7 | ||
|
|
5f86cddc85 | ||
|
|
b5cc167e31 | ||
|
|
51d3a869a4 | ||
|
|
dd930e3bde |
30
apps/pandora_server.py
Normal file
30
apps/pandora_server.py
Normal file
@@ -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
|
||||
@@ -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
|
||||
|
||||
179
bumble/device.py
179
bumble/device.py
@@ -23,7 +23,7 @@ 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
|
||||
@@ -528,6 +528,7 @@ class Connection(CompositeEventEmitter):
|
||||
transport: int
|
||||
self_address: Address
|
||||
peer_address: Address
|
||||
peer_resolvable_address: Optional[Address]
|
||||
role: int
|
||||
encryption: int
|
||||
authenticated: bool
|
||||
@@ -888,7 +889,7 @@ 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] = []
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
@@ -2196,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)
|
||||
|
||||
@@ -2232,7 +2243,7 @@ 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))
|
||||
@@ -2243,6 +2254,7 @@ class Device(CompositeEventEmitter):
|
||||
return None
|
||||
|
||||
return keys.link_key.value
|
||||
return None
|
||||
|
||||
# [Classic only]
|
||||
async def authenticate(self, connection):
|
||||
@@ -2772,89 +2784,103 @@ class Device(CompositeEventEmitter):
|
||||
# [Classic only]
|
||||
@host_event_handler
|
||||
@with_connection_from_address
|
||||
def on_authentication_user_confirmation_request(self, connection, code):
|
||||
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
|
||||
|
||||
# Respond
|
||||
if io_capability == HCI_DISPLAY_YES_NO_IO_CAPABILITY:
|
||||
if connection.peer_pairing_io_capability in (
|
||||
HCI_DISPLAY_YES_NO_IO_CAPABILITY,
|
||||
HCI_DISPLAY_ONLY_IO_CAPABILITY,
|
||||
):
|
||||
# Display the code and ask the user to compare
|
||||
async def prompt():
|
||||
return (
|
||||
await pairing_config.delegate.compare_numbers(code, digits=6),
|
||||
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,
|
||||
},
|
||||
}
|
||||
|
||||
method = methods[peer_io_capability][io_capability]
|
||||
|
||||
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
|
||||
)
|
||||
|
||||
)
|
||||
else:
|
||||
# Ask the user to confirm the pairing, without showing a code
|
||||
async def prompt():
|
||||
return await pairing_config.delegate.confirm()
|
||||
|
||||
async def confirm():
|
||||
if await prompt():
|
||||
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
|
||||
)
|
||||
await self.host.send_command(
|
||||
HCI_User_Confirmation_Request_Negative_Reply_Command( # type: ignore[call-arg]
|
||||
bd_addr=connection.peer_address
|
||||
)
|
||||
)
|
||||
|
||||
AsyncRunner.spawn(connection.abort_on('disconnection', confirm()))
|
||||
return
|
||||
|
||||
if io_capability == HCI_DISPLAY_ONLY_IO_CAPABILITY:
|
||||
# Display the code to the user
|
||||
AsyncRunner.spawn(pairing_config.delegate.display_number(code, 6))
|
||||
|
||||
# Automatic confirmation
|
||||
self.host.send_command_sync(
|
||||
HCI_User_Confirmation_Request_Reply_Command(bd_addr=connection.peer_address)
|
||||
)
|
||||
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)
|
||||
io_capability = pairing_config.delegate.classic_io_capability
|
||||
|
||||
# Respond
|
||||
if io_capability == HCI_KEYBOARD_ONLY_IO_CAPABILITY:
|
||||
# Ask the user to input a number
|
||||
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
|
||||
)
|
||||
)
|
||||
else:
|
||||
await self.host.send_command(
|
||||
HCI_User_Passkey_Request_Negative_Reply_Command(
|
||||
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
|
||||
)
|
||||
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( # type: ignore[call-arg]
|
||||
bd_addr=connection.peer_address
|
||||
)
|
||||
)
|
||||
|
||||
AsyncRunner.spawn(reply())
|
||||
|
||||
# [Classic only]
|
||||
@host_event_handler
|
||||
@@ -3059,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
|
||||
|
||||
@@ -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],
|
||||
included_services: List[Service] = [],
|
||||
primary=True,
|
||||
):
|
||||
# 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,12 +255,39 @@ 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 IncludedServiceDeclaration(Attribute):
|
||||
'''
|
||||
See Vol 3, Part G - 3.2 INCLUDE DEFINITION
|
||||
'''
|
||||
|
||||
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
|
||||
|
||||
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):
|
||||
'''
|
||||
|
||||
@@ -63,6 +63,7 @@ from .gatt import (
|
||||
GATT_PRIMARY_SERVICE_ATTRIBUTE_TYPE,
|
||||
GATT_REQUEST_TIMEOUT,
|
||||
GATT_SECONDARY_SERVICE_ATTRIBUTE_TYPE,
|
||||
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):
|
||||
@@ -502,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]
|
||||
|
||||
@@ -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 = (
|
||||
@@ -222,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:
|
||||
@@ -274,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:
|
||||
|
||||
@@ -25,7 +25,7 @@ import asyncio
|
||||
import logging
|
||||
import os
|
||||
import json
|
||||
from typing import TYPE_CHECKING, Optional
|
||||
from typing import TYPE_CHECKING, Dict, List, Optional, Tuple
|
||||
|
||||
from .colors import color
|
||||
from .hci import Address
|
||||
@@ -139,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))
|
||||
|
||||
@@ -177,15 +177,15 @@ class KeyStore:
|
||||
separator = '\n'
|
||||
|
||||
@staticmethod
|
||||
def create_for_device(device: Device) -> Optional[KeyStore]:
|
||||
def create_for_device(device: Device) -> KeyStore:
|
||||
if device.config.keystore is None:
|
||||
return None
|
||||
return MemoryKeyStore()
|
||||
|
||||
keystore_type = device.config.keystore.split(':', 1)[0]
|
||||
if keystore_type == 'JsonKeyStore':
|
||||
return JsonKeyStore.from_device(device)
|
||||
|
||||
return None
|
||||
return MemoryKeyStore()
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
@@ -257,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()
|
||||
@@ -307,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())
|
||||
|
||||
@@ -65,8 +65,9 @@ class PairingDelegate:
|
||||
DISTRIBUTE_SIGNING_KEY = SMP_SIGN_KEY_DISTRIBUTION_FLAG
|
||||
DISTRIBUTE_LINK_KEY = SMP_LINK_KEY_DISTRIBUTION_FLAG
|
||||
|
||||
DEFAULT_KEY_DISTRIBUTION: int = (
|
||||
SMP_ENC_KEY_DISTRIBUTION_FLAG | SMP_ID_KEY_DISTRIBUTION_FLAG
|
||||
DEFAULT_KEY_DISTRIBUTION: KeyDistribution = (
|
||||
KeyDistribution.DISTRIBUTE_ENCRYPTION_KEY
|
||||
| KeyDistribution.DISTRIBUTE_IDENTITY_KEY
|
||||
)
|
||||
|
||||
# Default mapping from abstract to Classic I/O capabilities.
|
||||
@@ -85,9 +86,9 @@ class PairingDelegate:
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
io_capability=NO_OUTPUT_NO_INPUT,
|
||||
local_initiator_key_distribution=DEFAULT_KEY_DISTRIBUTION,
|
||||
local_responder_key_distribution=DEFAULT_KEY_DISTRIBUTION,
|
||||
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
|
||||
@@ -113,8 +114,11 @@ class PairingDelegate:
|
||||
"""Accept or reject a Pairing request."""
|
||||
return True
|
||||
|
||||
async def confirm(self) -> bool:
|
||||
"""Respond yes or no to a Pairing confirmation question."""
|
||||
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
|
||||
@@ -129,7 +133,7 @@ class PairingDelegate:
|
||||
"""
|
||||
return 0
|
||||
|
||||
async def get_string(self, max_length) -> Optional[str]:
|
||||
async def get_string(self, max_length: int) -> Optional[str]:
|
||||
"""
|
||||
Return a string whose utf-8 encoding is up to max_length bytes.
|
||||
"""
|
||||
|
||||
105
bumble/pandora/__init__.py
Normal file
105
bumble/pandora/__init__.py
Normal file
@@ -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)
|
||||
48
bumble/pandora/config.py
Normal file
48
bumble/pandora/config.py
Normal file
@@ -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,
|
||||
)
|
||||
157
bumble/pandora/device.py
Normal file
157
bumble/pandora/device.py
Normal file
@@ -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),
|
||||
]
|
||||
)
|
||||
]
|
||||
),
|
||||
),
|
||||
]
|
||||
}
|
||||
856
bumble/pandora/host.py
Normal file
856
bumble/pandora/host.py
Normal file
@@ -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
|
||||
0
bumble/pandora/py.typed
Normal file
0
bumble/pandora/py.typed
Normal file
529
bumble/pandora/security.py
Normal file
529
bumble/pandora/security.py
Normal file
@@ -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()
|
||||
112
bumble/pandora/utils.py
Normal file
112
bumble/pandora/utils.py
Normal file
@@ -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
|
||||
376
bumble/smp.py
376
bumble/smp.py
@@ -26,16 +26,22 @@ 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 (
|
||||
HCI_DISPLAY_ONLY_IO_CAPABILITY,
|
||||
HCI_DISPLAY_YES_NO_IO_CAPABILITY,
|
||||
HCI_KEYBOARD_ONLY_IO_CAPABILITY,
|
||||
HCI_NO_INPUT_NO_OUTPUT_IO_CAPABILITY,
|
||||
Address,
|
||||
HCI_LE_Enable_Encryption_Command,
|
||||
HCI_Object,
|
||||
@@ -51,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
|
||||
@@ -184,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)
|
||||
|
||||
|
||||
@@ -197,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)
|
||||
@@ -217,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
|
||||
@@ -234,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:
|
||||
@@ -251,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
|
||||
@@ -272,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):
|
||||
@@ -320,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(
|
||||
@@ -343,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)])
|
||||
@@ -351,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)])
|
||||
@@ -359,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})])
|
||||
@@ -367,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)])
|
||||
@@ -375,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(
|
||||
@@ -387,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(
|
||||
@@ -402,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)])
|
||||
@@ -410,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)])
|
||||
@@ -418,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)])
|
||||
@@ -426,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(
|
||||
@@ -439,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)])
|
||||
@@ -447,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(
|
||||
@@ -459,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
|
||||
@@ -574,11 +626,17 @@ class Session:
|
||||
},
|
||||
}
|
||||
|
||||
def __init__(self, manager, connection, pairing_config, is_initiator):
|
||||
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)
|
||||
@@ -588,29 +646,29 @@ 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 = is_initiator
|
||||
@@ -628,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
|
||||
|
||||
@@ -641,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
|
||||
@@ -669,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
|
||||
@@ -706,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]
|
||||
@@ -724,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'))
|
||||
@@ -732,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()
|
||||
@@ -747,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(
|
||||
@@ -764,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:
|
||||
@@ -777,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()
|
||||
|
||||
@@ -793,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:
|
||||
@@ -809,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:
|
||||
@@ -817,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(
|
||||
@@ -838,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,
|
||||
@@ -850,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:
|
||||
@@ -892,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)),
|
||||
@@ -903,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,
|
||||
@@ -922,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 = (
|
||||
@@ -932,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
|
||||
@@ -1032,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:
|
||||
@@ -1055,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(
|
||||
@@ -1083,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
|
||||
|
||||
@@ -1091,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
|
||||
@@ -1104,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
|
||||
@@ -1121,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:
|
||||
@@ -1137,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
|
||||
@@ -1186,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:
|
||||
@@ -1199,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)
|
||||
@@ -1215,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:
|
||||
@@ -1280,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
|
||||
@@ -1331,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:
|
||||
@@ -1341,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()
|
||||
@@ -1352,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,
|
||||
@@ -1371,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
|
||||
):
|
||||
@@ -1394,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
|
||||
@@ -1402,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])
|
||||
@@ -1411,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,
|
||||
@@ -1435,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,
|
||||
@@ -1468,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()
|
||||
@@ -1502,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
|
||||
@@ -1538,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
|
||||
):
|
||||
@@ -1549,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()
|
||||
@@ -1558,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)
|
||||
|
||||
@@ -1591,14 +1708,24 @@ class Manager(EventEmitter):
|
||||
Implements the Initiator and Responder roles of the Security Manager Protocol
|
||||
'''
|
||||
|
||||
def __init__(self, device, pairing_config_factory):
|
||||
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 = 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}'
|
||||
@@ -1606,20 +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, is_initiator=False)
|
||||
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
|
||||
@@ -1633,23 +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, is_initiator=True)
|
||||
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(
|
||||
@@ -1663,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}')
|
||||
@@ -1679,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)
|
||||
|
||||
|
||||
@@ -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')
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
410
bumble/transport/android_netsim.py
Normal file
410
bumble/transport/android_netsim.py
Normal file
@@ -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')
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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: ...
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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)
|
||||
@@ -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
|
||||
0
bumble/transport/grpc_protobuf/__init__.py
Normal file
0
bumble/transport/grpc_protobuf/__init__.py
Normal file
25
bumble/transport/grpc_protobuf/common_pb2.py
Normal file
25
bumble/transport/grpc_protobuf/common_pb2.py
Normal file
@@ -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)
|
||||
12
bumble/transport/grpc_protobuf/common_pb2.pyi
Normal file
12
bumble/transport/grpc_protobuf/common_pb2.pyi
Normal file
@@ -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__ = []
|
||||
4
bumble/transport/grpc_protobuf/common_pb2_grpc.py
Normal file
4
bumble/transport/grpc_protobuf/common_pb2_grpc.py
Normal file
@@ -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
|
||||
|
||||
File diff suppressed because one or more lines are too long
158
bumble/transport/grpc_protobuf/emulated_bluetooth_device_pb2.pyi
Normal file
158
bumble/transport/grpc_protobuf/emulated_bluetooth_device_pb2.pyi
Normal file
@@ -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: ...
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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: ...
|
||||
@@ -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
|
||||
|
||||
32
bumble/transport/grpc_protobuf/emulated_bluetooth_pb2.py
Normal file
32
bumble/transport/grpc_protobuf/emulated_bluetooth_pb2.py
Normal file
@@ -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)
|
||||
19
bumble/transport/grpc_protobuf/emulated_bluetooth_pb2.pyi
Normal file
19
bumble/transport/grpc_protobuf/emulated_bluetooth_pb2.pyi
Normal file
@@ -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: ...
|
||||
237
bumble/transport/grpc_protobuf/emulated_bluetooth_pb2_grpc.py
Normal file
237
bumble/transport/grpc_protobuf/emulated_bluetooth_pb2_grpc.py
Normal file
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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: ...
|
||||
@@ -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
|
||||
|
||||
28
bumble/transport/grpc_protobuf/hci_packet_pb2.py
Normal file
28
bumble/transport/grpc_protobuf/hci_packet_pb2.py
Normal file
@@ -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)
|
||||
22
bumble/transport/grpc_protobuf/hci_packet_pb2.pyi
Normal file
22
bumble/transport/grpc_protobuf/hci_packet_pb2.pyi
Normal file
@@ -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: ...
|
||||
4
bumble/transport/grpc_protobuf/hci_packet_pb2_grpc.py
Normal file
4
bumble/transport/grpc_protobuf/hci_packet_pb2_grpc.py
Normal file
@@ -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
|
||||
|
||||
31
bumble/transport/grpc_protobuf/packet_streamer_pb2.py
Normal file
31
bumble/transport/grpc_protobuf/packet_streamer_pb2.py
Normal file
@@ -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)
|
||||
27
bumble/transport/grpc_protobuf/packet_streamer_pb2.pyi
Normal file
27
bumble/transport/grpc_protobuf/packet_streamer_pb2.pyi
Normal file
@@ -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: ...
|
||||
109
bumble/transport/grpc_protobuf/packet_streamer_pb2_grpc.py
Normal file
109
bumble/transport/grpc_protobuf/packet_streamer_pb2_grpc.py
Normal file
@@ -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)
|
||||
32
bumble/transport/grpc_protobuf/startup_pb2.py
Normal file
32
bumble/transport/grpc_protobuf/startup_pb2.py
Normal file
@@ -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)
|
||||
46
bumble/transport/grpc_protobuf/startup_pb2.pyi
Normal file
46
bumble/transport/grpc_protobuf/startup_pb2.pyi
Normal file
@@ -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: ...
|
||||
4
bumble/transport/grpc_protobuf/startup_pb2_grpc.py
Normal file
4
bumble/transport/grpc_protobuf/startup_pb2_grpc.py
Normal file
@@ -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
|
||||
|
||||
@@ -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"
|
||||
!!! example "Run the example GATT server connected to the emulator via Netsim"
|
||||
``` shell
|
||||
$ python run_gatt_server.py device1.json android-emulator
|
||||
$ python run_gatt_server.py device1.json android-netsim
|
||||
```
|
||||
|
||||
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-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"
|
||||
```shell
|
||||
$ emulator -forward-vhci -avd 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.
|
||||
|
||||
!!! 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.
|
||||
|
||||
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`.
|
||||
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
|
||||
$ bumble-hci-bridge android-emulator:mode=controller usb:0
|
||||
$ bumble-hci-bridge android-netsim:_:8877,mode=controller usb:0
|
||||
```
|
||||
|
||||
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).
|
||||
|
||||
!!! 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
|
||||
$ 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
|
||||
|
||||
@@ -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-emulator`
|
||||
connect as a host to the emulator on localhost:8554
|
||||
`android-netsim`
|
||||
connect as a host to Netsim on the gRPC port discovered automatically.
|
||||
|
||||
!!! example Example
|
||||
`android-emulator:mode=controller`
|
||||
connect as a controller 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: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: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.
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
{
|
||||
"name": "Bumble Speaker",
|
||||
"address": "F0:F1:F2:F3:F4:F5",
|
||||
"class_of_device": 2360324,
|
||||
"keystore": "JsonKeyStore"
|
||||
}
|
||||
|
||||
@@ -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,44 +40,35 @@ 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]
|
||||
exclude = ['bumble/transport/grpc_protobuf']
|
||||
|
||||
[[tool.mypy.overrides]]
|
||||
module = "bumble.transport.emulated_bluetooth_pb2_grpc"
|
||||
ignore_missing_imports = true
|
||||
|
||||
[[tool.mypy.overrides]]
|
||||
module = "bumble.transport.emulated_bluetooth_packets_pb2"
|
||||
module = "bumble.transport.grpc_protobuf.*"
|
||||
ignore_errors = true
|
||||
|
||||
[[tool.mypy.overrides]]
|
||||
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
|
||||
|
||||
@@ -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
|
||||
14
scripts/process_android_netsim_protos.sh
Normal file
14
scripts/process_android_netsim_protos.sh
Normal file
@@ -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'
|
||||
@@ -45,6 +45,7 @@ install_requires =
|
||||
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 =
|
||||
@@ -60,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
|
||||
@@ -74,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
|
||||
|
||||
@@ -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():
|
||||
@@ -49,6 +49,24 @@ def test_get_dict_key_by_value():
|
||||
assert get_dict_key_by_value(dictionary, 3) is None
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
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()
|
||||
|
||||
@@ -190,7 +190,9 @@ async def test_self_gatt():
|
||||
|
||||
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()
|
||||
@@ -225,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
|
||||
|
||||
Reference in New Issue
Block a user