Compare commits

..

29 Commits

Author SHA1 Message Date
Gilles Boccon-Gibod
0a251c9f8e Merge pull request #265 from mogenson/grpcio-update
Update grpcio and pip package versions
2023-08-31 14:53:54 -07:00
Michael Mogenson
351d77be59 Update grpcio and pip package versions
The current grpcio version 1.51.1 fails to build on aarch64 based MacOS
computers. Update the version of the grpcio and grpcio-tools packages to
the latest 1.57.0 version. There are binary wheels available for this
version from PyPi for aarch64 MacOS.

Also update the pip version for the Conda environment. It seems a newer
version of pip is required to detect and install these wheels.

Testing:

invoke test passes and I can start the bumble-pandora-server
successfully.
2023-08-31 14:01:14 -04:00
Gilles Boccon-Gibod
8f3fdecb93 Merge pull request #263 from zxzxwu/pdu
Typing packet transmission flow
2023-08-30 11:15:12 -07:00
Josh Wu
249a205d8e Typing packet transmission flow 2023-08-30 01:47:46 +08:00
Gilles Boccon-Gibod
7485801222 Merge pull request #256 from zxzxwu/sdp-type-fix
Typing SDP and add tests
2023-08-28 08:41:02 -07:00
Gilles Boccon-Gibod
4678e59737 Merge pull request #250 from google/gbg/new-rtk-dongles
add entry to the list of supported USB devices
2023-08-28 08:40:40 -07:00
Gilles Boccon-Gibod
952d351c00 Merge pull request #247 from google/gbg/wasm-with-ws
wasm with ws
2023-08-28 08:40:18 -07:00
Josh Wu
901eb55b0e Add SDP self tests 2023-08-24 01:27:07 +08:00
Josh Wu
727586e40e Typing SDP 2023-08-23 14:52:44 +08:00
Gilles Boccon-Gibod
3aa678a58e Merge pull request #253 from zxzxwu/rfcomm_type_fix
Adding more typing in rfcomm.py
2023-08-22 09:47:38 -07:00
Gilles Boccon-Gibod
fc7c1a8113 Merge pull request #255 from zxzxwu/player
Remove accidentally added files
2023-08-22 07:34:31 -07:00
Josh Wu
f62a0bbe75 Remove accidentally added files 2023-08-22 22:12:41 +08:00
Josh Wu
7341172739 Use __future__.annotations for typing 2023-08-22 14:44:15 +08:00
Gilles Boccon-Gibod
91b9fbe450 Merge pull request #240 from zxzxwu/ssp
Handle SSP Complete events
2023-08-21 18:01:28 -07:00
Josh Wu
e6b566b848 RFCOMM: Refactor role to enum 2023-08-21 15:16:34 +08:00
Josh Wu
2527a711dc Refactor RFCOMM states to enum 2023-08-21 15:12:52 +08:00
Josh Wu
5fba6b1cae Complete typing in RFCOMM 2023-08-21 15:12:52 +08:00
Gilles Boccon-Gibod
43e632f83c Merge pull request #244 from google/gbg/hci-source-termination-mode
add sink method for lost transports
2023-08-18 10:17:11 -07:00
Gilles Boccon-Gibod
623298b0e9 emit flush event when transport lost 2023-08-18 09:59:15 -07:00
Gilles Boccon-Gibod
85a61dc39d add entry to the list of supported USB devices 2023-08-18 09:56:06 -07:00
Gilles Boccon-Gibod
6e8c44b5e6 Merge pull request #249 from zxzxwu/player
Support SBC in speaker.app
2023-08-18 09:55:23 -07:00
Josh Wu
ec4dcc174e Support SBC in speaker.app 2023-08-18 17:13:11 +08:00
Charlie Boutier
b247aca3b4 pandora_server: add support to accept bumble config file 2023-08-17 14:24:56 -07:00
Gilles Boccon-Gibod
6226bfd196 fix typo after refactor 2023-08-17 09:51:56 -07:00
Gilles Boccon-Gibod
71e11b7cf8 format 2023-08-15 15:20:48 -07:00
Gilles Boccon-Gibod
800c62fdb6 add readme for web examples 2023-08-15 15:17:38 -07:00
Gilles Boccon-Gibod
640b9cd53a refactor pyiodide support and add examples 2023-08-15 13:36:58 -07:00
Gilles Boccon-Gibod
2bfec3c4ed add sink method for lost transports 2023-08-12 10:54:20 -07:00
Josh Wu
2af3494d8c Handle SSP Complete events 2023-08-10 10:58:41 +08:00
35 changed files with 1712 additions and 564 deletions

View File

@@ -1,8 +1,10 @@
import asyncio
import click
import logging
import json
from bumble.pandora import PandoraDevice, serve
from typing import Dict, Any
BUMBLE_SERVER_GRPC_PORT = 7999
ROOTCANAL_PORT_CUTTLEFISH = 7300
@@ -18,13 +20,30 @@ ROOTCANAL_PORT_CUTTLEFISH = 7300
help='HCI transport',
default=f'tcp-client:127.0.0.1:<rootcanal-port>',
)
def main(grpc_port: int, rootcanal_port: int, transport: str) -> None:
@click.option(
'--config',
help='Bumble json configuration file',
)
def main(grpc_port: int, rootcanal_port: int, transport: str, config: str) -> None:
if '<rootcanal-port>' in transport:
transport = transport.replace('<rootcanal-port>', str(rootcanal_port))
device = PandoraDevice({'transport': transport})
bumble_config = retrieve_config(config)
if 'transport' not in bumble_config.keys():
bumble_config.update({'transport': transport})
device = PandoraDevice(bumble_config)
logging.basicConfig(level=logging.DEBUG)
asyncio.run(serve(device, port=grpc_port))
def retrieve_config(config: str) -> Dict[str, Any]:
if not config:
return {}
with open(config, 'r') as f:
return json.load(f)
if __name__ == '__main__':
main() # pylint: disable=no-value-for-parameter

View File

@@ -228,10 +228,11 @@ class FfplayOutput(QueuedOutput):
subprocess: Optional[asyncio.subprocess.Process]
ffplay_task: Optional[asyncio.Task]
def __init__(self) -> None:
super().__init__(AacAudioExtractor())
def __init__(self, codec: str) -> None:
super().__init__(AudioExtractor.create(codec))
self.subprocess = None
self.ffplay_task = None
self.codec = codec
async def start(self):
if self.started:
@@ -240,7 +241,7 @@ class FfplayOutput(QueuedOutput):
await super().start()
self.subprocess = await asyncio.create_subprocess_shell(
'ffplay -acodec aac pipe:0',
f'ffplay -f {self.codec} pipe:0',
stdin=asyncio.subprocess.PIPE,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
@@ -419,7 +420,7 @@ class Speaker:
self.outputs = []
for output in outputs:
if output == '@ffplay':
self.outputs.append(FfplayOutput())
self.outputs.append(FfplayOutput(codec))
continue
# Default to FileOutput
@@ -708,17 +709,6 @@ def speaker(
):
"""Run the speaker."""
# ffplay only works with AAC for now
if codec != 'aac' and '@ffplay' in output:
print(
color(
f'{codec} not supported with @ffplay output, '
'@ffplay output will be skipped',
'yellow',
)
)
output = list(filter(lambda x: x != '@ffplay', output))
if '@ffplay' in output:
# Check if ffplay is installed
try:

View File

@@ -188,6 +188,8 @@ class Controller:
if link:
link.add_controller(self)
self.terminated = asyncio.get_running_loop().create_future()
@property
def host(self):
return self.hci_sink
@@ -288,10 +290,9 @@ class Controller:
if self.host:
self.host.on_packet(packet.to_bytes())
# This method allow the controller to emulate the same API as a transport source
# This method allows the controller to emulate the same API as a transport source
async def wait_for_termination(self):
# For now, just wait forever
await asyncio.get_running_loop().create_future()
await self.terminated
############################################################
# Link connections

View File

@@ -23,22 +23,18 @@
# -----------------------------------------------------------------------------
import logging
import operator
import platform
if platform.system() != 'Emscripten':
import secrets
from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
from cryptography.hazmat.primitives.asymmetric.ec import (
generate_private_key,
ECDH,
EllipticCurvePublicNumbers,
EllipticCurvePrivateNumbers,
SECP256R1,
)
from cryptography.hazmat.primitives import cmac
else:
# TODO: implement stubs
pass
import secrets
from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
from cryptography.hazmat.primitives.asymmetric.ec import (
generate_private_key,
ECDH,
EllipticCurvePublicNumbers,
EllipticCurvePrivateNumbers,
SECP256R1,
)
from cryptography.hazmat.primitives import cmac
# -----------------------------------------------------------------------------
# Logging

View File

@@ -652,7 +652,7 @@ class Connection(CompositeEventEmitter):
def is_incomplete(self) -> bool:
return self.handle is None
def send_l2cap_pdu(self, cid, pdu):
def send_l2cap_pdu(self, cid: int, pdu: bytes) -> None:
self.device.send_l2cap_pdu(self.handle, cid, pdu)
def create_l2cap_connector(self, psm):
@@ -1096,7 +1096,7 @@ class Device(CompositeEventEmitter):
return self._host
@host.setter
def host(self, host):
def host(self, host: Host) -> None:
# Unsubscribe from events from the current host
if self._host:
for event_name in device_host_event_handlers:
@@ -1183,7 +1183,7 @@ class Device(CompositeEventEmitter):
connection, psm, max_credits, mtu, mps
)
def send_l2cap_pdu(self, connection_handle, cid, pdu):
def send_l2cap_pdu(self, connection_handle: int, cid: int, pdu: bytes) -> None:
self.host.send_l2cap_pdu(connection_handle, cid, pdu)
async def send_command(self, command, check_result=False):
@@ -2262,17 +2262,21 @@ class Device(CompositeEventEmitter):
return keys.ltk_peripheral.value
async def get_link_key(self, address: Address) -> Optional[bytes]:
# Look for the key in the keystore
if self.keystore is not None:
keys = await self.keystore.get(str(address))
if keys is not None:
logger.debug('found keys in the key store')
if keys.link_key is None:
logger.warning('no link key')
return None
if self.keystore is None:
return None
return keys.link_key.value
return None
# Look for the key in the keystore
keys = await self.keystore.get(str(address))
if keys is None:
logger.debug(f'no keys found for {address}')
return None
logger.debug('found keys in the key store')
if keys.link_key is None:
logger.warning('no link key')
return None
return keys.link_key.value
# [Classic only]
async def authenticate(self, connection):
@@ -2391,6 +2395,18 @@ class Device(CompositeEventEmitter):
'connection_encryption_failure', on_encryption_failure
)
async def update_keys(self, address: str, keys: PairingKeys) -> None:
if self.keystore is None:
return
try:
await self.keystore.update(address, keys)
await self.refresh_resolving_list()
except Exception as error:
logger.warning(f'!!! error while storing keys: {error}')
else:
self.emit('key_store_update')
# [Classic only]
async def switch_role(self, connection: Connection, role: int):
pending_role_change = asyncio.get_running_loop().create_future()
@@ -2485,13 +2501,7 @@ class Device(CompositeEventEmitter):
value=link_key, authenticated=authenticated
)
async def store_keys():
try:
await self.keystore.update(str(bd_addr), pairing_keys)
except Exception as error:
logger.warning(f'!!! error while storing keys: {error}')
self.abort_on('flush', store_keys())
self.abort_on('flush', self.update_keys(str(bd_addr), pairing_keys))
if connection := self.find_connection_by_bd_addr(
bd_addr, transport=BT_BR_EDR_TRANSPORT
@@ -2743,20 +2753,6 @@ class Device(CompositeEventEmitter):
)
connection.emit('connection_authentication_failure', error)
@host_event_handler
@with_connection_from_address
def on_ssp_complete(self, connection):
# On Secure Simple Pairing complete, in case:
# - Connection isn't already authenticated
# - AND we are not the initiator of the authentication
# We must trigger authentication to know if we are truly authenticated
if not connection.authenticating and not connection.authenticated:
logger.debug(
f'*** Trigger Connection Authentication: [0x{connection.handle:04X}] '
f'{connection.peer_address}'
)
asyncio.create_task(connection.authenticate())
# [Classic only]
@host_event_handler
@with_connection_from_address
@@ -3111,6 +3107,18 @@ class Device(CompositeEventEmitter):
connection.emit('role_change_failure', error)
self.emit('role_change_failure', address, error)
# [Classic only]
@host_event_handler
@with_connection_from_address
def on_classic_pairing(self, connection: Connection) -> None:
connection.emit('classic_pairing')
# [Classic only]
@host_event_handler
@with_connection_from_address
def on_classic_pairing_failure(self, connection: Connection, status) -> None:
connection.emit('classic_pairing_failure', status)
def on_pairing_start(self, connection: Connection) -> None:
connection.emit('pairing_start')
@@ -3159,7 +3167,7 @@ class Device(CompositeEventEmitter):
@host_event_handler
@with_connection_from_handle
def on_l2cap_pdu(self, connection, cid, pdu):
def on_l2cap_pdu(self, connection: Connection, cid: int, pdu: bytes):
self.l2cap_channel_manager.on_pdu(connection, cid, pdu)
def __str__(self):

View File

@@ -125,6 +125,7 @@ RTK_USB_PRODUCTS = {
(0x2550, 0x8761),
(0x2B89, 0x8761),
(0x7392, 0xC611),
(0x0BDA, 0x877B),
# Realtek 8821AE
(0x0B05, 0x17DC),
(0x13D3, 0x3414),

View File

@@ -20,7 +20,7 @@ import struct
import collections
import logging
import functools
from typing import Dict, Type, Union
from typing import Dict, Type, Union, Callable, Any, Optional
from .colors import color
from .core import (
@@ -1918,7 +1918,7 @@ class HCI_Packet:
hci_packet_type: int
@staticmethod
def from_bytes(packet):
def from_bytes(packet: bytes) -> HCI_Packet:
packet_type = packet[0]
if packet_type == HCI_COMMAND_PACKET:
@@ -1992,7 +1992,7 @@ class HCI_Command(HCI_Packet):
return inner
@staticmethod
def from_bytes(packet):
def from_bytes(packet: bytes) -> HCI_Command:
op_code, length = struct.unpack_from('<HB', packet, 1)
parameters = packet[4:]
if len(parameters) != length:
@@ -2011,7 +2011,7 @@ class HCI_Command(HCI_Packet):
HCI_Object.init_from_bytes(self, parameters, 0, fields)
return self
return cls.from_parameters(parameters)
return cls.from_parameters(parameters) # type: ignore
@staticmethod
def command_name(op_code):
@@ -4350,13 +4350,14 @@ class HCI_Event(HCI_Packet):
return event_class
@staticmethod
def from_bytes(packet):
def from_bytes(packet: bytes) -> HCI_Event:
event_code = packet[1]
length = packet[2]
parameters = packet[3:]
if len(parameters) != length:
raise ValueError('invalid packet length')
cls: Type[HCI_Event | HCI_LE_Meta_Event] | None
if event_code == HCI_LE_META_EVENT:
# We do this dispatch here and not in the subclass in order to avoid call
# loops
@@ -4373,7 +4374,7 @@ class HCI_Event(HCI_Packet):
return HCI_Event(event_code, parameters)
# Invoke the factory to create a new instance
return cls.from_parameters(parameters)
return cls.from_parameters(parameters) # type: ignore
@classmethod
def from_parameters(cls, parameters):
@@ -5086,6 +5087,7 @@ class HCI_Command_Complete_Event(HCI_Event):
'''
return_parameters = b''
command_opcode: int
def map_return_parameters(self, return_parameters):
'''Map simple 'status' return parameters to their named constant form'''
@@ -5605,7 +5607,7 @@ class HCI_Remote_Host_Supported_Features_Notification_Event(HCI_Event):
# -----------------------------------------------------------------------------
class HCI_AclDataPacket:
class HCI_AclDataPacket(HCI_Packet):
'''
See Bluetooth spec @ 5.4.2 HCI ACL Data Packets
'''
@@ -5613,7 +5615,7 @@ class HCI_AclDataPacket:
hci_packet_type = HCI_ACL_DATA_PACKET
@staticmethod
def from_bytes(packet):
def from_bytes(packet: bytes) -> HCI_AclDataPacket:
# Read the header
h, data_total_length = struct.unpack_from('<HH', packet, 1)
connection_handle = h & 0xFFF
@@ -5655,12 +5657,14 @@ class HCI_AclDataPacket:
# -----------------------------------------------------------------------------
class HCI_AclDataPacketAssembler:
def __init__(self, callback):
current_data: Optional[bytes]
def __init__(self, callback: Callable[[bytes], Any]) -> None:
self.callback = callback
self.current_data = None
self.l2cap_pdu_length = 0
def feed_packet(self, packet):
def feed_packet(self, packet: HCI_AclDataPacket) -> None:
if packet.pb_flag in (
HCI_ACL_PB_FIRST_NON_FLUSHABLE,
HCI_ACL_PB_FIRST_FLUSHABLE,
@@ -5674,6 +5678,7 @@ class HCI_AclDataPacketAssembler:
return
self.current_data += packet.data
assert self.current_data is not None
if len(self.current_data) == self.l2cap_pdu_length + 4:
# The packet is complete, invoke the callback
logger.debug(f'<<< ACL PDU: {self.current_data.hex()}')

View File

@@ -15,23 +15,24 @@
# -----------------------------------------------------------------------------
# Imports
# -----------------------------------------------------------------------------
from __future__ import annotations
import asyncio
import collections
import logging
import struct
from typing import Optional
from bumble.colors import color
from bumble.l2cap import L2CAP_PDU
from bumble.snoop import Snooper
from bumble import drivers
from typing import Optional
from .hci import (
Address,
HCI_ACL_DATA_PACKET,
HCI_COMMAND_COMPLETE_EVENT,
HCI_COMMAND_PACKET,
HCI_COMMAND_COMPLETE_EVENT,
HCI_EVENT_PACKET,
HCI_LE_READ_BUFFER_SIZE_COMMAND,
HCI_LE_READ_LOCAL_SUPPORTED_FEATURES_COMMAND,
@@ -45,8 +46,11 @@ from .hci import (
HCI_VERSION_BLUETOOTH_CORE_4_0,
HCI_AclDataPacket,
HCI_AclDataPacketAssembler,
HCI_Command,
HCI_Command_Complete_Event,
HCI_Constant,
HCI_Error,
HCI_Event,
HCI_LE_Long_Term_Key_Request_Negative_Reply_Command,
HCI_LE_Long_Term_Key_Request_Reply_Command,
HCI_LE_Read_Buffer_Size_Command,
@@ -63,16 +67,15 @@ from .hci import (
HCI_Read_Local_Version_Information_Command,
HCI_Reset_Command,
HCI_Set_Event_Mask_Command,
map_null_terminated_utf8_string,
)
from .core import (
BT_BR_EDR_TRANSPORT,
BT_CENTRAL_ROLE,
BT_LE_TRANSPORT,
ConnectionPHY,
ConnectionParameters,
)
from .utils import AbortableEventEmitter
from .transport.common import TransportLostError
# -----------------------------------------------------------------------------
@@ -96,17 +99,17 @@ HOST_HC_TOTAL_NUM_ACL_DATA_PACKETS = 1
# -----------------------------------------------------------------------------
class Connection:
def __init__(self, host, handle, peer_address, transport):
def __init__(self, host: Host, handle: int, peer_address: Address, transport: int):
self.host = host
self.handle = handle
self.peer_address = peer_address
self.assembler = HCI_AclDataPacketAssembler(self.on_acl_pdu)
self.transport = transport
def on_hci_acl_data_packet(self, packet):
def on_hci_acl_data_packet(self, packet: HCI_AclDataPacket) -> None:
self.assembler.feed_packet(packet)
def on_acl_pdu(self, pdu):
def on_acl_pdu(self, pdu: bytes) -> None:
l2cap_pdu = L2CAP_PDU.from_bytes(pdu)
self.host.on_l2cap_pdu(self, l2cap_pdu.cid, l2cap_pdu.payload)
@@ -308,7 +311,7 @@ class Host(AbortableEventEmitter):
def set_packet_sink(self, sink):
self.hci_sink = sink
def send_hci_packet(self, packet):
def send_hci_packet(self, packet: HCI_Packet) -> None:
if self.snooper:
self.snooper.snoop(bytes(packet), Snooper.Direction.HOST_TO_CONTROLLER)
@@ -349,7 +352,7 @@ class Host(AbortableEventEmitter):
return response
except Exception as error:
logger.warning(
f'{color("!!! Exception while sending HCI packet:", "red")} {error}'
f'{color("!!! Exception while sending command:", "red")} {error}'
)
raise error
finally:
@@ -357,13 +360,13 @@ class Host(AbortableEventEmitter):
self.pending_response = None
# Use this method to send a command from a task
def send_command_sync(self, command):
async def send_command(command):
def send_command_sync(self, command: HCI_Command) -> None:
async def send_command(command: HCI_Command) -> None:
await self.send_command(command)
asyncio.create_task(send_command(command))
def send_l2cap_pdu(self, connection_handle, cid, pdu):
def send_l2cap_pdu(self, connection_handle: int, cid: int, pdu: bytes) -> None:
l2cap_pdu = bytes(L2CAP_PDU(cid, pdu))
# Send the data to the controller via ACL packets
@@ -388,7 +391,7 @@ class Host(AbortableEventEmitter):
offset += data_total_length
bytes_remaining -= data_total_length
def queue_acl_packet(self, acl_packet):
def queue_acl_packet(self, acl_packet: HCI_AclDataPacket) -> None:
self.acl_packet_queue.appendleft(acl_packet)
self.check_acl_packet_queue()
@@ -398,7 +401,7 @@ class Host(AbortableEventEmitter):
f'{len(self.acl_packet_queue)} in queue'
)
def check_acl_packet_queue(self):
def check_acl_packet_queue(self) -> None:
# Send all we can (TODO: support different LE/Classic limits)
while (
len(self.acl_packet_queue) > 0
@@ -444,47 +447,53 @@ class Host(AbortableEventEmitter):
]
# Packet Sink protocol (packets coming from the controller via HCI)
def on_packet(self, packet):
def on_packet(self, packet: bytes) -> None:
hci_packet = HCI_Packet.from_bytes(packet)
if self.ready or (
hci_packet.hci_packet_type == HCI_EVENT_PACKET
and hci_packet.event_code == HCI_COMMAND_COMPLETE_EVENT
isinstance(hci_packet, HCI_Command_Complete_Event)
and hci_packet.command_opcode == HCI_RESET_COMMAND
):
self.on_hci_packet(hci_packet)
else:
logger.debug('reset not done, ignoring packet from controller')
def on_hci_packet(self, packet):
def on_transport_lost(self):
# Called by the source when the transport has been lost.
if self.pending_response:
self.pending_response.set_exception(TransportLostError('transport lost'))
self.emit('flush')
def on_hci_packet(self, packet: HCI_Packet) -> None:
logger.debug(f'{color("### CONTROLLER -> HOST", "green")}: {packet}')
if self.snooper:
self.snooper.snoop(bytes(packet), Snooper.Direction.CONTROLLER_TO_HOST)
# If the packet is a command, invoke the handler for this packet
if packet.hci_packet_type == HCI_COMMAND_PACKET:
if isinstance(packet, HCI_Command):
self.on_hci_command_packet(packet)
elif packet.hci_packet_type == HCI_EVENT_PACKET:
elif isinstance(packet, HCI_Event):
self.on_hci_event_packet(packet)
elif packet.hci_packet_type == HCI_ACL_DATA_PACKET:
elif isinstance(packet, HCI_AclDataPacket):
self.on_hci_acl_data_packet(packet)
else:
logger.warning(f'!!! unknown packet type {packet.hci_packet_type}')
def on_hci_command_packet(self, command):
def on_hci_command_packet(self, command: HCI_Command) -> None:
logger.warning(f'!!! unexpected command packet: {command}')
def on_hci_event_packet(self, event):
def on_hci_event_packet(self, event: HCI_Event) -> None:
handler_name = f'on_{event.name.lower()}'
handler = getattr(self, handler_name, self.on_hci_event)
handler(event)
def on_hci_acl_data_packet(self, packet):
def on_hci_acl_data_packet(self, packet: HCI_AclDataPacket) -> None:
# Look for the connection to which this data belongs
if connection := self.connections.get(packet.connection_handle):
connection.on_hci_acl_data_packet(packet)
def on_l2cap_pdu(self, connection, cid, pdu):
def on_l2cap_pdu(self, connection: Connection, cid: int, pdu: bytes) -> None:
self.emit('l2cap_pdu', connection.handle, cid, pdu)
def on_command_processed(self, event):
@@ -822,6 +831,10 @@ class Host(AbortableEventEmitter):
f'simple pairing complete for {event.bd_addr}: '
f'status={HCI_Constant.status_name(event.status)}'
)
if event.status == HCI_SUCCESS:
self.emit('classic_pairing', event.bd_addr)
else:
self.emit('classic_pairing_failure', event.bd_addr, event.status)
def on_hci_pin_code_request_event(self, event):
self.emit('pin_code_request', event.bd_addr)

View File

@@ -33,6 +33,7 @@ from typing import (
Union,
Deque,
Iterable,
SupportsBytes,
TYPE_CHECKING,
)
@@ -47,6 +48,7 @@ from .hci import (
if TYPE_CHECKING:
from bumble.device import Connection
from bumble.host import Host
# -----------------------------------------------------------------------------
# Logging
@@ -728,7 +730,7 @@ class Channel(EventEmitter):
def __init__(
self,
manager: 'ChannelManager',
manager: ChannelManager,
connection: Connection,
signaling_cid: int,
psm: int,
@@ -755,13 +757,13 @@ class Channel(EventEmitter):
)
self.state = new_state
def send_pdu(self, pdu) -> None:
def send_pdu(self, pdu: SupportsBytes | bytes) -> None:
self.manager.send_pdu(self.connection, self.destination_cid, pdu)
def send_control_frame(self, frame) -> None:
def send_control_frame(self, frame: L2CAP_Control_Frame) -> None:
self.manager.send_control_frame(self.connection, self.signaling_cid, frame)
async def send_request(self, request) -> bytes:
async def send_request(self, request: SupportsBytes) -> bytes:
# Check that there isn't already a request pending
if self.response:
raise InvalidStateError('request already pending')
@@ -772,7 +774,7 @@ class Channel(EventEmitter):
self.send_pdu(request)
return await self.response
def on_pdu(self, pdu) -> None:
def on_pdu(self, pdu: bytes) -> None:
if self.response:
self.response.set_result(pdu)
self.response = None
@@ -1041,7 +1043,7 @@ class LeConnectionOrientedChannel(EventEmitter):
def __init__(
self,
manager: 'ChannelManager',
manager: ChannelManager,
connection: Connection,
le_psm: int,
source_cid: int,
@@ -1096,10 +1098,10 @@ class LeConnectionOrientedChannel(EventEmitter):
elif new_state == self.DISCONNECTED:
self.emit('close')
def send_pdu(self, pdu) -> None:
def send_pdu(self, pdu: SupportsBytes | bytes) -> None:
self.manager.send_pdu(self.connection, self.destination_cid, pdu)
def send_control_frame(self, frame) -> None:
def send_control_frame(self, frame: L2CAP_Control_Frame) -> None:
self.manager.send_control_frame(self.connection, L2CAP_LE_SIGNALING_CID, frame)
async def connect(self) -> LeConnectionOrientedChannel:
@@ -1154,7 +1156,7 @@ class LeConnectionOrientedChannel(EventEmitter):
if self.state == self.CONNECTED:
self.change_state(self.DISCONNECTED)
def on_pdu(self, pdu) -> None:
def on_pdu(self, pdu: bytes) -> None:
if self.sink is None:
logger.warning('received pdu without a sink')
return
@@ -1384,6 +1386,7 @@ class ChannelManager:
]
le_coc_requests: Dict[int, L2CAP_LE_Credit_Based_Connection_Request]
fixed_channels: Dict[int, Optional[Callable[[int, bytes], Any]]]
_host: Optional[Host]
def __init__(
self,
@@ -1407,11 +1410,12 @@ class ChannelManager:
self.connectionless_mtu = connectionless_mtu
@property
def host(self):
def host(self) -> Host:
assert self._host
return self._host
@host.setter
def host(self, host):
def host(self, host: Host) -> None:
if self._host is not None:
self._host.remove_listener('disconnection', self.on_disconnection)
self._host = host
@@ -1565,7 +1569,7 @@ class ChannelManager:
if connection_handle in self.identifiers:
del self.identifiers[connection_handle]
def send_pdu(self, connection, cid: int, pdu) -> None:
def send_pdu(self, connection, cid: int, pdu: SupportsBytes | bytes) -> None:
pdu_str = pdu.hex() if isinstance(pdu, bytes) else str(pdu)
logger.debug(
f'{color(">>> Sending L2CAP PDU", "blue")} '
@@ -1574,7 +1578,7 @@ class ChannelManager:
)
self.host.send_l2cap_pdu(connection.handle, cid, bytes(pdu))
def on_pdu(self, connection: Connection, cid: int, pdu) -> None:
def on_pdu(self, connection: Connection, cid: int, pdu: bytes) -> None:
if cid in (L2CAP_SIGNALING_CID, L2CAP_LE_SIGNALING_CID):
# Parse the L2CAP payload into a Control Frame object
control_frame = L2CAP_Control_Frame.from_bytes(pdu)
@@ -1596,7 +1600,7 @@ class ChannelManager:
channel.on_pdu(pdu)
def send_control_frame(
self, connection: Connection, cid: int, control_frame
self, connection: Connection, cid: int, control_frame: L2CAP_Control_Frame
) -> None:
logger.debug(
f'{color(">>> Sending L2CAP Signaling Control Frame", "blue")} '
@@ -1605,7 +1609,9 @@ class ChannelManager:
)
self.host.send_l2cap_pdu(connection.handle, cid, bytes(control_frame))
def on_control_frame(self, connection: Connection, cid: int, control_frame) -> None:
def on_control_frame(
self, connection: Connection, cid: int, control_frame: L2CAP_Control_Frame
) -> None:
logger.debug(
f'{color("<<< Received L2CAP Signaling Control Frame", "green")} '
f'on connection [0x{connection.handle:04X}] (CID={cid}) '

View File

@@ -423,6 +423,8 @@ class SecurityService(SecurityServicer):
'pairing': try_set_success,
'connection_authentication': try_set_success,
'connection_encryption_change': on_encryption_change,
'classic_pairing': try_set_success,
'classic_pairing_failure': set_failure('pairing_failure'),
}
# register event handlers

View File

@@ -15,16 +15,22 @@
# -----------------------------------------------------------------------------
# Imports
# -----------------------------------------------------------------------------
from __future__ import annotations
import logging
import asyncio
import enum
from pyee import EventEmitter
from typing import Optional, Tuple, Callable, Dict, Union
from typing import Optional, Tuple, Callable, Dict, Union, TYPE_CHECKING
from . import core, l2cap
from .colors import color
from .core import BT_BR_EDR_TRANSPORT, InvalidStateError, ProtocolError
if TYPE_CHECKING:
from bumble.device import Device, Connection
# -----------------------------------------------------------------------------
# Logging
# -----------------------------------------------------------------------------
@@ -149,9 +155,9 @@ class RFCOMM_Frame:
return RFCOMM_FRAME_TYPE_NAMES[self.type]
@staticmethod
def parse_mcc(data) -> Tuple[int, int, bytes]:
def parse_mcc(data) -> Tuple[int, bool, bytes]:
mcc_type = data[0] >> 2
c_r = (data[0] >> 1) & 1
c_r = bool((data[0] >> 1) & 1)
length = data[1]
if data[1] & 1:
length >>= 1
@@ -192,7 +198,7 @@ class RFCOMM_Frame:
)
@staticmethod
def from_bytes(data: bytes):
def from_bytes(data: bytes) -> RFCOMM_Frame:
# Extract fields
dlci = (data[0] >> 2) & 0x3F
c_r = (data[0] >> 1) & 0x01
@@ -215,7 +221,7 @@ class RFCOMM_Frame:
return frame
def __bytes__(self):
def __bytes__(self) -> bytes:
return (
bytes([self.address, self.control])
+ self.length
@@ -223,7 +229,7 @@ class RFCOMM_Frame:
+ bytes([self.fcs])
)
def __str__(self):
def __str__(self) -> str:
return (
f'{color(self.type_name(), "yellow")}'
f'(c/r={self.c_r},'
@@ -253,7 +259,7 @@ class RFCOMM_MCC_PN:
max_frame_size: int,
max_retransmissions: int,
window_size: int,
):
) -> None:
self.dlci = dlci
self.cl = cl
self.priority = priority
@@ -263,7 +269,7 @@ class RFCOMM_MCC_PN:
self.window_size = window_size
@staticmethod
def from_bytes(data: bytes):
def from_bytes(data: bytes) -> RFCOMM_MCC_PN:
return RFCOMM_MCC_PN(
dlci=data[0],
cl=data[1],
@@ -274,7 +280,7 @@ class RFCOMM_MCC_PN:
window_size=data[7],
)
def __bytes__(self):
def __bytes__(self) -> bytes:
return bytes(
[
self.dlci & 0xFF,
@@ -288,7 +294,7 @@ class RFCOMM_MCC_PN:
]
)
def __str__(self):
def __str__(self) -> str:
return (
f'PN(dlci={self.dlci},'
f'cl={self.cl},'
@@ -309,7 +315,9 @@ class RFCOMM_MCC_MSC:
ic: int
dv: int
def __init__(self, dlci: int, fc: int, rtc: int, rtr: int, ic: int, dv: int):
def __init__(
self, dlci: int, fc: int, rtc: int, rtr: int, ic: int, dv: int
) -> None:
self.dlci = dlci
self.fc = fc
self.rtc = rtc
@@ -318,7 +326,7 @@ class RFCOMM_MCC_MSC:
self.dv = dv
@staticmethod
def from_bytes(data: bytes):
def from_bytes(data: bytes) -> RFCOMM_MCC_MSC:
return RFCOMM_MCC_MSC(
dlci=data[0] >> 2,
fc=data[1] >> 1 & 1,
@@ -328,7 +336,7 @@ class RFCOMM_MCC_MSC:
dv=data[1] >> 7 & 1,
)
def __bytes__(self):
def __bytes__(self) -> bytes:
return bytes(
[
(self.dlci << 2) | 3,
@@ -341,7 +349,7 @@ class RFCOMM_MCC_MSC:
]
)
def __str__(self):
def __str__(self) -> str:
return (
f'MSC(dlci={self.dlci},'
f'fc={self.fc},'
@@ -354,29 +362,24 @@ class RFCOMM_MCC_MSC:
# -----------------------------------------------------------------------------
class DLC(EventEmitter):
# States
INIT = 0x00
CONNECTING = 0x01
CONNECTED = 0x02
DISCONNECTING = 0x03
DISCONNECTED = 0x04
RESET = 0x05
STATE_NAMES = {
INIT: 'INIT',
CONNECTING: 'CONNECTING',
CONNECTED: 'CONNECTED',
DISCONNECTING: 'DISCONNECTING',
DISCONNECTED: 'DISCONNECTED',
RESET: 'RESET',
}
class State(enum.IntEnum):
INIT = 0x00
CONNECTING = 0x01
CONNECTED = 0x02
DISCONNECTING = 0x03
DISCONNECTED = 0x04
RESET = 0x05
connection_result: Optional[asyncio.Future]
sink: Optional[Callable[[bytes], None]]
def __init__(
self, multiplexer, dlci: int, max_frame_size: int, initial_tx_credits: int
):
self,
multiplexer: Multiplexer,
dlci: int,
max_frame_size: int,
initial_tx_credits: int,
) -> None:
super().__init__()
self.multiplexer = multiplexer
self.dlci = dlci
@@ -384,9 +387,9 @@ class DLC(EventEmitter):
self.rx_threshold = self.rx_credits // 2
self.tx_credits = initial_tx_credits
self.tx_buffer = b''
self.state = DLC.INIT
self.state = DLC.State.INIT
self.role = multiplexer.role
self.c_r = 1 if self.role == Multiplexer.INITIATOR else 0
self.c_r = 1 if self.role == Multiplexer.Role.INITIATOR else 0
self.sink = None
self.connection_result = None
@@ -396,14 +399,8 @@ class DLC(EventEmitter):
max_frame_size, self.multiplexer.l2cap_channel.mtu - max_overhead
)
@staticmethod
def state_name(state: int) -> str:
return DLC.STATE_NAMES[state]
def change_state(self, new_state: int) -> None:
logger.debug(
f'{self} state change -> {color(self.state_name(new_state), "magenta")}'
)
def change_state(self, new_state: State) -> None:
logger.debug(f'{self} state change -> {color(new_state.name, "magenta")}')
self.state = new_state
def send_frame(self, frame: RFCOMM_Frame) -> None:
@@ -413,8 +410,8 @@ class DLC(EventEmitter):
handler = getattr(self, f'on_{frame.type_name()}_frame'.lower())
handler(frame)
def on_sabm_frame(self, _frame) -> None:
if self.state != DLC.CONNECTING:
def on_sabm_frame(self, _frame: RFCOMM_Frame) -> None:
if self.state != DLC.State.CONNECTING:
logger.warning(
color('!!! received SABM when not in CONNECTING state', 'red')
)
@@ -430,11 +427,11 @@ class DLC(EventEmitter):
logger.debug(f'>>> MCC MSC Command: {msc}')
self.send_frame(RFCOMM_Frame.uih(c_r=self.c_r, dlci=0, information=mcc))
self.change_state(DLC.CONNECTED)
self.change_state(DLC.State.CONNECTED)
self.emit('open')
def on_ua_frame(self, _frame) -> None:
if self.state != DLC.CONNECTING:
def on_ua_frame(self, _frame: RFCOMM_Frame) -> None:
if self.state != DLC.State.CONNECTING:
logger.warning(
color('!!! received SABM when not in CONNECTING state', 'red')
)
@@ -448,14 +445,14 @@ class DLC(EventEmitter):
logger.debug(f'>>> MCC MSC Command: {msc}')
self.send_frame(RFCOMM_Frame.uih(c_r=self.c_r, dlci=0, information=mcc))
self.change_state(DLC.CONNECTED)
self.change_state(DLC.State.CONNECTED)
self.multiplexer.on_dlc_open_complete(self)
def on_dm_frame(self, frame) -> None:
def on_dm_frame(self, frame: RFCOMM_Frame) -> None:
# TODO: handle all states
pass
def on_disc_frame(self, _frame) -> None:
def on_disc_frame(self, _frame: RFCOMM_Frame) -> None:
# TODO: handle all states
self.send_frame(RFCOMM_Frame.ua(c_r=1 - self.c_r, dlci=self.dlci))
@@ -489,10 +486,10 @@ class DLC(EventEmitter):
# Check if there's anything to send (including credits)
self.process_tx()
def on_ui_frame(self, frame) -> None:
def on_ui_frame(self, frame: RFCOMM_Frame) -> None:
pass
def on_mcc_msc(self, c_r, msc) -> None:
def on_mcc_msc(self, c_r: bool, msc: RFCOMM_MCC_MSC) -> None:
if c_r:
# Command
logger.debug(f'<<< MCC MSC Command: {msc}')
@@ -507,15 +504,15 @@ class DLC(EventEmitter):
logger.debug(f'<<< MCC MSC Response: {msc}')
def connect(self) -> None:
if self.state != DLC.INIT:
if self.state != DLC.State.INIT:
raise InvalidStateError('invalid state')
self.change_state(DLC.CONNECTING)
self.change_state(DLC.State.CONNECTING)
self.connection_result = asyncio.get_running_loop().create_future()
self.send_frame(RFCOMM_Frame.sabm(c_r=self.c_r, dlci=self.dlci))
def accept(self) -> None:
if self.state != DLC.INIT:
if self.state != DLC.State.INIT:
raise InvalidStateError('invalid state')
pn = RFCOMM_MCC_PN(
@@ -530,7 +527,7 @@ class DLC(EventEmitter):
mcc = RFCOMM_Frame.make_mcc(mcc_type=RFCOMM_MCC_PN_TYPE, c_r=0, data=bytes(pn))
logger.debug(f'>>> PN Response: {pn}')
self.send_frame(RFCOMM_Frame.uih(c_r=self.c_r, dlci=0, information=mcc))
self.change_state(DLC.CONNECTING)
self.change_state(DLC.State.CONNECTING)
def rx_credits_needed(self) -> int:
if self.rx_credits <= self.rx_threshold:
@@ -592,34 +589,24 @@ class DLC(EventEmitter):
# TODO
pass
def __str__(self):
return f'DLC(dlci={self.dlci},state={self.state_name(self.state)})'
def __str__(self) -> str:
return f'DLC(dlci={self.dlci},state={self.state.name})'
# -----------------------------------------------------------------------------
class Multiplexer(EventEmitter):
# Roles
INITIATOR = 0x00
RESPONDER = 0x01
class Role(enum.IntEnum):
INITIATOR = 0x00
RESPONDER = 0x01
# States
INIT = 0x00
CONNECTING = 0x01
CONNECTED = 0x02
OPENING = 0x03
DISCONNECTING = 0x04
DISCONNECTED = 0x05
RESET = 0x06
STATE_NAMES = {
INIT: 'INIT',
CONNECTING: 'CONNECTING',
CONNECTED: 'CONNECTED',
OPENING: 'OPENING',
DISCONNECTING: 'DISCONNECTING',
DISCONNECTED: 'DISCONNECTED',
RESET: 'RESET',
}
class State(enum.IntEnum):
INIT = 0x00
CONNECTING = 0x01
CONNECTED = 0x02
OPENING = 0x03
DISCONNECTING = 0x04
DISCONNECTED = 0x05
RESET = 0x06
connection_result: Optional[asyncio.Future]
disconnection_result: Optional[asyncio.Future]
@@ -627,11 +614,11 @@ class Multiplexer(EventEmitter):
acceptor: Optional[Callable[[int], bool]]
dlcs: Dict[int, DLC]
def __init__(self, l2cap_channel: l2cap.Channel, role: int) -> None:
def __init__(self, l2cap_channel: l2cap.Channel, role: Role) -> None:
super().__init__()
self.role = role
self.l2cap_channel = l2cap_channel
self.state = Multiplexer.INIT
self.state = Multiplexer.State.INIT
self.dlcs = {} # DLCs, by DLCI
self.connection_result = None
self.disconnection_result = None
@@ -641,14 +628,8 @@ class Multiplexer(EventEmitter):
# Become a sink for the L2CAP channel
l2cap_channel.sink = self.on_pdu
@staticmethod
def state_name(state: int):
return Multiplexer.STATE_NAMES[state]
def change_state(self, new_state: int) -> None:
logger.debug(
f'{self} state change -> {color(self.state_name(new_state), "cyan")}'
)
def change_state(self, new_state: State) -> None:
logger.debug(f'{self} state change -> {color(new_state.name, "cyan")}')
self.state = new_state
def send_frame(self, frame: RFCOMM_Frame) -> None:
@@ -679,28 +660,28 @@ class Multiplexer(EventEmitter):
handler = getattr(self, f'on_{frame.type_name()}_frame'.lower())
handler(frame)
def on_sabm_frame(self, _frame) -> None:
if self.state != Multiplexer.INIT:
def on_sabm_frame(self, _frame: RFCOMM_Frame) -> None:
if self.state != Multiplexer.State.INIT:
logger.debug('not in INIT state, ignoring SABM')
return
self.change_state(Multiplexer.CONNECTED)
self.change_state(Multiplexer.State.CONNECTED)
self.send_frame(RFCOMM_Frame.ua(c_r=1, dlci=0))
def on_ua_frame(self, _frame) -> None:
if self.state == Multiplexer.CONNECTING:
self.change_state(Multiplexer.CONNECTED)
def on_ua_frame(self, _frame: RFCOMM_Frame) -> None:
if self.state == Multiplexer.State.CONNECTING:
self.change_state(Multiplexer.State.CONNECTED)
if self.connection_result:
self.connection_result.set_result(0)
self.connection_result = None
elif self.state == Multiplexer.DISCONNECTING:
self.change_state(Multiplexer.DISCONNECTED)
elif self.state == Multiplexer.State.DISCONNECTING:
self.change_state(Multiplexer.State.DISCONNECTED)
if self.disconnection_result:
self.disconnection_result.set_result(None)
self.disconnection_result = None
def on_dm_frame(self, _frame) -> None:
if self.state == Multiplexer.OPENING:
self.change_state(Multiplexer.CONNECTED)
def on_dm_frame(self, _frame: RFCOMM_Frame) -> None:
if self.state == Multiplexer.State.OPENING:
self.change_state(Multiplexer.State.CONNECTED)
if self.open_result:
self.open_result.set_exception(
core.ConnectionError(
@@ -713,10 +694,12 @@ class Multiplexer(EventEmitter):
else:
logger.warning(f'unexpected state for DM: {self}')
def on_disc_frame(self, _frame) -> None:
self.change_state(Multiplexer.DISCONNECTED)
def on_disc_frame(self, _frame: RFCOMM_Frame) -> None:
self.change_state(Multiplexer.State.DISCONNECTED)
self.send_frame(
RFCOMM_Frame.ua(c_r=0 if self.role == Multiplexer.INITIATOR else 1, dlci=0)
RFCOMM_Frame.ua(
c_r=0 if self.role == Multiplexer.Role.INITIATOR else 1, dlci=0
)
)
def on_uih_frame(self, frame: RFCOMM_Frame) -> None:
@@ -729,11 +712,11 @@ class Multiplexer(EventEmitter):
mcs = RFCOMM_MCC_MSC.from_bytes(value)
self.on_mcc_msc(c_r, mcs)
def on_ui_frame(self, frame) -> None:
def on_ui_frame(self, frame: RFCOMM_Frame) -> None:
pass
def on_mcc_pn(self, c_r, pn) -> None:
if c_r == 1:
def on_mcc_pn(self, c_r: bool, pn: RFCOMM_MCC_PN) -> None:
if c_r:
# Command
logger.debug(f'<<< PN Command: {pn}')
@@ -764,14 +747,14 @@ class Multiplexer(EventEmitter):
else:
# Response
logger.debug(f'>>> PN Response: {pn}')
if self.state == Multiplexer.OPENING:
if self.state == Multiplexer.State.OPENING:
dlc = DLC(self, pn.dlci, pn.max_frame_size, pn.window_size)
self.dlcs[pn.dlci] = dlc
dlc.connect()
else:
logger.warning('ignoring PN response')
def on_mcc_msc(self, c_r, msc) -> None:
def on_mcc_msc(self, c_r: bool, msc: RFCOMM_MCC_MSC) -> None:
dlc = self.dlcs.get(msc.dlci)
if dlc is None:
logger.warning(f'no dlc for DLCI {msc.dlci}')
@@ -779,30 +762,30 @@ class Multiplexer(EventEmitter):
dlc.on_mcc_msc(c_r, msc)
async def connect(self) -> None:
if self.state != Multiplexer.INIT:
if self.state != Multiplexer.State.INIT:
raise InvalidStateError('invalid state')
self.change_state(Multiplexer.CONNECTING)
self.change_state(Multiplexer.State.CONNECTING)
self.connection_result = asyncio.get_running_loop().create_future()
self.send_frame(RFCOMM_Frame.sabm(c_r=1, dlci=0))
return await self.connection_result
async def disconnect(self) -> None:
if self.state != Multiplexer.CONNECTED:
if self.state != Multiplexer.State.CONNECTED:
return
self.disconnection_result = asyncio.get_running_loop().create_future()
self.change_state(Multiplexer.DISCONNECTING)
self.change_state(Multiplexer.State.DISCONNECTING)
self.send_frame(
RFCOMM_Frame.disc(
c_r=1 if self.role == Multiplexer.INITIATOR else 0, dlci=0
c_r=1 if self.role == Multiplexer.Role.INITIATOR else 0, dlci=0
)
)
await self.disconnection_result
async def open_dlc(self, channel: int) -> DLC:
if self.state != Multiplexer.CONNECTED:
if self.state == Multiplexer.OPENING:
if self.state != Multiplexer.State.CONNECTED:
if self.state == Multiplexer.State.OPENING:
raise InvalidStateError('open already in progress')
raise InvalidStateError('not connected')
@@ -819,10 +802,10 @@ class Multiplexer(EventEmitter):
mcc = RFCOMM_Frame.make_mcc(mcc_type=RFCOMM_MCC_PN_TYPE, c_r=1, data=bytes(pn))
logger.debug(f'>>> Sending MCC: {pn}')
self.open_result = asyncio.get_running_loop().create_future()
self.change_state(Multiplexer.OPENING)
self.change_state(Multiplexer.State.OPENING)
self.send_frame(
RFCOMM_Frame.uih(
c_r=1 if self.role == Multiplexer.INITIATOR else 0,
c_r=1 if self.role == Multiplexer.Role.INITIATOR else 0,
dlci=0,
information=mcc,
)
@@ -831,14 +814,14 @@ class Multiplexer(EventEmitter):
self.open_result = None
return result
def on_dlc_open_complete(self, dlc: DLC):
def on_dlc_open_complete(self, dlc: DLC) -> None:
logger.debug(f'DLC [{dlc.dlci}] open complete')
self.change_state(Multiplexer.CONNECTED)
self.change_state(Multiplexer.State.CONNECTED)
if self.open_result:
self.open_result.set_result(dlc)
def __str__(self):
return f'Multiplexer(state={self.state_name(self.state)})'
def __str__(self) -> str:
return f'Multiplexer(state={self.state.name})'
# -----------------------------------------------------------------------------
@@ -846,7 +829,7 @@ class Client:
multiplexer: Optional[Multiplexer]
l2cap_channel: Optional[l2cap.Channel]
def __init__(self, device, connection) -> None:
def __init__(self, device: Device, connection: Connection) -> None:
self.device = device
self.connection = connection
self.l2cap_channel = None
@@ -864,7 +847,7 @@ class Client:
assert self.l2cap_channel is not None
# Create a mutliplexer to manage DLCs with the server
self.multiplexer = Multiplexer(self.l2cap_channel, Multiplexer.INITIATOR)
self.multiplexer = Multiplexer(self.l2cap_channel, Multiplexer.Role.INITIATOR)
# Connect the multiplexer
await self.multiplexer.connect()
@@ -886,7 +869,7 @@ class Client:
class Server(EventEmitter):
acceptors: Dict[int, Callable[[DLC], None]]
def __init__(self, device) -> None:
def __init__(self, device: Device) -> None:
super().__init__()
self.device = device
self.multiplexer = None
@@ -925,7 +908,7 @@ class Server(EventEmitter):
logger.debug(f'$$$ L2CAP channel open: {l2cap_channel}')
# Create a new multiplexer for the channel
multiplexer = Multiplexer(l2cap_channel, Multiplexer.RESPONDER)
multiplexer = Multiplexer(l2cap_channel, Multiplexer.Role.RESPONDER)
multiplexer.acceptor = self.accept_dlc
multiplexer.on('dlc', self.on_dlc)

View File

@@ -18,13 +18,16 @@
from __future__ import annotations
import logging
import struct
from typing import Dict, List, Type
from typing import Dict, List, Type, Optional, Tuple, Union, NewType, TYPE_CHECKING
from . import core
from . import core, l2cap
from .colors import color
from .core import InvalidStateError
from .hci import HCI_Object, name_or_number, key_with_value
if TYPE_CHECKING:
from .device import Device, Connection
# -----------------------------------------------------------------------------
# Logging
# -----------------------------------------------------------------------------
@@ -466,7 +469,7 @@ class ServiceAttribute:
self.value = value
@staticmethod
def list_from_data_elements(elements):
def list_from_data_elements(elements: List[DataElement]) -> List[ServiceAttribute]:
attribute_list = []
for i in range(0, len(elements) // 2):
attribute_id, attribute_value = elements[2 * i : 2 * (i + 1)]
@@ -478,7 +481,9 @@ class ServiceAttribute:
return attribute_list
@staticmethod
def find_attribute_in_list(attribute_list, attribute_id):
def find_attribute_in_list(
attribute_list: List[ServiceAttribute], attribute_id: int
) -> Optional[DataElement]:
return next(
(
attribute.value
@@ -493,7 +498,7 @@ class ServiceAttribute:
return name_or_number(SDP_ATTRIBUTE_ID_NAMES, id_code)
@staticmethod
def is_uuid_in_value(uuid, value):
def is_uuid_in_value(uuid: core.UUID, value: DataElement) -> bool:
# Find if a uuid matches a value, either directly or recursing into sequences
if value.type == DataElement.UUID:
return value.value == uuid
@@ -547,7 +552,9 @@ class SDP_PDU:
return self
@staticmethod
def parse_service_record_handle_list_preceded_by_count(data, offset):
def parse_service_record_handle_list_preceded_by_count(
data: bytes, offset: int
) -> Tuple[int, List[int]]:
count = struct.unpack_from('>H', data, offset - 2)[0]
handle_list = [
struct.unpack_from('>I', data, offset + x * 4)[0] for x in range(count)
@@ -645,6 +652,10 @@ class SDP_ServiceSearchRequest(SDP_PDU):
See Bluetooth spec @ Vol 3, Part B - 4.5.1 SDP_ServiceSearchRequest PDU
'''
service_search_pattern: DataElement
maximum_service_record_count: int
continuation_state: bytes
# -----------------------------------------------------------------------------
@SDP_PDU.subclass(
@@ -663,6 +674,11 @@ class SDP_ServiceSearchResponse(SDP_PDU):
See Bluetooth spec @ Vol 3, Part B - 4.5.2 SDP_ServiceSearchResponse PDU
'''
service_record_handle_list: List[int]
total_service_record_count: int
current_service_record_count: int
continuation_state: bytes
# -----------------------------------------------------------------------------
@SDP_PDU.subclass(
@@ -678,6 +694,11 @@ class SDP_ServiceAttributeRequest(SDP_PDU):
See Bluetooth spec @ Vol 3, Part B - 4.6.1 SDP_ServiceAttributeRequest PDU
'''
service_record_handle: int
maximum_attribute_byte_count: int
attribute_id_list: DataElement
continuation_state: bytes
# -----------------------------------------------------------------------------
@SDP_PDU.subclass(
@@ -692,6 +713,10 @@ class SDP_ServiceAttributeResponse(SDP_PDU):
See Bluetooth spec @ Vol 3, Part B - 4.6.2 SDP_ServiceAttributeResponse PDU
'''
attribute_list_byte_count: int
attribute_list: bytes
continuation_state: bytes
# -----------------------------------------------------------------------------
@SDP_PDU.subclass(
@@ -707,6 +732,11 @@ class SDP_ServiceSearchAttributeRequest(SDP_PDU):
See Bluetooth spec @ Vol 3, Part B - 4.7.1 SDP_ServiceSearchAttributeRequest PDU
'''
service_search_pattern: DataElement
maximum_attribute_byte_count: int
attribute_id_list: DataElement
continuation_state: bytes
# -----------------------------------------------------------------------------
@SDP_PDU.subclass(
@@ -721,26 +751,34 @@ class SDP_ServiceSearchAttributeResponse(SDP_PDU):
See Bluetooth spec @ Vol 3, Part B - 4.7.2 SDP_ServiceSearchAttributeResponse PDU
'''
attribute_list_byte_count: int
attribute_list: bytes
continuation_state: bytes
# -----------------------------------------------------------------------------
class Client:
def __init__(self, device):
channel: Optional[l2cap.Channel]
def __init__(self, device: Device) -> None:
self.device = device
self.pending_request = None
self.channel = None
async def connect(self, connection):
async def connect(self, connection: Connection) -> None:
result = await self.device.l2cap_channel_manager.connect(connection, SDP_PSM)
self.channel = result
async def disconnect(self):
async def disconnect(self) -> None:
if self.channel:
await self.channel.disconnect()
self.channel = None
async def search_services(self, uuids):
async def search_services(self, uuids: List[core.UUID]) -> List[int]:
if self.pending_request is not None:
raise InvalidStateError('request already pending')
if self.channel is None:
raise InvalidStateError('L2CAP not connected')
service_search_pattern = DataElement.sequence(
[DataElement.uuid(uuid) for uuid in uuids]
@@ -770,9 +808,13 @@ class Client:
return service_record_handle_list
async def search_attributes(self, uuids, attribute_ids):
async def search_attributes(
self, uuids: List[core.UUID], attribute_ids: List[Union[int, Tuple[int, int]]]
) -> List[List[ServiceAttribute]]:
if self.pending_request is not None:
raise InvalidStateError('request already pending')
if self.channel is None:
raise InvalidStateError('L2CAP not connected')
service_search_pattern = DataElement.sequence(
[DataElement.uuid(uuid) for uuid in uuids]
@@ -823,9 +865,15 @@ class Client:
if sequence.type == DataElement.SEQUENCE
]
async def get_attributes(self, service_record_handle, attribute_ids):
async def get_attributes(
self,
service_record_handle: int,
attribute_ids: List[Union[int, Tuple[int, int]]],
) -> List[ServiceAttribute]:
if self.pending_request is not None:
raise InvalidStateError('request already pending')
if self.channel is None:
raise InvalidStateError('L2CAP not connected')
attribute_id_list = DataElement.sequence(
[
@@ -873,21 +921,25 @@ class Client:
# -----------------------------------------------------------------------------
class Server:
CONTINUATION_STATE = bytes([0x01, 0x43])
channel: Optional[l2cap.Channel]
Service = NewType('Service', List[ServiceAttribute])
service_records: Dict[int, Service]
current_response: Union[None, bytes, Tuple[int, List[int]]]
def __init__(self, device):
def __init__(self, device: Device) -> None:
self.device = device
self.service_records = {} # Service records maps, by record handle
self.channel = None
self.current_response = None
def register(self, l2cap_channel_manager):
def register(self, l2cap_channel_manager: l2cap.ChannelManager) -> None:
l2cap_channel_manager.register_server(SDP_PSM, self.on_connection)
def send_response(self, response):
logger.debug(f'{color(">>> Sending SDP Response", "blue")}: {response}')
self.channel.send_pdu(response)
def match_services(self, search_pattern):
def match_services(self, search_pattern: DataElement) -> Dict[int, Service]:
# Find the services for which the attributes in the pattern is a subset of the
# service's attribute values (NOTE: the value search recurses into sequences)
matching_services = {}
@@ -957,7 +1009,9 @@ class Server:
return (payload, continuation_state)
@staticmethod
def get_service_attributes(service, attribute_ids):
def get_service_attributes(
service: Service, attribute_ids: List[DataElement]
) -> DataElement:
attributes = []
for attribute_id in attribute_ids:
if attribute_id.value_size == 4:
@@ -982,10 +1036,10 @@ class Server:
return attribute_list
def on_sdp_service_search_request(self, request):
def on_sdp_service_search_request(self, request: SDP_ServiceSearchRequest) -> None:
# Check if this is a continuation
if len(request.continuation_state) > 1:
if not self.current_response:
if self.current_response is None:
self.send_response(
SDP_ErrorResponse(
transaction_id=request.transaction_id,
@@ -1014,6 +1068,7 @@ class Server:
)
# Respond, keeping any unsent handles for later
assert isinstance(self.current_response, tuple)
service_record_handles = self.current_response[1][
: request.maximum_service_record_count
]
@@ -1037,10 +1092,12 @@ class Server:
)
)
def on_sdp_service_attribute_request(self, request):
def on_sdp_service_attribute_request(
self, request: SDP_ServiceAttributeRequest
) -> None:
# Check if this is a continuation
if len(request.continuation_state) > 1:
if not self.current_response:
if self.current_response is None:
self.send_response(
SDP_ErrorResponse(
transaction_id=request.transaction_id,
@@ -1073,22 +1130,24 @@ class Server:
self.current_response = bytes(attribute_list)
# Respond, keeping any pending chunks for later
attribute_list, continuation_state = self.get_next_response_payload(
attribute_list_response, continuation_state = self.get_next_response_payload(
request.maximum_attribute_byte_count
)
self.send_response(
SDP_ServiceAttributeResponse(
transaction_id=request.transaction_id,
attribute_list_byte_count=len(attribute_list),
attribute_list_byte_count=len(attribute_list_response),
attribute_list=attribute_list,
continuation_state=continuation_state,
)
)
def on_sdp_service_search_attribute_request(self, request):
def on_sdp_service_search_attribute_request(
self, request: SDP_ServiceSearchAttributeRequest
) -> None:
# Check if this is a continuation
if len(request.continuation_state) > 1:
if not self.current_response:
if self.current_response is None:
self.send_response(
SDP_ErrorResponse(
transaction_id=request.transaction_id,
@@ -1118,13 +1177,13 @@ class Server:
self.current_response = bytes(attribute_lists)
# Respond, keeping any pending chunks for later
attribute_lists, continuation_state = self.get_next_response_payload(
attribute_lists_response, continuation_state = self.get_next_response_payload(
request.maximum_attribute_byte_count
)
self.send_response(
SDP_ServiceSearchAttributeResponse(
transaction_id=request.transaction_id,
attribute_lists_byte_count=len(attribute_lists),
attribute_lists_byte_count=len(attribute_lists_response),
attribute_lists=attribute_lists,
continuation_state=continuation_state,
)

View File

@@ -1832,8 +1832,9 @@ class Manager(EventEmitter):
) -> None:
# Store the keys in the key store
if self.device.keystore and identity_address is not None:
await self.device.keystore.update(str(identity_address), keys)
await self.device.refresh_resolving_list()
self.device.abort_on(
'flush', self.device.update_keys(str(identity_address), keys)
)
# Notify the device
self.device.on_pairing(session.connection, identity_address, keys, session.sc)

View File

@@ -69,6 +69,7 @@ async def open_transport(name: str) -> Transport:
* usb
* pyusb
* android-emulator
* android-netsim
"""
return _wrap_transport(await _open_transport(name))

View File

@@ -44,11 +44,18 @@ HCI_PACKET_INFO = {
}
# -----------------------------------------------------------------------------
class TransportLostError(Exception):
"""
The Transport has been lost/disconnected.
"""
# -----------------------------------------------------------------------------
class PacketPump:
'''
Pump HCI packets from a reader to a sink
'''
"""
Pump HCI packets from a reader to a sink.
"""
def __init__(self, reader, sink):
self.reader = reader
@@ -68,10 +75,10 @@ class PacketPump:
# -----------------------------------------------------------------------------
class PacketParser:
'''
"""
In-line parser that accepts data and emits 'on_packet' when a full packet has been
parsed
'''
parsed.
"""
# pylint: disable=attribute-defined-outside-init
@@ -134,9 +141,9 @@ class PacketParser:
# -----------------------------------------------------------------------------
class PacketReader:
'''
Reader that reads HCI packets from a sync source
'''
"""
Reader that reads HCI packets from a sync source.
"""
def __init__(self, source):
self.source = source
@@ -169,9 +176,9 @@ class PacketReader:
# -----------------------------------------------------------------------------
class AsyncPacketReader:
'''
Reader that reads HCI packets from an async source
'''
"""
Reader that reads HCI packets from an async source.
"""
def __init__(self, source):
self.source = source
@@ -198,9 +205,9 @@ class AsyncPacketReader:
# -----------------------------------------------------------------------------
class AsyncPipeSink:
'''
Sink that forwards packets asynchronously to another sink
'''
"""
Sink that forwards packets asynchronously to another sink.
"""
def __init__(self, sink):
self.sink = sink
@@ -216,6 +223,9 @@ class ParserSource:
Base class designed to be subclassed by transport-specific source classes
"""
terminated: asyncio.Future
parser: PacketParser
def __init__(self):
self.parser = PacketParser()
self.terminated = asyncio.get_running_loop().create_future()
@@ -223,7 +233,19 @@ class ParserSource:
def set_packet_sink(self, sink):
self.parser.set_packet_sink(sink)
def on_transport_lost(self):
self.terminated.set_result(None)
if self.parser.sink:
try:
self.parser.sink.on_transport_lost()
except AttributeError:
pass
async def wait_for_termination(self):
"""
Convenience method for backward compatibility. Prefer using the `terminated`
attribute instead.
"""
return await self.terminated
def close(self):

View File

@@ -39,7 +39,7 @@ async def open_tcp_client_transport(spec):
class TcpPacketSource(StreamPacketSource):
def connection_lost(self, exc):
logger.debug(f'connection lost: {exc}')
self.terminated.set_result(exc)
self.on_transport_lost()
remote_host, remote_port = spec.split(':')
tcp_transport, packet_source = await asyncio.get_running_loop().create_connection(

View File

@@ -43,7 +43,7 @@ async def open_ws_server_transport(spec):
def __init__(self):
source = ParserSource()
sink = PumpedPacketSink(self.send_packet)
self.connection = asyncio.get_running_loop().create_future()
self.connection = None
self.server = None
super().__init__(source, sink)
@@ -63,7 +63,7 @@ async def open_ws_server_transport(spec):
f'new connection on {connection.local_address} '
f'from {connection.remote_address}'
)
self.connection.set_result(connection)
self.connection = connection
# pylint: disable=no-member
try:
async for packet in connection:
@@ -74,12 +74,14 @@ async def open_ws_server_transport(spec):
except websockets.WebSocketException as error:
logger.debug(f'exception while receiving packet: {error}')
# Wait for a new connection
self.connection = asyncio.get_running_loop().create_future()
# We're now disconnected
self.connection = None
async def send_packet(self, packet):
connection = await self.connection
return await connection.send(packet)
if self.connection is None:
logger.debug('no connection, dropping packet')
return
return await self.connection.send(packet)
local_host, local_port = spec.split(':')
transport = WsServerTransport()

View File

@@ -3,7 +3,7 @@ channels:
- defaults
- conda-forge
dependencies:
- pip=20
- pip=23
- python=3.8
- pip:
- --editable .[development,documentation,test]

View File

@@ -32,17 +32,17 @@ package_dir =
include_package_data = True
install_requires =
aiohttp ~= 3.8; platform_system!='Emscripten'
appdirs >= 1.4
bt-test-interfaces >= 0.0.2
appdirs >= 1.4; platform_system!='Emscripten'
bt-test-interfaces >= 0.0.2; platform_system!='Emscripten'
click == 8.1.3; platform_system!='Emscripten'
cryptography == 35; platform_system!='Emscripten'
grpcio == 1.51.1; platform_system!='Emscripten'
humanize >= 4.6.0
cryptography == 39; platform_system!='Emscripten'
grpcio == 1.57.0; platform_system!='Emscripten'
humanize >= 4.6.0; 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'
prettytable >= 3.6.0
protobuf >= 3.12.4
prettytable >= 3.6.0; platform_system!='Emscripten'
protobuf >= 3.12.4; platform_system!='Emscripten'
pyee >= 8.2.2
pyserial-asyncio >= 0.5; platform_system!='Emscripten'
pyserial >= 3.5; platform_system!='Emscripten'
@@ -81,7 +81,7 @@ test =
coverage >= 6.4
development =
black == 22.10
grpcio-tools >= 1.51.1
grpcio-tools >= 1.57.0
invoke >= 1.7.3
mypy == 1.2.0
nox >= 2022

View File

@@ -1,28 +0,0 @@
<!DOCTYPE html>
<html>
<head>
<title>Audio WAV Player</title>
</head>
<body>
<h1>Audio WAV Player</h1>
<audio id="audioPlayer" controls>
<source src="" type="audio/wav">
</audio>
<script>
const audioPlayer = document.getElementById('audioPlayer');
const ws = new WebSocket('ws://localhost:8080');
let mediaSource = new MediaSource();
audioPlayer.src = URL.createObjectURL(mediaSource);
mediaSource.addEventListener('sourceopen', function(event) {
const sourceBuffer = mediaSource.addSourceBuffer('audio/wav');
ws.onmessage = function(event) {
sourceBuffer.appendBuffer(event.data);
};
});
</script>
</body>
</html>

View File

@@ -177,3 +177,33 @@ project_tasks.add_task(lint)
project_tasks.add_task(format_code, name="format")
project_tasks.add_task(check_types, name="check-types")
project_tasks.add_task(pre_commit)
# -----------------------------------------------------------------------------
# Web
# -----------------------------------------------------------------------------
web_tasks = Collection()
ns.add_collection(web_tasks, name="web")
# -----------------------------------------------------------------------------
@task
def serve(ctx, port=8000):
"""
Run a simple HTTP server for the examples under the `web` directory.
"""
import http.server
address = ("", port)
class Handler(http.server.SimpleHTTPRequestHandler):
def __init__(self, *args, **kwargs):
super().__init__(*args, directory="web", **kwargs)
server = http.server.HTTPServer(address, Handler)
print(f"Now serving on port {port} 🕸️")
server.serve_forever()
# -----------------------------------------------------------------------------
web_tasks.add_task(serve)

13
tests/__init__.py Normal file
View File

@@ -0,0 +1,13 @@
# Copyright 2023 Google LLC
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# https://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.

View File

@@ -21,13 +21,9 @@ import os
import random
import pytest
from bumble.controller import Controller
from bumble.link import LocalLink
from bumble.device import Device
from bumble.host import Host
from bumble.transport import AsyncPipeSink
from bumble.core import ProtocolError
from bumble.l2cap import L2CAP_Connection_Request
from .test_utils import TwoDevices
# -----------------------------------------------------------------------------
@@ -37,60 +33,6 @@ logger = logging.getLogger(__name__)
# -----------------------------------------------------------------------------
class TwoDevices:
def __init__(self):
self.connections = [None, None]
self.link = LocalLink()
self.controllers = [
Controller('C1', link=self.link),
Controller('C2', link=self.link),
]
self.devices = [
Device(
address='F0:F1:F2:F3:F4:F5',
host=Host(self.controllers[0], AsyncPipeSink(self.controllers[0])),
),
Device(
address='F5:F4:F3:F2:F1:F0',
host=Host(self.controllers[1], AsyncPipeSink(self.controllers[1])),
),
]
self.paired = [None, None]
def on_connection(self, which, connection):
self.connections[which] = connection
def on_paired(self, which, keys):
self.paired[which] = keys
# -----------------------------------------------------------------------------
async def setup_connection():
# Create two devices, each with a controller, attached to the same link
two_devices = TwoDevices()
# Attach listeners
two_devices.devices[0].on(
'connection', lambda connection: two_devices.on_connection(0, connection)
)
two_devices.devices[1].on(
'connection', lambda connection: two_devices.on_connection(1, connection)
)
# Start
await two_devices.devices[0].power_on()
await two_devices.devices[1].power_on()
# Connect the two devices
await two_devices.devices[0].connect(two_devices.devices[1].random_address)
# Check the post conditions
assert two_devices.connections[0] is not None
assert two_devices.connections[1] is not None
return two_devices
# -----------------------------------------------------------------------------
@@ -132,7 +74,8 @@ def test_helpers():
# -----------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_basic_connection():
devices = await setup_connection()
devices = TwoDevices()
await devices.setup_connection()
psm = 1234
# Check that if there's no one listening, we can't connect
@@ -184,7 +127,8 @@ async def test_basic_connection():
# -----------------------------------------------------------------------------
async def transfer_payload(max_credits, mtu, mps):
devices = await setup_connection()
devices = TwoDevices()
await devices.setup_connection()
received = []
@@ -226,7 +170,8 @@ async def test_transfer():
# -----------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_bidirectional_transfer():
devices = await setup_connection()
devices = TwoDevices()
await devices.setup_connection()
client_received = []
server_received = []

View File

@@ -15,15 +15,30 @@
# -----------------------------------------------------------------------------
# Imports
# -----------------------------------------------------------------------------
from bumble.core import UUID
from bumble.sdp import DataElement
import asyncio
import logging
import os
from bumble.core import UUID, BT_L2CAP_PROTOCOL_ID, BT_RFCOMM_PROTOCOL_ID
from bumble.sdp import (
DataElement,
ServiceAttribute,
Client,
Server,
SDP_SERVICE_RECORD_HANDLE_ATTRIBUTE_ID,
SDP_BROWSE_GROUP_LIST_ATTRIBUTE_ID,
SDP_PUBLIC_BROWSE_ROOT,
SDP_SERVICE_CLASS_ID_LIST_ATTRIBUTE_ID,
SDP_PROTOCOL_DESCRIPTOR_LIST_ATTRIBUTE_ID,
)
from .test_utils import TwoDevices
# -----------------------------------------------------------------------------
# pylint: disable=invalid-name
# -----------------------------------------------------------------------------
# -----------------------------------------------------------------------------
def basic_check(x):
def basic_check(x: DataElement) -> None:
serialized = bytes(x)
if len(serialized) < 500:
print('Original:', x)
@@ -41,7 +56,7 @@ def basic_check(x):
# -----------------------------------------------------------------------------
def test_data_elements():
def test_data_elements() -> None:
e = DataElement(DataElement.NIL, None)
basic_check(e)
@@ -157,5 +172,108 @@ def test_data_elements():
# -----------------------------------------------------------------------------
if __name__ == '__main__':
def sdp_records():
return {
0x00010001: [
ServiceAttribute(
SDP_SERVICE_RECORD_HANDLE_ATTRIBUTE_ID,
DataElement.unsigned_integer_32(0x00010001),
),
ServiceAttribute(
SDP_BROWSE_GROUP_LIST_ATTRIBUTE_ID,
DataElement.sequence([DataElement.uuid(SDP_PUBLIC_BROWSE_ROOT)]),
),
ServiceAttribute(
SDP_SERVICE_CLASS_ID_LIST_ATTRIBUTE_ID,
DataElement.sequence(
[DataElement.uuid(UUID('E6D55659-C8B4-4B85-96BB-B1143AF6D3AE'))]
),
),
ServiceAttribute(
SDP_PROTOCOL_DESCRIPTOR_LIST_ATTRIBUTE_ID,
DataElement.sequence(
[
DataElement.sequence([DataElement.uuid(BT_L2CAP_PROTOCOL_ID)]),
]
),
),
]
}
# -----------------------------------------------------------------------------
async def test_service_search():
# Setup connections
devices = TwoDevices()
await devices.setup_connection()
assert devices.connections[0]
assert devices.connections[1]
# Register SDP service
devices.devices[0].sdp_server.service_records.update(sdp_records())
# Search for service
client = Client(devices.devices[1])
await client.connect(devices.connections[1])
services = await client.search_services(
[UUID('E6D55659-C8B4-4B85-96BB-B1143AF6D3AE')]
)
# Then
assert services[0] == 0x00010001
# -----------------------------------------------------------------------------
async def test_service_attribute():
# Setup connections
devices = TwoDevices()
await devices.setup_connection()
# Register SDP service
devices.devices[0].sdp_server.service_records.update(sdp_records())
# Search for service
client = Client(devices.devices[1])
await client.connect(devices.connections[1])
attributes = await client.get_attributes(
0x00010001, [SDP_SERVICE_RECORD_HANDLE_ATTRIBUTE_ID]
)
# Then
assert attributes[0].value.value == sdp_records()[0x00010001][0].value.value
# -----------------------------------------------------------------------------
async def test_service_search_attribute():
# Setup connections
devices = TwoDevices()
await devices.setup_connection()
# Register SDP service
devices.devices[0].sdp_server.service_records.update(sdp_records())
# Search for service
client = Client(devices.devices[1])
await client.connect(devices.connections[1])
attributes = await client.search_attributes(
[UUID('E6D55659-C8B4-4B85-96BB-B1143AF6D3AE')], [(0x0000FFFF, 8)]
)
# Then
for expect, actual in zip(attributes, sdp_records().values()):
assert expect.id == actual.id
assert expect.value == actual.value
# -----------------------------------------------------------------------------
async def run():
test_data_elements()
await test_service_attribute()
await test_service_search()
await test_service_search_attribute()
# -----------------------------------------------------------------------------
if __name__ == '__main__':
logging.basicConfig(level=os.environ.get('BUMBLE_LOGLEVEL', 'INFO').upper())
asyncio.run(run())

73
tests/test_utils.py Normal file
View File

@@ -0,0 +1,73 @@
# Copyright 2023 Google LLC
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# https://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
from typing import List, Optional
from bumble.controller import Controller
from bumble.link import LocalLink
from bumble.device import Device, Connection
from bumble.host import Host
from bumble.transport import AsyncPipeSink
from bumble.hci import Address
class TwoDevices:
connections: List[Optional[Connection]]
def __init__(self) -> None:
self.connections = [None, None]
self.link = LocalLink()
self.controllers = [
Controller('C1', link=self.link),
Controller('C2', link=self.link),
]
self.devices = [
Device(
address=Address('F0:F1:F2:F3:F4:F5'),
host=Host(self.controllers[0], AsyncPipeSink(self.controllers[0])),
),
Device(
address=Address('F5:F4:F3:F2:F1:F0'),
host=Host(self.controllers[1], AsyncPipeSink(self.controllers[1])),
),
]
self.paired = [None, None]
def on_connection(self, which, connection):
self.connections[which] = connection
def on_paired(self, which, keys):
self.paired[which] = keys
async def setup_connection(self) -> None:
# Attach listeners
self.devices[0].on(
'connection', lambda connection: self.on_connection(0, connection)
)
self.devices[1].on(
'connection', lambda connection: self.on_connection(1, connection)
)
# Start
await self.devices[0].power_on()
await self.devices[1].power_on()
# Connect the two devices
await self.devices[0].connect(self.devices[1].random_address)
# Check the post conditions
assert self.connections[0] is not None
assert self.connections[1] is not None

48
web/README.md Normal file
View File

@@ -0,0 +1,48 @@
Bumble For Web Browsers
=======================
Early prototype the consists of running the Bumble stack in a web browser
environment, using [pyodide](https://pyodide.org/)
Two examples are included here:
* scanner - a simple scanner
* speaker - a pure-web-based version of the Speaker app
Both examples rely on the shared code in `bumble.js`.
Running The Examples
--------------------
To run the examples, you will need an HTTP server to serve the HTML and JS files, and
and a WebSocket server serving an HCI transport.
For HCI over WebSocket, recent versions of the `netsim` virtual controller support it,
or you may use the Bumble HCI Bridge app to bridge a WebSocket server to a virtual
controller using some other transport (ex: `python apps/hci_bridge.py ws-server:_:9999 usb:0`).
For HTTP, start an HTTP server with the `web` directory as its
root. You can use the invoke task `inv web.serve` for convenience.
In a browser, open either `scanner/scanner.html` or `speaker/speaker.html`.
You can pass optional query parameters:
* `package` may be set to point to a local build of Bumble (`.whl` files).
The filename must be URL-encoded of course, and must be located under
the `web` directory (the HTTP server won't serve files not under its
root directory).
* `hci` may be set to specify a non-default WebSocket URL to use as the HCI
transport (the default is: `"ws://localhost:9922/hci`). This also needs
to be URL-encoded.
Example:
With a local HTTP server running on port 8000, to run the `scanner` example
with a locally-built Bumble package `../bumble-0.0.163.dev5+g6f832b6.d20230812-py3-none-any.whl`
(assuming that `bumble-0.0.163.dev5+g6f832b6.d20230812-py3-none-any.whl` exists under the `web`
directory and the HCI WebSocket transport at `ws://localhost:9999/hci`, the URL with the
URL-encoded query parameters would be:
`http://localhost:8000/scanner/scanner.html?hci=ws%3A%2F%2Flocalhost%3A9999%2Fhci&package=..%2Fbumble-0.0.163.dev5%2Bg6f832b6.d20230812-py3-none-any.whl`
NOTE: to get a local build of the Bumble package, use `inv build`, the built `.whl` file can be found in the `dist` directory.
Make a copy of the built `.whl` file in the `web` directory.

92
web/bumble.js Normal file
View File

@@ -0,0 +1,92 @@
function bufferToHex(buffer) {
return [...new Uint8Array(buffer)].map(x => x.toString(16).padStart(2, '0')).join('');
}
class PacketSource {
constructor(pyodide) {
this.parser = pyodide.runPython(`
from bumble.transport.common import PacketParser
class ProxiedPacketParser(PacketParser):
def feed_data(self, js_data):
super().feed_data(bytes(js_data.to_py()))
ProxiedPacketParser()
`);
}
set_packet_sink(sink) {
this.parser.set_packet_sink(sink);
}
data_received(data) {
console.log(`HCI[controller->host]: ${bufferToHex(data)}`);
this.parser.feed_data(data);
}
}
class PacketSink {
constructor(writer) {
this.writer = writer;
}
on_packet(packet) {
const buffer = packet.toJs({create_proxies : false});
packet.destroy();
console.log(`HCI[host->controller]: ${bufferToHex(buffer)}`);
// TODO: create an async queue here instead of blindly calling write without awaiting
this.writer(buffer);
}
}
export async function connectWebSocketTransport(pyodide, hciWsUrl) {
return new Promise((resolve, reject) => {
let resolved = false;
let ws = new WebSocket(hciWsUrl);
ws.binaryType = "arraybuffer";
ws.onopen = () => {
console.log("WebSocket open");
resolve({
packet_source,
packet_sink
});
resolved = true;
}
ws.onclose = () => {
console.log("WebSocket close");
if (!resolved) {
reject(`Failed to connect to ${hciWsUrl}`)
}
}
ws.onmessage = (event) => {
packet_source.data_received(event.data);
}
const packet_source = new PacketSource(pyodide);
const packet_sink = new PacketSink((packet) => ws.send(packet));
})
}
export async function loadBumble(pyodide, bumblePackage) {
// Load the Bumble module
await pyodide.loadPackage("micropip");
await pyodide.runPythonAsync(`
import micropip
await micropip.install("cryptography")
await micropip.install("${bumblePackage}")
package_list = micropip.list()
print(package_list)
`)
// Mount a filesystem so that we can persist data like the Key Store
let mountDir = "/bumble";
pyodide.FS.mkdir(mountDir);
pyodide.FS.mount(pyodide.FS.filesystems.IDBFS, { root: "." }, mountDir);
// Sync previously persisted filesystem data into memory
pyodide.FS.syncfs(true, () => {
console.log("FS synced in")
});
}

View File

@@ -1,131 +0,0 @@
<html>
<head>
<script src="https://cdn.jsdelivr.net/pyodide/v0.19.1/full/pyodide.js"></script>
</head>
<body>
<button onclick="runUSB()">USB</button>
<button onclick="runSerial()">Serial</button>
<br />
<br />
<div>Output:</div>
<textarea id="output" style="width: 100%;" rows="30" disabled></textarea>
<script>
function bufferToHex(buffer) {
return [...new Uint8Array(buffer)].map(x => x.toString(16).padStart(2, '0')).join('');
}
const output = document.getElementById("output");
const code = document.getElementById("code");
function addToOutput(s) {
output.value += s + "\n";
}
output.value = "Initializing...\n";
async function main() {
let pyodide = await loadPyodide({
indexURL: "https://cdn.jsdelivr.net/pyodide/v0.19.1/full/",
})
output.value += "Ready!\n"
return pyodide;
}
let pyodideReadyPromise = main();
async function readLoop(port, packet_source) {
const reader = port.readable.getReader()
try {
while (true) {
console.log('@@@ Reading...')
const { done, value } = await reader.read()
if (done) {
console.log("--- DONE!")
break
}
console.log('@@@ Serial data:', bufferToHex(value))
if (packet_source.delegate !== undefined) {
packet_source.delegate.data_received(value)
} else {
console.warn('@@@ delegate not set yet, dropping data')
}
}
} catch (error) {
console.error(error)
} finally {
reader.releaseLock()
}
}
async function runUSB() {
const device = await navigator.usb.requestDevice({
filters: [
{
classCode: 0xE0,
subclassCode: 0x01
}
]
});
if (device.configuration === null) {
await device.selectConfiguration(1);
}
await device.claimInterface(0)
}
async function runSerial() {
const ports = await navigator.serial.getPorts()
console.log('Paired ports:', ports)
const port = await navigator.serial.requestPort()
await port.open({ baudRate: 1000000 })
const writer = port.writable.getWriter()
}
async function run() {
let pyodide = await pyodideReadyPromise;
try {
const script = await(await fetch('scanner.py')).text()
await pyodide.loadPackage('micropip')
await pyodide.runPythonAsync(`
import micropip
await micropip.install('../dist/bumble-0.0.36.dev0+g3adbfe7.d20210807-py3-none-any.whl')
`)
let output = await pyodide.runPythonAsync(script)
addToOutput(output)
const pythonMain = pyodide.globals.get('main')
const packet_source = {}
const packet_sink = {
on_packet: (packet) => {
// Variant A, with the conversion done in Javascript
const buffer = packet.toJs()
console.log(`$$$ on_packet: ${bufferToHex(buffer)}`)
// TODO: create an sync queue here instead of blindly calling write without awaiting
/*await*/ writer.write(buffer)
packet.destroy()
// Variant B, with the conversion `to_js` done at the Python layer
// console.log(`$$$ on_packet: ${bufferToHex(packet)}`)
// /*await*/ writer.write(packet)
}
}
serialLooper = readLoop(port, packet_source)
pythonResult = await pythonMain(packet_source, packet_sink)
console.log(pythonResult)
serialResult = await serialLooper
writer.releaseLock()
await port.close()
console.log('### done')
} catch (err) {
addToOutput(err);
}
}
</script>
</body>
</html>

129
web/scanner/scanner.html Normal file
View File

@@ -0,0 +1,129 @@
<html>
<head>
<script src="https://cdn.jsdelivr.net/pyodide/v0.23.2/full/pyodide.js"></script>
<style>
body {
font-family: monospace;
}
table, th, td {
padding: 2px;
white-space: pre;
border: 1px solid black;
border-collapse: collapse;
}
</style>
</head>
<body>
<button id="connectButton" disabled>Connect</button>
<br />
<br />
<div>Log Output</div><br>
<textarea id="output" style="width: 100%;" rows="10" disabled></textarea>
<div id="scanTableContainer"><table></table></div>
<script type="module">
import { loadBumble, connectWebSocketTransport } from "../bumble.js"
let pyodide;
let output;
function logToOutput(s) {
output.value += s + "\n";
console.log(s);
}
async function run() {
const params = (new URL(document.location)).searchParams;
const hciWsUrl = params.get("hci") || "ws://localhost:9922/hci";
try {
// Create a WebSocket HCI transport
let transport
try {
transport = await connectWebSocketTransport(pyodide, hciWsUrl);
} catch (error) {
logToOutput(error);
return;
}
// Run the scanner example
const script = await (await fetch("scanner.py")).text();
await pyodide.runPythonAsync(script);
const pythonMain = pyodide.globals.get("main");
logToOutput("Starting scanner...");
await pythonMain(transport.packet_source, transport.packet_sink, onScanUpdate);
logToOutput("Scanner running");
} catch (err) {
logToOutput(err);
}
}
function onScanUpdate(scanEntries) {
scanEntries = scanEntries.toJs();
const scanTable = document.createElement("table");
const tableHeader = document.createElement("tr");
for (const name of ["Address", "Address Type", "RSSI", "Data"]) {
const header = document.createElement("th");
header.appendChild(document.createTextNode(name));
tableHeader.appendChild(header);
}
scanTable.appendChild(tableHeader);
scanEntries.forEach(entry => {
const row = document.createElement("tr");
const addressCell = document.createElement("td");
addressCell.appendChild(document.createTextNode(entry.address));
row.appendChild(addressCell);
const addressTypeCell = document.createElement("td");
addressTypeCell.appendChild(document.createTextNode(entry.address_type));
row.appendChild(addressTypeCell);
const rssiCell = document.createElement("td");
rssiCell.appendChild(document.createTextNode(entry.rssi));
row.appendChild(rssiCell);
const dataCell = document.createElement("td");
dataCell.appendChild(document.createTextNode(entry.data));
row.appendChild(dataCell);
scanTable.appendChild(row);
});
const scanTableContainer = document.getElementById("scanTableContainer");
scanTableContainer.replaceChild(scanTable, scanTableContainer.firstChild);
return true;
}
async function main() {
output = document.getElementById("output");
// Load pyodide
logToOutput("Loading Pyodide");
pyodide = await loadPyodide();
// Load Bumble
logToOutput("Loading Bumble");
const params = (new URL(document.location)).searchParams;
const bumblePackage = params.get("package") || "bumble";
await loadBumble(pyodide, bumblePackage);
logToOutput("Ready!")
// Enable the Connect button
const connectButton = document.getElementById("connectButton");
connectButton.disabled = false
connectButton.addEventListener("click", run)
}
main();
</script>
</body>
</html>

View File

@@ -15,50 +15,38 @@
# -----------------------------------------------------------------------------
# Imports
# -----------------------------------------------------------------------------
import time
from bumble.device import Device
from bumble.transport.common import PacketParser
# -----------------------------------------------------------------------------
class ScanEntry:
def __init__(self, advertisement):
self.address = str(advertisement.address).replace("/P", "")
self.address_type = ('Public', 'Random', 'Public Identity', 'Random Identity')[
advertisement.address.address_type
]
self.rssi = advertisement.rssi
self.data = advertisement.data.to_string("\n")
# -----------------------------------------------------------------------------
class ScannerListener(Device.Listener):
def __init__(self, callback):
self.callback = callback
self.entries = {}
def on_advertisement(self, advertisement):
address_type_string = ('P', 'R', 'PI', 'RI')[advertisement.address.address_type]
print(
f'>>> {advertisement.address} [{address_type_string}]: RSSI={advertisement.rssi}, {advertisement.ad_data}'
)
class HciSource:
def __init__(self, host_source):
self.parser = PacketParser()
host_source.delegate = self
def set_packet_sink(self, sink):
self.parser.set_packet_sink(sink)
# host source delegation
def data_received(self, data):
print('*** DATA from JS:', data)
buffer = bytes(data.to_py())
self.parser.feed_data(buffer)
# class HciSink:
# def __init__(self, host_sink):
# self.host_sink = host_sink
# def on_packet(self, packet):
# print(f'>>> PACKET from Python: {packet}')
# self.host_sink.on_packet(packet)
self.entries[advertisement.address] = ScanEntry(advertisement)
self.callback(list(self.entries.values()))
# -----------------------------------------------------------------------------
async def main(host_source, host_sink):
async def main(hci_source, hci_sink, callback):
print('### Starting Scanner')
hci_source = HciSource(host_source)
hci_sink = host_sink
device = Device.with_hci('Bumble', 'F0:F1:F2:F3:F4:F5', hci_source, hci_sink)
device.listener = ScannerListener()
device.listener = ScannerListener(callback)
await device.power_on()
await device.start_scanning()

42
web/speaker/logo.svg Normal file
View File

@@ -0,0 +1,42 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?> <!-- Created with Vectornator for iOS (http://vectornator.io/) --><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg height="100%" style="fill-rule:nonzero;clip-rule:evenodd;stroke-linecap:round;stroke-linejoin:round;" xmlns:xlink="http://www.w3.org/1999/xlink" xmlns="http://www.w3.org/2000/svg" xml:space="preserve" width="100%" xmlns:vectornator="http://vectornator.io" version="1.1" viewBox="0 0 745 744.634">
<metadata>
<vectornator:setting key="DimensionsVisible" value="1"/>
<vectornator:setting key="PencilOnly" value="0"/>
<vectornator:setting key="SnapToPoints" value="0"/>
<vectornator:setting key="OutlineMode" value="0"/>
<vectornator:setting key="CMYKEnabledKey" value="0"/>
<vectornator:setting key="RulersVisible" value="1"/>
<vectornator:setting key="SnapToEdges" value="0"/>
<vectornator:setting key="GuidesVisible" value="1"/>
<vectornator:setting key="DisplayWhiteBackground" value="0"/>
<vectornator:setting key="doHistoryDisabled" value="0"/>
<vectornator:setting key="SnapToGuides" value="1"/>
<vectornator:setting key="TimeLapseWatermarkDisabled" value="0"/>
<vectornator:setting key="Units" value="Pixels"/>
<vectornator:setting key="DynamicGuides" value="0"/>
<vectornator:setting key="IsolateActiveLayer" value="0"/>
<vectornator:setting key="SnapToGrid" value="0"/>
</metadata>
<defs/>
<g id="Layer 1" vectornator:layerName="Layer 1">
<path stroke="#000000" stroke-width="18.6464" d="M368.753+729.441L58.8847+550.539L58.8848+192.734L368.753+13.8313L678.621+192.734L678.621+550.539L368.753+729.441Z" fill="#0082fc" stroke-linecap="butt" fill-opacity="0.307489" opacity="1" stroke-linejoin="round"/>
<g opacity="1">
<g opacity="1">
<path stroke="#000000" stroke-width="20" d="M292.873+289.256L442.872+289.256L442.872+539.254L292.873+539.254L292.873+289.256Z" fill="#fcd100" stroke-linecap="butt" opacity="1" stroke-linejoin="round"/>
<path stroke="#000000" stroke-width="20" d="M292.873+289.256C292.873+247.835+326.452+214.257+367.873+214.257C409.294+214.257+442.872+247.835+442.872+289.256C442.872+330.677+409.294+364.256+367.873+364.256C326.452+364.256+292.873+330.677+292.873+289.256Z" fill="#fcd100" stroke-linecap="butt" opacity="1" stroke-linejoin="round"/>
<path stroke="#000000" stroke-width="20" d="M292.873+539.254C292.873+497.833+326.452+464.255+367.873+464.255C409.294+464.255+442.872+497.833+442.872+539.254C442.872+580.675+409.294+614.254+367.873+614.254C326.452+614.254+292.873+580.675+292.873+539.254Z" fill="#fcd100" stroke-linecap="butt" opacity="1" stroke-linejoin="round"/>
<path stroke="#0082fc" stroke-width="0.1" d="M302.873+289.073L432.872+289.073L432.872+539.072L302.873+539.072L302.873+289.073Z" fill="#fcd100" stroke-linecap="butt" opacity="1" stroke-linejoin="round"/>
</g>
<path stroke="#000000" stroke-width="0.1" d="M103.161+309.167L226.956+443.903L366.671+309.604L103.161+309.167Z" fill="#0082fc" stroke-linecap="round" opacity="1" stroke-linejoin="round"/>
<path stroke="#000000" stroke-width="0.1" d="M383.411+307.076L508.887+440.112L650.5+307.507L383.411+307.076Z" fill="#0082fc" stroke-linecap="round" opacity="1" stroke-linejoin="round"/>
<path stroke="#000000" stroke-width="20" d="M522.045+154.808L229.559+448.882L83.8397+300.104L653.666+302.936L511.759+444.785L223.101+156.114" fill="none" stroke-linecap="round" opacity="1" stroke-linejoin="round"/>
<path stroke="#000000" stroke-width="61.8698" d="M295.857+418.738L438.9+418.738" fill="none" stroke-linecap="butt" opacity="1" stroke-linejoin="round"/>
<path stroke="#000000" stroke-width="61.8698" d="M295.857+521.737L438.9+521.737" fill="none" stroke-linecap="butt" opacity="1" stroke-linejoin="round"/>
<g opacity="1">
<path stroke="#0082fc" stroke-width="0.1" d="M367.769+667.024L367.821+616.383L403.677+616.336C383.137+626.447+368.263+638.69+367.769+667.024Z" fill="#000000" stroke-linecap="butt" opacity="1" stroke-linejoin="round"/>
<path stroke="#0082fc" stroke-width="0.1" d="M367.836+667.024L367.784+616.383L331.928+616.336C352.468+626.447+367.341+638.69+367.836+667.024Z" fill="#000000" stroke-linecap="butt" opacity="1" stroke-linejoin="round"/>
</g>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 4.1 KiB

76
web/speaker/speaker.css Normal file
View File

@@ -0,0 +1,76 @@
body, h1, h2, h3, h4, h5, h6 {
font-family: sans-serif;
}
#controlsDiv {
margin: 6px;
}
#errorText {
background-color: rgb(239, 89, 75);
border: none;
border-radius: 4px;
padding: 8px;
display: inline-block;
margin: 4px;
}
#startButton {
padding: 4px;
margin: 6px;
}
#fftCanvas {
border-radius: 16px;
margin: 6px;
}
#bandwidthCanvas {
border: grey;
border-style: solid;
border-radius: 8px;
margin: 6px;
}
#streamStateText {
background-color: rgb(93, 165, 93);
border: none;
border-radius: 8px;
padding: 10px 20px;
display: inline-block;
margin: 6px;
}
#connectionStateText {
background-color: rgb(112, 146, 206);
border: none;
border-radius: 8px;
padding: 10px 20px;
display: inline-block;
margin: 6px;
}
#propertiesTable {
border: grey;
border-style: solid;
border-radius: 4px;
padding: 4px;
margin: 6px;
margin-left: 0px;
}
th, td {
padding-left: 6px;
padding-right: 6px;
}
.properties td:nth-child(even) {
background-color: #D6EEEE;
font-family: monospace;
}
.properties td:nth-child(odd) {
font-weight: bold;
}
.properties tr td:nth-child(2) { width: 150px; }

34
web/speaker/speaker.html Normal file
View File

@@ -0,0 +1,34 @@
<!DOCTYPE html>
<html>
<head>
<title>Bumble Speaker</title>
<script src="https://cdn.jsdelivr.net/pyodide/v0.23.2/full/pyodide.js"></script>
<script type="module" src="speaker.js"></script>
<link rel="stylesheet" href="speaker.css">
</head>
<body>
<h1><img src="logo.svg" width=100 height=100 style="vertical-align:middle" alt=""/>Bumble Virtual Speaker</h1>
<div id="errorText"></div>
<div id="speaker">
<table><tr>
<td>
<table id="propertiesTable" class="properties">
<tr><td>Codec</td><td><span id="codecText"></span></td></tr>
<tr><td>Packets</td><td><span id="packetsReceivedText"></span></td></tr>
<tr><td>Bytes</td><td><span id="bytesReceivedText"></span></td></tr>
</table>
</td>
<td>
<canvas id="bandwidthCanvas" width="500", height="100">Bandwidth Graph</canvas>
</td>
</tr></table>
<span id="streamStateText">IDLE</span>
<span id="connectionStateText">NOT CONNECTED</span>
<div id="controlsDiv">
<button id="audioOnButton">Audio On</button>
</div>
<canvas id="fftCanvas" width="1024", height="300">Audio Frequencies Animation</canvas>
<audio id="audio"></audio>
</div>
</body>
</html>

289
web/speaker/speaker.js Normal file
View File

@@ -0,0 +1,289 @@
import { loadBumble, connectWebSocketTransport } from "../bumble.js";
(function () {
'use strict';
let codecText;
let packetsReceivedText;
let bytesReceivedText;
let streamStateText;
let connectionStateText;
let errorText;
let audioOnButton;
let mediaSource;
let sourceBuffer;
let audioElement;
let audioContext;
let audioAnalyzer;
let audioFrequencyBinCount;
let audioFrequencyData;
let packetsReceived = 0;
let bytesReceived = 0;
let audioState = "stopped";
let streamState = "IDLE";
let fftCanvas;
let fftCanvasContext;
let bandwidthCanvas;
let bandwidthCanvasContext;
let bandwidthBinCount;
let bandwidthBins = [];
let pyodide;
const FFT_WIDTH = 800;
const FFT_HEIGHT = 256;
const BANDWIDTH_WIDTH = 500;
const BANDWIDTH_HEIGHT = 100;
function init() {
initUI();
initMediaSource();
initAudioElement();
initAnalyzer();
initBumble();
}
function initUI() {
audioOnButton = document.getElementById("audioOnButton");
codecText = document.getElementById("codecText");
packetsReceivedText = document.getElementById("packetsReceivedText");
bytesReceivedText = document.getElementById("bytesReceivedText");
streamStateText = document.getElementById("streamStateText");
errorText = document.getElementById("errorText");
connectionStateText = document.getElementById("connectionStateText");
audioOnButton.onclick = () => startAudio();
codecText.innerText = "AAC";
setErrorText("");
requestAnimationFrame(onAnimationFrame);
}
function initMediaSource() {
mediaSource = new MediaSource();
mediaSource.onsourceopen = onMediaSourceOpen;
mediaSource.onsourceclose = onMediaSourceClose;
mediaSource.onsourceended = onMediaSourceEnd;
}
function initAudioElement() {
audioElement = document.getElementById("audio");
audioElement.src = URL.createObjectURL(mediaSource);
// audioElement.controls = true;
}
function initAnalyzer() {
fftCanvas = document.getElementById("fftCanvas");
fftCanvas.width = FFT_WIDTH
fftCanvas.height = FFT_HEIGHT
fftCanvasContext = fftCanvas.getContext('2d');
fftCanvasContext.fillStyle = "rgb(0, 0, 0)";
fftCanvasContext.fillRect(0, 0, FFT_WIDTH, FFT_HEIGHT);
bandwidthCanvas = document.getElementById("bandwidthCanvas");
bandwidthCanvas.width = BANDWIDTH_WIDTH
bandwidthCanvas.height = BANDWIDTH_HEIGHT
bandwidthCanvasContext = bandwidthCanvas.getContext('2d');
bandwidthCanvasContext.fillStyle = "rgb(255, 255, 255)";
bandwidthCanvasContext.fillRect(0, 0, BANDWIDTH_WIDTH, BANDWIDTH_HEIGHT);
}
async function initBumble() {
// Load pyodide
console.log("Loading Pyodide");
pyodide = await loadPyodide();
// Load Bumble
console.log("Loading Bumble");
const params = (new URL(document.location)).searchParams;
const bumblePackage = params.get("package") || "bumble";
await loadBumble(pyodide, bumblePackage);
console.log("Ready!")
const hciWsUrl = params.get("hci") || "ws://localhost:9922/hci";
try {
// Create a WebSocket HCI transport
let transport
try {
transport = await connectWebSocketTransport(pyodide, hciWsUrl);
} catch (error) {
console.error(error);
setErrorText(error);
return;
}
// Run the scanner example
const script = await (await fetch("speaker.py")).text();
await pyodide.runPythonAsync(script);
const pythonMain = pyodide.globals.get("main");
console.log("Starting speaker...");
await pythonMain(transport.packet_source, transport.packet_sink, onEvent);
console.log("Speaker running");
} catch (err) {
console.log(err);
}
}
function startAnalyzer() {
// FFT
if (audioElement.captureStream !== undefined) {
audioContext = new AudioContext();
audioAnalyzer = audioContext.createAnalyser();
audioAnalyzer.fftSize = 128;
audioFrequencyBinCount = audioAnalyzer.frequencyBinCount;
audioFrequencyData = new Uint8Array(audioFrequencyBinCount);
const stream = audioElement.captureStream();
const source = audioContext.createMediaStreamSource(stream);
source.connect(audioAnalyzer);
}
// Bandwidth
bandwidthBinCount = BANDWIDTH_WIDTH / 2;
bandwidthBins = [];
}
function setErrorText(message) {
errorText.innerText = message;
if (message.length == 0) {
errorText.style.display = "none";
} else {
errorText.style.display = "inline-block";
}
}
function setStreamState(state) {
streamState = state;
streamStateText.innerText = streamState;
}
function onAnimationFrame() {
// FFT
if (audioAnalyzer !== undefined) {
audioAnalyzer.getByteFrequencyData(audioFrequencyData);
fftCanvasContext.fillStyle = "rgb(0, 0, 0)";
fftCanvasContext.fillRect(0, 0, FFT_WIDTH, FFT_HEIGHT);
const barCount = audioFrequencyBinCount;
const barWidth = (FFT_WIDTH / audioFrequencyBinCount) - 1;
for (let bar = 0; bar < barCount; bar++) {
const barHeight = audioFrequencyData[bar];
fftCanvasContext.fillStyle = `rgb(${barHeight / 256 * 200 + 50}, 50, ${50 + 2 * bar})`;
fftCanvasContext.fillRect(bar * (barWidth + 1), FFT_HEIGHT - barHeight, barWidth, barHeight);
}
}
// Bandwidth
bandwidthCanvasContext.fillStyle = "rgb(255, 255, 255)";
bandwidthCanvasContext.fillRect(0, 0, BANDWIDTH_WIDTH, BANDWIDTH_HEIGHT);
bandwidthCanvasContext.fillStyle = `rgb(100, 100, 100)`;
for (let t = 0; t < bandwidthBins.length; t++) {
const lineHeight = (bandwidthBins[t] / 1000) * BANDWIDTH_HEIGHT;
bandwidthCanvasContext.fillRect(t * 2, BANDWIDTH_HEIGHT - lineHeight, 2, lineHeight);
}
// Display again at the next frame
requestAnimationFrame(onAnimationFrame);
}
function onMediaSourceOpen() {
console.log(this.readyState);
sourceBuffer = mediaSource.addSourceBuffer("audio/aac");
}
function onMediaSourceClose() {
console.log(this.readyState);
}
function onMediaSourceEnd() {
console.log(this.readyState);
}
async function startAudio() {
try {
console.log("starting audio...");
audioOnButton.disabled = true;
audioState = "starting";
await audioElement.play();
console.log("audio started");
audioState = "playing";
startAnalyzer();
} catch (error) {
console.error(`play failed: ${error}`);
audioState = "stopped";
audioOnButton.disabled = false;
}
}
async function onEvent(name, params) {
// Dispatch the message.
const handlerName = `on${name.charAt(0).toUpperCase()}${name.slice(1)}`
const handler = eventHandlers[handlerName];
if (handler !== undefined) {
handler(params);
} else {
console.warn(`unhandled event: ${name}`)
}
}
function onStart() {
setStreamState("STARTED");
}
function onStop() {
setStreamState("STOPPED");
}
function onSuspend() {
setStreamState("SUSPENDED");
}
function onConnection(params) {
connectionStateText.innerText = `CONNECTED: ${params.get('peer_name')} (${params.get('peer_address')})`;
}
function onDisconnection(params) {
connectionStateText.innerText = "DISCONNECTED";
}
function onAudio(python_packet) {
const packet = python_packet.toJs({create_proxies : false});
python_packet.destroy();
if (audioState != "stopped") {
// Queue the audio packet.
sourceBuffer.appendBuffer(packet);
}
packetsReceived += 1;
packetsReceivedText.innerText = packetsReceived;
bytesReceived += packet.byteLength;
bytesReceivedText.innerText = bytesReceived;
bandwidthBins[bandwidthBins.length] = packet.byteLength;
if (bandwidthBins.length > bandwidthBinCount) {
bandwidthBins.shift();
}
}
function onKeystoreupdate() {
// Sync the FS
pyodide.FS.syncfs(() => {
console.log("FS synced out")
});
}
const eventHandlers = {
onStart,
onStop,
onSuspend,
onConnection,
onDisconnection,
onAudio,
onKeystoreupdate
}
window.onload = (event) => {
init();
}
}());

321
web/speaker/speaker.py Normal file
View File

@@ -0,0 +1,321 @@
# 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
# -----------------------------------------------------------------------------
from __future__ import annotations
import enum
import logging
from typing import Dict, List
from bumble.core import BT_BR_EDR_TRANSPORT, CommandTimeoutError
from bumble.device import Device, DeviceConfiguration
from bumble.pairing import PairingConfig
from bumble.sdp import ServiceAttribute
from bumble.avdtp import (
AVDTP_AUDIO_MEDIA_TYPE,
Listener,
MediaCodecCapabilities,
MediaPacket,
Protocol,
)
from bumble.a2dp import (
make_audio_sink_service_sdp_records,
MPEG_2_AAC_LC_OBJECT_TYPE,
A2DP_SBC_CODEC_TYPE,
A2DP_MPEG_2_4_AAC_CODEC_TYPE,
SBC_MONO_CHANNEL_MODE,
SBC_DUAL_CHANNEL_MODE,
SBC_SNR_ALLOCATION_METHOD,
SBC_LOUDNESS_ALLOCATION_METHOD,
SBC_STEREO_CHANNEL_MODE,
SBC_JOINT_STEREO_CHANNEL_MODE,
SbcMediaCodecInformation,
AacMediaCodecInformation,
)
from bumble.utils import AsyncRunner
from bumble.codecs import AacAudioRtpPacket
# -----------------------------------------------------------------------------
# Logging
# -----------------------------------------------------------------------------
logger = logging.getLogger(__name__)
# -----------------------------------------------------------------------------
class AudioExtractor:
@staticmethod
def create(codec: str):
if codec == 'aac':
return AacAudioExtractor()
if codec == 'sbc':
return SbcAudioExtractor()
def extract_audio(self, packet: MediaPacket) -> bytes:
raise NotImplementedError()
# -----------------------------------------------------------------------------
class AacAudioExtractor:
def extract_audio(self, packet: MediaPacket) -> bytes:
return AacAudioRtpPacket(packet.payload).to_adts()
# -----------------------------------------------------------------------------
class SbcAudioExtractor:
def extract_audio(self, packet: MediaPacket) -> bytes:
# header = packet.payload[0]
# fragmented = header >> 7
# start = (header >> 6) & 0x01
# last = (header >> 5) & 0x01
# number_of_frames = header & 0x0F
# TODO: support fragmented payloads
return packet.payload[1:]
# -----------------------------------------------------------------------------
class Speaker:
class StreamState(enum.Enum):
IDLE = 0
STOPPED = 1
STARTED = 2
SUSPENDED = 3
def __init__(self, hci_source, hci_sink, emit_event, codec, discover):
self.hci_source = hci_source
self.hci_sink = hci_sink
self.emit_event = emit_event
self.codec = codec
self.discover = discover
self.device = None
self.connection = None
self.listener = None
self.packets_received = 0
self.bytes_received = 0
self.stream_state = Speaker.StreamState.IDLE
self.audio_extractor = AudioExtractor.create(codec)
def sdp_records(self) -> Dict[int, List[ServiceAttribute]]:
service_record_handle = 0x00010001
return {
service_record_handle: make_audio_sink_service_sdp_records(
service_record_handle
)
}
def codec_capabilities(self) -> MediaCodecCapabilities:
if self.codec == 'aac':
return self.aac_codec_capabilities()
if self.codec == 'sbc':
return self.sbc_codec_capabilities()
raise RuntimeError('unsupported codec')
def aac_codec_capabilities(self) -> MediaCodecCapabilities:
return MediaCodecCapabilities(
media_type=AVDTP_AUDIO_MEDIA_TYPE,
media_codec_type=A2DP_MPEG_2_4_AAC_CODEC_TYPE,
media_codec_information=AacMediaCodecInformation.from_lists(
object_types=[MPEG_2_AAC_LC_OBJECT_TYPE],
sampling_frequencies=[48000, 44100],
channels=[1, 2],
vbr=1,
bitrate=256000,
),
)
def sbc_codec_capabilities(self) -> MediaCodecCapabilities:
return MediaCodecCapabilities(
media_type=AVDTP_AUDIO_MEDIA_TYPE,
media_codec_type=A2DP_SBC_CODEC_TYPE,
media_codec_information=SbcMediaCodecInformation.from_lists(
sampling_frequencies=[48000, 44100, 32000, 16000],
channel_modes=[
SBC_MONO_CHANNEL_MODE,
SBC_DUAL_CHANNEL_MODE,
SBC_STEREO_CHANNEL_MODE,
SBC_JOINT_STEREO_CHANNEL_MODE,
],
block_lengths=[4, 8, 12, 16],
subbands=[4, 8],
allocation_methods=[
SBC_LOUDNESS_ALLOCATION_METHOD,
SBC_SNR_ALLOCATION_METHOD,
],
minimum_bitpool_value=2,
maximum_bitpool_value=53,
),
)
def on_key_store_update(self):
print("Key Store updated")
self.emit_event('keystoreupdate', None)
def on_bluetooth_connection(self, connection):
print(f'Connection: {connection}')
self.connection = connection
connection.on('disconnection', self.on_bluetooth_disconnection)
peer_name = '' if connection.peer_name is None else connection.peer_name
peer_address = str(connection.peer_address).replace('/P', '')
self.emit_event(
'connection', {'peer_name': peer_name, 'peer_address': peer_address}
)
def on_bluetooth_disconnection(self, reason):
print(f'Disconnection ({reason})')
self.connection = None
AsyncRunner.spawn(self.advertise())
self.emit_event('disconnection', None)
def on_avdtp_connection(self, protocol):
print('Audio Stream Open')
# Add a sink endpoint to the server
sink = protocol.add_sink(self.codec_capabilities())
sink.on('start', self.on_sink_start)
sink.on('stop', self.on_sink_stop)
sink.on('suspend', self.on_sink_suspend)
sink.on('configuration', lambda: self.on_sink_configuration(sink.configuration))
sink.on('rtp_packet', self.on_rtp_packet)
sink.on('rtp_channel_open', self.on_rtp_channel_open)
sink.on('rtp_channel_close', self.on_rtp_channel_close)
# Listen for close events
protocol.on('close', self.on_avdtp_close)
# Discover all endpoints on the remote device is requested
if self.discover:
AsyncRunner.spawn(self.discover_remote_endpoints(protocol))
def on_avdtp_close(self):
print("Audio Stream Closed")
def on_sink_start(self):
print("Sink Started")
self.stream_state = self.StreamState.STARTED
self.emit_event('start', None)
def on_sink_stop(self):
print("Sink Stopped")
self.stream_state = self.StreamState.STOPPED
self.emit_event('stop', None)
def on_sink_suspend(self):
print("Sink Suspended")
self.stream_state = self.StreamState.SUSPENDED
self.emit_event('suspend', None)
def on_sink_configuration(self, config):
print("Sink Configuration:")
print('\n'.join([" " + str(capability) for capability in config]))
def on_rtp_channel_open(self):
print("RTP Channel Open")
def on_rtp_channel_close(self):
print("RTP Channel Closed")
self.stream_state = self.StreamState.IDLE
def on_rtp_packet(self, packet):
self.packets_received += 1
self.bytes_received += len(packet.payload)
self.emit_event("audio", self.audio_extractor.extract_audio(packet))
async def advertise(self):
await self.device.set_discoverable(True)
await self.device.set_connectable(True)
async def connect(self, address):
# Connect to the source
print(f'=== Connecting to {address}...')
connection = await self.device.connect(address, transport=BT_BR_EDR_TRANSPORT)
print(f'=== Connected to {connection.peer_address}')
# Request authentication
print('*** Authenticating...')
await connection.authenticate()
print('*** Authenticated')
# Enable encryption
print('*** Enabling encryption...')
await connection.encrypt()
print('*** Encryption on')
protocol = await Protocol.connect(connection)
self.listener.set_server(connection, protocol)
self.on_avdtp_connection(protocol)
async def discover_remote_endpoints(self, protocol):
endpoints = await protocol.discover_remote_endpoints()
print(f'@@@ Found {len(endpoints)} endpoints')
for endpoint in endpoints:
print('@@@', endpoint)
async def run(self, connect_address):
# Create a device
device_config = DeviceConfiguration()
device_config.name = "Bumble Speaker"
device_config.class_of_device = 0x240414
device_config.keystore = "JsonKeyStore:/bumble/keystore.json"
device_config.classic_enabled = True
device_config.le_enabled = False
self.device = Device.from_config_with_hci(
device_config, self.hci_source, self.hci_sink
)
# Setup the SDP to expose the sink service
self.device.sdp_service_records = self.sdp_records()
# Don't require MITM when pairing.
self.device.pairing_config_factory = lambda connection: PairingConfig(
mitm=False
)
# Start the controller
await self.device.power_on()
# Listen for Bluetooth connections
self.device.on('connection', self.on_bluetooth_connection)
# Listen for changes to the key store
self.device.on('key_store_update', self.on_key_store_update)
# Create a listener to wait for AVDTP connections
self.listener = Listener(Listener.create_registrar(self.device))
self.listener.on('connection', self.on_avdtp_connection)
print(f'Speaker ready to play, codec={self.codec}')
if connect_address:
# Connect to the source
try:
await self.connect(connect_address)
except CommandTimeoutError:
print("Connection timed out")
return
else:
# Start being discoverable and connectable
print("Waiting for connection...")
await self.advertise()
# -----------------------------------------------------------------------------
async def main(hci_source, hci_sink, emit_event):
# logging.basicConfig(level='DEBUG')
speaker = Speaker(hci_source, hci_sink, emit_event, "aac", False)
await speaker.run(None)