Compare commits

...

28 Commits

Author SHA1 Message Date
Gilles Boccon-Gibod 8be9f4cb0e add doc and fix types 2023-09-06 17:05:30 -07:00
Gilles Boccon-Gibod 1ea12b1bf7 rebase 2023-09-06 17:05:24 -07:00
Gilles Boccon-Gibod 65e6d68355 add tcp server 2023-09-06 16:49:21 -07:00
Gilles Boccon-Gibod fd4d1bcca3 Merge pull request #261 from marshallpierce/mp/rust-realtek-tools
Rust tools for working with Realtek firmware
2023-09-05 10:55:29 -07:00
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
Marshall Pierce 0e2fc80509 Rust tools for working with Realtek firmware
Further adventures in porting tools to Rust to flesh out the supported
API.

These tools didn't feel like `example`s, so I made a top level `bumble`
CLI tool that hosts them all as subcommands. I also moved the usb probe
not-really-an-`example` into it as well. I'm open to suggestions on how
best to organize the subcommands to make them intuitive to explore with
`--help`, and how to leave room for other future tools.

I also adopted the per-OS project data dir for a default firmware
location so that users can download once and then use those .bin files
from anywhere without having to sprinkle .bin files in project
directories or reaching inside the python package dir hierarchy.
2023-08-30 15:37:35 -06: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 85a61dc39d add entry to the list of supported USB devices 2023-08-18 09:56:06 -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
Josh Wu 2af3494d8c Handle SSP Complete events 2023-08-10 10:58:41 +08:00
58 changed files with 3666 additions and 748 deletions
+12 -5
View File
@@ -45,7 +45,8 @@ jobs:
runs-on: ubuntu-latest
strategy:
matrix:
python-version: [ "3.8", "3.9", "3.10" ]
python-version: [ "3.8", "3.9", "3.10", "3.11" ]
rust-version: [ "1.70.0", "stable" ]
fail-fast: false
steps:
- name: Check out from Git
@@ -62,9 +63,15 @@ jobs:
uses: actions-rust-lang/setup-rust-toolchain@v1
with:
components: clippy,rustfmt
- name: Rust Lints
run: cd rust && cargo fmt --check && cargo clippy --all-targets -- --deny warnings
toolchain: ${{ matrix.rust-version }}
- name: Rust Build
run: cd rust && cargo build --all-targets
run: cd rust && cargo build --all-targets && cargo build --all-features --all-targets
# Lints after build so what clippy needs is already built
- name: Rust Lints
run: cd rust && cargo fmt --check && cargo clippy --all-targets -- --deny warnings && cargo clippy --all-features --all-targets -- --deny warnings
- name: Rust Tests
run: cd rust && cargo test
run: cd rust && cargo test
# At some point, hook up publishing the binary. For now, just make sure it builds.
# Once we're ready to publish binaries, this should be built with `--release`.
- name: Build Bumble CLI
run: cd rust && cargo build --features bumble-tools --bin bumble
+11 -15
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
+43 -35
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):
+22
View File
@@ -21,6 +21,9 @@ like loading firmware after a cold start.
# -----------------------------------------------------------------------------
import abc
import logging
import pathlib
import platform
import platformdirs
from . import rtk
@@ -66,3 +69,22 @@ async def get_driver_for_host(host):
return driver
return None
def project_data_dir() -> pathlib.Path:
"""
Returns:
A path to an OS-specific directory for bumble data. The directory is created if
it doesn't exist.
"""
if platform.system() == 'Darwin':
# platformdirs doesn't handle macOS right: it doesn't assemble a bundle id
# out of author & project
return platformdirs.user_data_path(
appname='com.google.bumble', ensure_exists=True
)
else:
# windows and linux don't use the com qualifier
return platformdirs.user_data_path(
appname='bumble', appauthor='google', ensure_exists=True
)
+19
View File
@@ -125,6 +125,7 @@ RTK_USB_PRODUCTS = {
(0x2550, 0x8761),
(0x2B89, 0x8761),
(0x7392, 0xC611),
(0x0BDA, 0x877B),
# Realtek 8821AE
(0x0B05, 0x17DC),
(0x13D3, 0x3414),
@@ -445,6 +446,11 @@ class Driver:
# When the environment variable is set, don't look elsewhere
return None
# Then, look where the firmware download tool writes by default
if (path := rtk_firmware_dir() / file_name).is_file():
logger.debug(f"{file_name} found in project data dir")
return path
# Then, look in the package's driver directory
if (path := pathlib.Path(__file__).parent / "rtk_fw" / file_name).is_file():
logger.debug(f"{file_name} found in package dir")
@@ -645,3 +651,16 @@ class Driver:
await self.download_firmware()
await self.host.send_command(HCI_Reset_Command(), check_result=True)
logger.info(f"loaded FW image {self.driver_info.fw_name}")
def rtk_firmware_dir() -> pathlib.Path:
"""
Returns:
A path to a subdir of the project data dir for Realtek firmware.
The directory is created if it doesn't exist.
"""
from bumble.drivers import project_data_dir
p = project_data_dir() / "firmware" / "realtek"
p.mkdir(parents=True, exist_ok=True)
return p
+15 -10
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()}')
+28 -21
View File
@@ -15,6 +15,7 @@
# -----------------------------------------------------------------------------
# Imports
# -----------------------------------------------------------------------------
from __future__ import annotations
import asyncio
import collections
import logging
@@ -30,8 +31,8 @@ from bumble import drivers
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,
@@ -95,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)
@@ -307,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)
@@ -356,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
@@ -387,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()
@@ -397,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
@@ -443,11 +447,10 @@ 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)
@@ -461,36 +464,36 @@ class Host(AbortableEventEmitter):
self.emit('flush')
def on_hci_packet(self, packet):
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):
@@ -828,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)
+21 -15
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}) '
+2
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
+169 -126
View File
@@ -15,15 +15,37 @@
# -----------------------------------------------------------------------------
# Imports
# -----------------------------------------------------------------------------
from __future__ import annotations
import logging
import asyncio
import enum
from typing import Callable, Dict, List, Optional, Tuple, Union, TYPE_CHECKING
from pyee import EventEmitter
from typing import Optional, Tuple, Callable, Dict, Union
from . import core, l2cap
from .colors import color
from .core import BT_BR_EDR_TRANSPORT, InvalidStateError, ProtocolError
from .core import (
UUID,
BT_RFCOMM_PROTOCOL_ID,
BT_BR_EDR_TRANSPORT,
BT_L2CAP_PROTOCOL_ID,
InvalidStateError,
ProtocolError,
)
from .sdp import (
SDP_SERVICE_RECORD_HANDLE_ATTRIBUTE_ID,
SDP_BROWSE_GROUP_LIST_ATTRIBUTE_ID,
SDP_SERVICE_CLASS_ID_LIST_ATTRIBUTE_ID,
SDP_PROTOCOL_DESCRIPTOR_LIST_ATTRIBUTE_ID,
SDP_PUBLIC_BROWSE_ROOT,
DataElement,
ServiceAttribute,
)
if TYPE_CHECKING:
from bumble.device import Device, Connection
# -----------------------------------------------------------------------------
# Logging
@@ -105,6 +127,50 @@ RFCOMM_DYNAMIC_CHANNEL_NUMBER_END = 30
# fmt: on
# -----------------------------------------------------------------------------
def make_service_sdp_records(
service_record_handle: int, channel: int, uuid: Optional[UUID] = None
) -> List[ServiceAttribute]:
"""
Create SDP records for an RFComm service given a channel number and an
optional UUID. A Service Class Attribute is included only if the UUID is not None.
"""
records = [
ServiceAttribute(
SDP_SERVICE_RECORD_HANDLE_ATTRIBUTE_ID,
DataElement.unsigned_integer_32(service_record_handle),
),
ServiceAttribute(
SDP_BROWSE_GROUP_LIST_ATTRIBUTE_ID,
DataElement.sequence([DataElement.uuid(SDP_PUBLIC_BROWSE_ROOT)]),
),
ServiceAttribute(
SDP_PROTOCOL_DESCRIPTOR_LIST_ATTRIBUTE_ID,
DataElement.sequence(
[
DataElement.sequence([DataElement.uuid(BT_L2CAP_PROTOCOL_ID)]),
DataElement.sequence(
[
DataElement.uuid(BT_RFCOMM_PROTOCOL_ID),
DataElement.unsigned_integer_8(channel),
]
),
]
),
),
]
if uuid:
records.append(
ServiceAttribute(
SDP_SERVICE_CLASS_ID_LIST_ATTRIBUTE_ID,
DataElement.sequence([DataElement.uuid(uuid)]),
)
)
return records
# -----------------------------------------------------------------------------
def compute_fcs(buffer: bytes) -> int:
result = 0xFF
@@ -149,9 +215,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 +258,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 +281,7 @@ class RFCOMM_Frame:
return frame
def __bytes__(self):
def __bytes__(self) -> bytes:
return (
bytes([self.address, self.control])
+ self.length
@@ -223,7 +289,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 +319,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 +329,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 +340,7 @@ class RFCOMM_MCC_PN:
window_size=data[7],
)
def __bytes__(self):
def __bytes__(self) -> bytes:
return bytes(
[
self.dlci & 0xFF,
@@ -288,7 +354,7 @@ class RFCOMM_MCC_PN:
]
)
def __str__(self):
def __str__(self) -> str:
return (
f'PN(dlci={self.dlci},'
f'cl={self.cl},'
@@ -309,7 +375,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 +386,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 +396,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 +409,7 @@ class RFCOMM_MCC_MSC:
]
)
def __str__(self):
def __str__(self) -> str:
return (
f'MSC(dlci={self.dlci},'
f'fc={self.fc},'
@@ -354,29 +422,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 +447,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 +459,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 +470,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 +487,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 +505,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 +546,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 +564,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 +587,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 +649,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 +674,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 +688,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 +720,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 +754,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 +772,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 +807,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 +822,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 +862,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 +874,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 +889,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 +907,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 +929,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 +968,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)
+85 -26
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,
)
+3 -2
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)
+1
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))
+8 -6
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()
+1 -1
View File
@@ -3,7 +3,7 @@ channels:
- defaults
- conda-forge
dependencies:
- pip=20
- pip=23
- python=3.8
- pip:
- --editable .[development,documentation,test]
+91 -60
View File
@@ -20,83 +20,109 @@ import sys
import os
import logging
from bumble.core import UUID
from bumble.device import Device
from bumble.transport import open_transport_or_link
from bumble.core import BT_L2CAP_PROTOCOL_ID, BT_RFCOMM_PROTOCOL_ID, UUID
from bumble.rfcomm import Server
from bumble.sdp import (
DataElement,
ServiceAttribute,
SDP_PUBLIC_BROWSE_ROOT,
SDP_SERVICE_RECORD_HANDLE_ATTRIBUTE_ID,
SDP_BROWSE_GROUP_LIST_ATTRIBUTE_ID,
SDP_SERVICE_CLASS_ID_LIST_ATTRIBUTE_ID,
SDP_PROTOCOL_DESCRIPTOR_LIST_ATTRIBUTE_ID,
)
from bumble.utils import AsyncRunner
from bumble.rfcomm import make_service_sdp_records
# -----------------------------------------------------------------------------
def sdp_records(channel):
def sdp_records(channel, uuid):
service_record_handle = 0x00010001
return {
0x00010001: [
ServiceAttribute(
SDP_SERVICE_RECORD_HANDLE_ATTRIBUTE_ID,
DataElement.unsigned_integer_32(0x00010001),
),
ServiceAttribute(
SDP_BROWSE_GROUP_LIST_ATTRIBUTE_ID,
DataElement.sequence([DataElement.uuid(SDP_PUBLIC_BROWSE_ROOT)]),
),
ServiceAttribute(
SDP_SERVICE_CLASS_ID_LIST_ATTRIBUTE_ID,
DataElement.sequence(
[DataElement.uuid(UUID('E6D55659-C8B4-4B85-96BB-B1143AF6D3AE'))]
),
),
ServiceAttribute(
SDP_PROTOCOL_DESCRIPTOR_LIST_ATTRIBUTE_ID,
DataElement.sequence(
[
DataElement.sequence([DataElement.uuid(BT_L2CAP_PROTOCOL_ID)]),
DataElement.sequence(
[
DataElement.uuid(BT_RFCOMM_PROTOCOL_ID),
DataElement.unsigned_integer_8(channel),
]
),
]
),
),
]
service_record_handle: make_service_sdp_records(
service_record_handle, channel, UUID(uuid)
)
}
# -----------------------------------------------------------------------------
def on_dlc(dlc):
print('*** DLC connected', dlc)
dlc.sink = lambda data: on_rfcomm_data_received(dlc, data)
def on_rfcomm_session(rfcomm_session, tcp_server):
print('*** RFComm session connected', rfcomm_session)
tcp_server.attach_session(rfcomm_session)
# -----------------------------------------------------------------------------
def on_rfcomm_data_received(dlc, data):
print(f'<<< Data received: {data.hex()}')
try:
message = data.decode('utf-8')
print(f'<<< Message = {message}')
except Exception:
pass
class TcpServerProtocol(asyncio.Protocol):
def __init__(self, server):
self.server = server
# Echo everything back
dlc.write(data)
def connection_made(self, transport):
peer_name = transport.get_extra_info('peer_name')
print(f'<<< TCP Server: connection from {peer_name}')
if self.server:
self.server.tcp_transport = transport
else:
transport.close()
def connection_lost(self, exc):
print('<<< TCP Server: connection lost')
if self.server:
self.server.tcp_transport = None
def data_received(self, data):
print(f'<<< TCP Server: data received: {len(data)} bytes - {data.hex()}')
if self.server:
self.server.tcp_data_received(data)
# -----------------------------------------------------------------------------
class TcpServer:
def __init__(self, port):
self.rfcomm_session = None
self.tcp_transport = None
AsyncRunner.spawn(self.run(port))
def attach_session(self, rfcomm_session):
if self.rfcomm_session:
self.rfcomm_session.sink = None
self.rfcomm_session = rfcomm_session
rfcomm_session.sink = self.rfcomm_data_received
def rfcomm_data_received(self, data):
print(f'<<< RFCOMM Data: {data.hex()}')
if self.tcp_transport:
self.tcp_transport.write(data)
else:
print('!!! no TCP connection, dropping data')
def tcp_data_received(self, data):
if self.rfcomm_session:
self.rfcomm_session.write(data)
else:
print('!!! no RFComm session, dropping data')
async def run(self, port):
print(f'$$$ Starting TCP server on port {port}')
server = await asyncio.get_running_loop().create_server(
lambda: TcpServerProtocol(self), '127.0.0.1', port
)
async with server:
await server.serve_forever()
# -----------------------------------------------------------------------------
async def main():
if len(sys.argv) < 3:
print('Usage: run_rfcomm_server.py <device-config> <transport-spec>')
print('example: run_rfcomm_server.py classic2.json usb:04b4:f901')
if len(sys.argv) < 4:
print(
'Usage: run_rfcomm_server.py <device-config> <transport-spec> '
'<tcp-port> [<uuid>]'
)
print('example: run_rfcomm_server.py classic2.json usb:0 8888')
return
tcp_port = int(sys.argv[3])
if len(sys.argv) >= 5:
uuid = sys.argv[4]
else:
uuid = 'E6D55659-C8B4-4B85-96BB-B1143AF6D3AE'
print('<<< connecting to HCI...')
async with await open_transport_or_link(sys.argv[2]) as (hci_source, hci_sink):
print('<<< connected')
@@ -105,15 +131,20 @@ async def main():
device = Device.from_config_file_with_hci(sys.argv[1], hci_source, hci_sink)
device.classic_enabled = True
# Create and register a server
# Create a TCP server
tcp_server = TcpServer(tcp_port)
# Create and register an RFComm server
rfcomm_server = Server(device)
# Listen for incoming DLC connections
channel_number = rfcomm_server.listen(on_dlc)
print(f'### Listening for connection on channel {channel_number}')
channel_number = rfcomm_server.listen(
lambda session: on_rfcomm_session(session, tcp_server)
)
print(f'### Listening for RFComm connections on channel {channel_number}')
# Setup the SDP to advertise this channel
device.sdp_service_records = sdp_records(channel_number)
device.sdp_service_records = sdp_records(channel_number, uuid)
# Start the controller
await device.power_on()
BIN
View File
Binary file not shown.
+727 -105
View File
File diff suppressed because it is too large Load Diff
+25 -5
View File
@@ -10,7 +10,7 @@ documentation = "https://docs.rs/crate/bumble"
authors = ["Marshall Pierce <marshallpierce@google.com>"]
keywords = ["bluetooth", "ble"]
categories = ["api-bindings", "network-programming"]
rust-version = "1.69.0"
rust-version = "1.70.0"
[dependencies]
pyo3 = { version = "0.18.3", features = ["macros"] }
@@ -23,7 +23,16 @@ hex = "0.4.3"
itertools = "0.11.0"
lazy_static = "1.4.0"
thiserror = "1.0.41"
# CLI
anyhow = { version = "1.0.71", optional = true }
clap = { version = "4.3.3", features = ["derive"], optional = true }
directories = { version = "5.0.1", optional = true }
owo-colors = { version = "3.5.0", optional = true }
reqwest = { version = "0.11.20", features = ["blocking"], optional = true }
rusb = { version = "0.9.2", optional = true }
log = { version = "0.4.19", optional = true }
env_logger = { version = "0.10.0", optional = true }
[dev-dependencies]
tokio = { version = "1.28.2", features = ["full"] }
@@ -32,17 +41,25 @@ nix = "0.26.2"
anyhow = "1.0.71"
pyo3 = { version = "0.18.3", features = ["macros", "anyhow"] }
pyo3-asyncio = { version = "0.18.0", features = ["tokio-runtime", "attributes", "testing"] }
rusb = "0.9.2"
rand = "0.8.5"
clap = { version = "4.3.3", features = ["derive"] }
owo-colors = "3.5.0"
log = "0.4.19"
env_logger = "0.10.0"
rusb = "0.9.2"
rand = "0.8.5"
[package.metadata.docs.rs]
rustdoc-args = ["--generate-link-to-definition"]
[[bin]]
name = "gen-assigned-numbers"
path = "tools/gen_assigned_numbers.rs"
required-features = ["bumble-dev-tools"]
required-features = ["bumble-codegen"]
[[bin]]
name = "bumble"
path = "src/main.rs"
required-features = ["bumble-tools"]
# test entry point that uses pyo3_asyncio's test harness
[[test]]
@@ -53,4 +70,7 @@ harness = false
[features]
anyhow = ["pyo3/anyhow"]
pyo3-asyncio-attributes = ["pyo3-asyncio/attributes"]
bumble-dev-tools = ["dep:anyhow"]
bumble-codegen = ["dep:anyhow"]
# separate feature for CLI so that dependencies don't spend time building these
bumble-tools = ["dep:clap", "anyhow", "dep:anyhow", "dep:directories", "pyo3-asyncio-attributes", "dep:owo-colors", "dep:reqwest", "dep:rusb", "dep:log", "dep:env_logger"]
default = []
+13 -3
View File
@@ -5,7 +5,8 @@ Rust wrappers around the [Bumble](https://github.com/google/bumble) Python API.
Method calls are mapped to the equivalent Python, and return types adapted where
relevant.
See the `examples` directory for usage.
See the CLI in `src/main.rs` or the `examples` directory for how to use the
Bumble API.
# Usage
@@ -27,6 +28,15 @@ PYTHONPATH=..:~/.virtualenvs/bumble/lib/python3.10/site-packages/ \
Run the corresponding `battery_server` Python example, and launch an emulator in
Android Studio (currently, Canary is required) to run netsim.
# CLI
Explore the available subcommands:
```
PYTHONPATH=..:[virtualenv site-packages] \
cargo run --features bumble-tools --bin bumble -- --help
```
# Development
Run the tests:
@@ -43,7 +53,7 @@ cargo clippy --all-targets
## Code gen
To have the fastest startup while keeping the build simple, code gen for
To have the fastest startup while keeping the build simple, code gen for
assigned numbers is done with the `gen_assigned_numbers` tool. It should
be re-run whenever the Python assigned numbers are changed. To ensure that the
generated code is kept up to date, the Rust data is compared to the Python
@@ -52,5 +62,5 @@ in tests at `pytests/assigned_numbers.rs`.
To regenerate the assigned number tables based on the Python codebase:
```
PYTHONPATH=.. cargo run --bin gen-assigned-numbers --features bumble-dev-tools
PYTHONPATH=.. cargo run --bin gen-assigned-numbers --features bumble-codegen
```
+7 -1
View File
@@ -12,7 +12,7 @@
// See the License for the specific language governing permissions and
// limitations under the License.
use bumble::wrapper::transport::Transport;
use bumble::wrapper::{drivers::rtk::DriverInfo, transport::Transport};
use nix::sys::stat::Mode;
use pyo3::PyResult;
@@ -29,3 +29,9 @@ async fn fifo_transport_can_open() -> PyResult<()> {
Ok(())
}
#[pyo3_asyncio::tokio::test]
async fn realtek_driver_info_all_drivers() -> PyResult<()> {
assert_eq!(12, DriverInfo::all_drivers()?.len());
Ok(())
}
@@ -0,0 +1,4 @@
This dir contains samples firmware images in the format used for Realtek chips,
but with repetitions of the length of the section as a little-endian 32-bit int
for the patch data instead of actual firmware, since we only need the structure
to test parsing.
+15
View File
@@ -0,0 +1,15 @@
// 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
//
// http://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.
pub(crate) mod rtk;
+265
View File
@@ -0,0 +1,265 @@
// 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
//
// http://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.
//! Realtek firmware tools
use crate::{Download, Source};
use anyhow::anyhow;
use bumble::wrapper::{
drivers::rtk::{Driver, DriverInfo, Firmware},
host::{DriverFactory, Host},
transport::Transport,
};
use owo_colors::{colors::css, OwoColorize};
use pyo3::PyResult;
use std::{fs, path};
pub(crate) async fn download(dl: Download) -> PyResult<()> {
let data_dir = dl
.output_dir
.or_else(|| {
directories::ProjectDirs::from("com", "google", "bumble")
.map(|pd| pd.data_local_dir().join("firmware").join("realtek"))
})
.unwrap_or_else(|| {
eprintln!("Could not determine standard data directory");
path::PathBuf::from(".")
});
fs::create_dir_all(&data_dir)?;
let (base_url, uses_bin_suffix) = match dl.source {
Source::LinuxKernel => ("https://git.kernel.org/pub/scm/linux/kernel/git/firmware/linux-firmware.git/plain/rtl_bt", true),
Source::RealtekOpensource => ("https://github.com/Realtek-OpenSource/android_hardware_realtek/raw/rtk1395/bt/rtkbt/Firmware/BT", false),
Source::LinuxFromScratch => ("https://anduin.linuxfromscratch.org/sources/linux-firmware/rtl_bt", true),
};
println!("Downloading");
println!("{} {}", "FROM:".green(), base_url);
println!("{} {}", "TO:".green(), data_dir.to_string_lossy());
let url_for_file = |file_name: &str| {
let url_suffix = if uses_bin_suffix {
file_name
} else {
file_name.trim_end_matches(".bin")
};
let mut url = base_url.to_string();
url.push('/');
url.push_str(url_suffix);
url
};
let to_download = if let Some(single) = dl.single {
vec![(
format!("{single}_fw.bin"),
Some(format!("{single}_config.bin")),
false,
)]
} else {
DriverInfo::all_drivers()?
.iter()
.map(|di| Ok((di.firmware_name()?, di.config_name()?, di.config_needed()?)))
.collect::<PyResult<Vec<_>>>()?
};
let client = SimpleClient::new();
for (fw_filename, config_filename, config_needed) in to_download {
println!("{}", "---".yellow());
let fw_path = data_dir.join(&fw_filename);
let config_path = config_filename.as_ref().map(|f| data_dir.join(f));
if fw_path.exists() && !dl.overwrite {
println!(
"{}",
format!("{} already exists, skipping", fw_path.to_string_lossy())
.fg::<css::Orange>()
);
continue;
}
if let Some(cp) = config_path.as_ref() {
if cp.exists() && !dl.overwrite {
println!(
"{}",
format!("{} already exists, skipping", cp.to_string_lossy())
.fg::<css::Orange>()
);
continue;
}
}
let fw_contents = match client.get(&url_for_file(&fw_filename)).await {
Ok(data) => {
println!("Downloaded {}: {} bytes", fw_filename, data.len());
data
}
Err(e) => {
eprintln!(
"{} {} {:?}",
"Failed to download".red(),
fw_filename.red(),
e
);
continue;
}
};
let config_contents = if let Some(cn) = &config_filename {
match client.get(&url_for_file(cn)).await {
Ok(data) => {
println!("Downloaded {}: {} bytes", cn, data.len());
Some(data)
}
Err(e) => {
if config_needed {
eprintln!("{} {} {:?}", "Failed to download".red(), cn.red(), e);
continue;
} else {
eprintln!(
"{}",
format!("No config available as {cn}").fg::<css::Orange>()
);
None
}
}
}
} else {
None
};
fs::write(&fw_path, &fw_contents)?;
if !dl.no_parse && config_filename.is_some() {
println!("{} {}", "Parsing:".cyan(), &fw_filename);
match Firmware::parse(&fw_contents).map_err(|e| anyhow!("Parse error: {:?}", e)) {
Ok(fw) => dump_firmware_desc(&fw),
Err(e) => {
eprintln!(
"{} {:?}",
"Could not parse firmware:".fg::<css::Orange>(),
e
);
}
}
}
if let Some((cp, cd)) = config_path
.as_ref()
.and_then(|p| config_contents.map(|c| (p, c)))
{
fs::write(cp, &cd)?;
}
}
Ok(())
}
pub(crate) fn parse(firmware_path: &path::Path) -> PyResult<()> {
let contents = fs::read(firmware_path)?;
let fw = Firmware::parse(&contents)
// squish the error into a string to avoid the error type requiring that the input be
// 'static
.map_err(|e| anyhow!("Parse error: {:?}", e))?;
dump_firmware_desc(&fw);
Ok(())
}
pub(crate) async fn info(transport: &str, force: bool) -> PyResult<()> {
let transport = Transport::open(transport).await?;
let mut host = Host::new(transport.source()?, transport.sink()?)?;
host.reset(DriverFactory::None).await?;
if !force && !Driver::check(&host).await? {
println!("USB device not supported by this RTK driver");
} else if let Some(driver_info) = Driver::driver_info_for_host(&host).await? {
println!("Driver:");
println!(" {:10} {:04X}", "ROM:", driver_info.rom()?);
println!(" {:10} {}", "Firmware:", driver_info.firmware_name()?);
println!(
" {:10} {}",
"Config:",
driver_info.config_name()?.unwrap_or_default()
);
} else {
println!("Firmware already loaded or no supported driver for this device.")
}
Ok(())
}
pub(crate) async fn load(transport: &str, force: bool) -> PyResult<()> {
let transport = Transport::open(transport).await?;
let mut host = Host::new(transport.source()?, transport.sink()?)?;
host.reset(DriverFactory::None).await?;
match Driver::for_host(&host, force).await? {
None => {
eprintln!("Firmware already loaded or no supported driver for this device.");
}
Some(mut d) => d.download_firmware().await?,
};
Ok(())
}
pub(crate) async fn drop(transport: &str) -> PyResult<()> {
let transport = Transport::open(transport).await?;
let mut host = Host::new(transport.source()?, transport.sink()?)?;
host.reset(DriverFactory::None).await?;
Driver::drop_firmware(&mut host).await?;
Ok(())
}
fn dump_firmware_desc(fw: &Firmware) {
println!(
"Firmware: version=0x{:08X} project_id=0x{:04X}",
fw.version(),
fw.project_id()
);
for p in fw.patches() {
println!(
" Patch: chip_id=0x{:04X}, {} bytes, SVN Version={:08X}",
p.chip_id(),
p.contents().len(),
p.svn_version()
)
}
}
struct SimpleClient {
client: reqwest::Client,
}
impl SimpleClient {
fn new() -> Self {
Self {
client: reqwest::Client::new(),
}
}
async fn get(&self, url: &str) -> anyhow::Result<Vec<u8>> {
let resp = self.client.get(url).send().await?;
if !resp.status().is_success() {
return Err(anyhow!("Bad status: {}", resp.status()));
}
let bytes = resp.bytes().await?;
Ok(bytes.as_ref().to_vec())
}
}
+17
View File
@@ -0,0 +1,17 @@
// 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
//
// http://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.
pub(crate) mod firmware;
pub(crate) mod usb;
@@ -23,7 +23,6 @@
//! whether it is a Bluetooth device that uses a non-standard Class, or some other
//! type of device (there's no way to tell).
use clap::Parser as _;
use itertools::Itertools as _;
use owo_colors::{OwoColorize, Style};
use rusb::{Device, DeviceDescriptor, Direction, TransferType, UsbContext};
@@ -31,15 +30,12 @@ use std::{
collections::{HashMap, HashSet},
time::Duration,
};
const USB_DEVICE_CLASS_DEVICE: u8 = 0x00;
const USB_DEVICE_CLASS_WIRELESS_CONTROLLER: u8 = 0xE0;
const USB_DEVICE_SUBCLASS_RF_CONTROLLER: u8 = 0x01;
const USB_DEVICE_PROTOCOL_BLUETOOTH_PRIMARY_CONTROLLER: u8 = 0x01;
fn main() -> anyhow::Result<()> {
let cli = Cli::parse();
pub(crate) fn probe(verbose: bool) -> anyhow::Result<()> {
let mut bt_dev_count = 0;
let mut device_serials_by_id: HashMap<(u16, u16), HashSet<String>> = HashMap::new();
for device in rusb::devices()?.iter() {
@@ -159,7 +155,7 @@ fn main() -> anyhow::Result<()> {
println!("{:26}{}", " Product:".green(), p);
}
if cli.verbose {
if verbose {
print_device_details(&device, &device_desc)?;
}
@@ -332,11 +328,3 @@ impl From<&DeviceDescriptor> for ClassInfo {
)
}
}
#[derive(clap::Parser)]
#[command(author, version, about, long_about = None)]
struct Cli {
/// Show additional info for each USB device
#[arg(long, default_value_t = false)]
verbose: bool,
}
+17
View File
@@ -0,0 +1,17 @@
// 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
//
// http://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.
//! Device drivers
pub(crate) mod rtk;
+253
View File
@@ -0,0 +1,253 @@
// 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
//
// http://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.
//! Drivers for Realtek controllers
use nom::{bytes, combinator, error, multi, number, sequence};
/// Realtek firmware file contents
pub struct Firmware {
version: u32,
project_id: u8,
patches: Vec<Patch>,
}
impl Firmware {
/// Parse a `*_fw.bin` file
pub fn parse(input: &[u8]) -> Result<Self, nom::Err<error::Error<&[u8]>>> {
let extension_sig = [0x51, 0x04, 0xFD, 0x77];
let (_rem, (_tag, fw_version, patch_count, payload)) =
combinator::all_consuming(combinator::map_parser(
// ignore the sig suffix
sequence::terminated(
bytes::complete::take(
// underflow will show up as parse failure
input.len().saturating_sub(extension_sig.len()),
),
bytes::complete::tag(extension_sig.as_slice()),
),
sequence::tuple((
bytes::complete::tag(b"Realtech"),
// version
number::complete::le_u32,
// patch count
combinator::map(number::complete::le_u16, |c| c as usize),
// everything else except suffix
combinator::rest,
)),
))(input)?;
// ignore remaining input, since patch offsets are relative to the complete input
let (_rem, (chip_ids, patch_lengths, patch_offsets)) = sequence::tuple((
// chip id
multi::many_m_n(patch_count, patch_count, number::complete::le_u16),
// patch length
multi::many_m_n(patch_count, patch_count, number::complete::le_u16),
// patch offset
multi::many_m_n(patch_count, patch_count, number::complete::le_u32),
))(payload)?;
let patches = chip_ids
.into_iter()
.zip(patch_lengths.into_iter())
.zip(patch_offsets.into_iter())
.map(|((chip_id, patch_length), patch_offset)| {
combinator::map(
sequence::preceded(
bytes::complete::take(patch_offset),
// ignore trailing 4-byte suffix
sequence::terminated(
// patch including svn version, but not suffix
combinator::consumed(sequence::preceded(
// patch before svn version or version suffix
// prefix length underflow will show up as parse failure
bytes::complete::take(patch_length.saturating_sub(8)),
// svn version
number::complete::le_u32,
)),
// dummy suffix, overwritten with firmware version
bytes::complete::take(4_usize),
),
),
|(patch_contents_before_version, svn_version): (&[u8], u32)| {
let mut contents = patch_contents_before_version.to_vec();
// replace what would have been the trailing dummy suffix with fw version
contents.extend_from_slice(&fw_version.to_le_bytes());
Patch {
contents,
svn_version,
chip_id,
}
},
)(input)
.map(|(_rem, output)| output)
})
.collect::<Result<Vec<_>, _>>()?;
// look for project id from the end
let mut offset = payload.len();
let mut project_id: Option<u8> = None;
while offset >= 2 {
// Won't panic, since offset >= 2
let chunk = &payload[offset - 2..offset];
let length: usize = chunk[0].into();
let opcode = chunk[1];
offset -= 2;
if opcode == 0xFF {
break;
}
if length == 0 {
// report what nom likely would have done, if nom was good at parsing backwards
return Err(nom::Err::Error(error::Error::new(
chunk,
error::ErrorKind::Verify,
)));
}
if opcode == 0 && length == 1 {
project_id = offset
.checked_sub(1)
.and_then(|index| payload.get(index))
.copied();
break;
}
offset -= length;
}
match project_id {
Some(project_id) => Ok(Firmware {
project_id,
version: fw_version,
patches,
}),
None => {
// we ran out of file without finding a project id
Err(nom::Err::Error(error::Error::new(
payload,
error::ErrorKind::Eof,
)))
}
}
}
/// Patch version
pub fn version(&self) -> u32 {
self.version
}
/// Project id
pub fn project_id(&self) -> u8 {
self.project_id
}
/// Patches
pub fn patches(&self) -> &[Patch] {
&self.patches
}
}
/// Patch in a [Firmware}
pub struct Patch {
chip_id: u16,
contents: Vec<u8>,
svn_version: u32,
}
impl Patch {
/// Chip id
pub fn chip_id(&self) -> u16 {
self.chip_id
}
/// Contents of the patch, including the 4-byte firmware version suffix
pub fn contents(&self) -> &[u8] {
&self.contents
}
/// SVN version
pub fn svn_version(&self) -> u32 {
self.svn_version
}
}
#[cfg(test)]
mod tests {
use super::*;
use anyhow::anyhow;
use std::{fs, io, path};
#[test]
fn parse_firmware_rtl8723b() -> anyhow::Result<()> {
let fw = Firmware::parse(&firmware_contents("rtl8723b_fw_structure.bin")?)
.map_err(|e| anyhow!("{:?}", e))?;
let fw_version = 0x0E2F9F73;
assert_eq!(fw_version, fw.version());
assert_eq!(0x0001, fw.project_id());
assert_eq!(
vec![(0x0001, 0x00002BBF, 22368,), (0x0002, 0x00002BBF, 22496,),],
patch_summaries(fw, fw_version)
);
Ok(())
}
#[test]
fn parse_firmware_rtl8761bu() -> anyhow::Result<()> {
let fw = Firmware::parse(&firmware_contents("rtl8761bu_fw_structure.bin")?)
.map_err(|e| anyhow!("{:?}", e))?;
let fw_version = 0xDFC6D922;
assert_eq!(fw_version, fw.version());
assert_eq!(0x000E, fw.project_id());
assert_eq!(
vec![(0x0001, 0x00005060, 14048,), (0x0002, 0xD6D525A4, 30204,),],
patch_summaries(fw, fw_version)
);
Ok(())
}
fn firmware_contents(filename: &str) -> io::Result<Vec<u8>> {
fs::read(
path::PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.join("resources/test/firmware/realtek")
.join(filename),
)
}
/// Return a tuple of (chip id, svn version, contents len, contents sha256)
fn patch_summaries(fw: Firmware, fw_version: u32) -> Vec<(u16, u32, usize)> {
fw.patches()
.iter()
.map(|p| {
let contents = p.contents();
let mut dummy_contents = dummy_contents(contents.len());
dummy_contents.extend_from_slice(&p.svn_version().to_le_bytes());
dummy_contents.extend_from_slice(&fw_version.to_le_bytes());
assert_eq!(&dummy_contents, contents);
(p.chip_id(), p.svn_version(), contents.len())
})
.collect::<Vec<_>>()
}
fn dummy_contents(len: usize) -> Vec<u8> {
let mut vec = (len as u32).to_le_bytes().as_slice().repeat(len / 4 + 1);
assert!(vec.len() >= len);
// leave room for svn version and firmware version
vec.truncate(len - 8);
vec
}
}
+20
View File
@@ -0,0 +1,20 @@
// 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
//
// http://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.
//! It's not clear where to put Rust code that isn't simply a wrapper around Python. Until we have
//! a good answer for what to do there, the idea is to put it in this (non-public) module, and
//! `pub use` it into the relevant areas of the `wrapper` module so that it's still easy for users
//! to discover.
pub(crate) mod drivers;
+2
View File
@@ -29,3 +29,5 @@
pub mod wrapper;
pub mod adv;
pub(crate) mod internal;
+179
View File
@@ -0,0 +1,179 @@
// 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
//
// http://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.
//! CLI tools for Bumble
#![deny(missing_docs, unsafe_code)]
use bumble::wrapper::logging::{bumble_env_logging_level, py_logging_basic_config};
use clap::Parser as _;
use pyo3::PyResult;
use std::{fmt, path};
mod cli;
#[pyo3_asyncio::tokio::main]
async fn main() -> PyResult<()> {
env_logger::builder()
.filter_level(log::LevelFilter::Info)
.init();
py_logging_basic_config(bumble_env_logging_level("INFO"))?;
let cli: Cli = Cli::parse();
match cli.subcommand {
Subcommand::Firmware { subcommand: fw } => match fw {
Firmware::Realtek { subcommand: rtk } => match rtk {
Realtek::Download(dl) => {
cli::firmware::rtk::download(dl).await?;
}
Realtek::Drop { transport } => cli::firmware::rtk::drop(&transport).await?,
Realtek::Info { transport, force } => {
cli::firmware::rtk::info(&transport, force).await?;
}
Realtek::Load { transport, force } => {
cli::firmware::rtk::load(&transport, force).await?
}
Realtek::Parse { firmware_path } => cli::firmware::rtk::parse(&firmware_path)?,
},
},
Subcommand::Usb { subcommand } => match subcommand {
Usb::Probe(probe) => cli::usb::probe(probe.verbose)?,
},
}
Ok(())
}
#[derive(clap::Parser)]
struct Cli {
#[clap(subcommand)]
subcommand: Subcommand,
}
#[derive(clap::Subcommand, Debug, Clone)]
enum Subcommand {
/// Manage device firmware
Firmware {
#[clap(subcommand)]
subcommand: Firmware,
},
/// USB operations
Usb {
#[clap(subcommand)]
subcommand: Usb,
},
}
#[derive(clap::Subcommand, Debug, Clone)]
enum Firmware {
/// Manage Realtek chipset firmware
Realtek {
#[clap(subcommand)]
subcommand: Realtek,
},
}
#[derive(clap::Subcommand, Debug, Clone)]
enum Realtek {
/// Download Realtek firmware
Download(Download),
/// Drop firmware from a USB device
Drop {
/// Bumble transport spec. Must be for a USB device.
///
/// <https://google.github.io/bumble/transports/index.html>
#[arg(long)]
transport: String,
},
/// Show driver info for a USB device
Info {
/// Bumble transport spec. Must be for a USB device.
///
/// <https://google.github.io/bumble/transports/index.html>
#[arg(long)]
transport: String,
/// Try to resolve driver info even if USB info is not available, or if the USB
/// (vendor,product) tuple is not in the list of known compatible RTK USB dongles.
#[arg(long, default_value_t = false)]
force: bool,
},
/// Load firmware onto a USB device
Load {
/// Bumble transport spec. Must be for a USB device.
///
/// <https://google.github.io/bumble/transports/index.html>
#[arg(long)]
transport: String,
/// Load firmware even if the USB info doesn't match.
#[arg(long, default_value_t = false)]
force: bool,
},
/// Parse a firmware file
Parse {
/// Firmware file to parse
firmware_path: path::PathBuf,
},
}
#[derive(clap::Args, Debug, Clone)]
struct Download {
/// Directory to download to. Defaults to an OS-specific path specific to the Bumble tool.
#[arg(long)]
output_dir: Option<path::PathBuf>,
/// Source to download from
#[arg(long, default_value_t = Source::LinuxKernel)]
source: Source,
/// Only download a single image
#[arg(long, value_name = "base name")]
single: Option<String>,
/// Overwrite existing files
#[arg(long, default_value_t = false)]
overwrite: bool,
/// Don't print the parse results for the downloaded file names
#[arg(long)]
no_parse: bool,
}
#[derive(Debug, Clone, clap::ValueEnum)]
enum Source {
LinuxKernel,
RealtekOpensource,
LinuxFromScratch,
}
impl fmt::Display for Source {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Source::LinuxKernel => write!(f, "linux-kernel"),
Source::RealtekOpensource => write!(f, "realtek-opensource"),
Source::LinuxFromScratch => write!(f, "linux-from-scratch"),
}
}
}
#[derive(clap::Subcommand, Debug, Clone)]
enum Usb {
/// Probe the USB bus for Bluetooth devices
Probe(Probe),
}
#[derive(clap::Args, Debug, Clone)]
struct Probe {
/// Show additional info for each USB device
#[arg(long, default_value_t = false)]
verbose: bool,
}
+24 -16
View File
@@ -20,12 +20,17 @@ use crate::{
core::AdvertisingData,
gatt_client::{ProfileServiceProxy, ServiceProxy},
hci::Address,
host::Host,
transport::{Sink, Source},
ClosureCallback,
ClosureCallback, PyObjectExt,
},
};
use pyo3::types::PyDict;
use pyo3::{intern, types::PyModule, PyObject, PyResult, Python, ToPyObject};
use pyo3::{
intern,
types::{PyDict, PyModule},
PyObject, PyResult, Python, ToPyObject,
};
use pyo3_asyncio::tokio::into_future;
use std::path;
/// A device that can send/receive HCI frames.
@@ -65,7 +70,7 @@ impl Device {
Python::with_gil(|py| {
self.0
.call_method0(py, intern!(py, "power_on"))
.and_then(|coroutine| pyo3_asyncio::tokio::into_future(coroutine.as_ref(py)))
.and_then(|coroutine| into_future(coroutine.as_ref(py)))
})?
.await
.map(|_| ())
@@ -76,7 +81,7 @@ impl Device {
Python::with_gil(|py| {
self.0
.call_method1(py, intern!(py, "connect"), (peer_addr,))
.and_then(|coroutine| pyo3_asyncio::tokio::into_future(coroutine.as_ref(py)))
.and_then(|coroutine| into_future(coroutine.as_ref(py)))
})?
.await
.map(Connection)
@@ -89,7 +94,7 @@ impl Device {
kwargs.set_item("filter_duplicates", filter_duplicates)?;
self.0
.call_method(py, intern!(py, "start_scanning"), (), Some(kwargs))
.and_then(|coroutine| pyo3_asyncio::tokio::into_future(coroutine.as_ref(py)))
.and_then(|coroutine| into_future(coroutine.as_ref(py)))
})?
.await
.map(|_| ())
@@ -123,6 +128,15 @@ impl Device {
.map(|_| ())
}
/// Returns the host used by the device, if any
pub fn host(&mut self) -> PyResult<Option<Host>> {
Python::with_gil(|py| {
self.0
.getattr(py, intern!(py, "host"))
.map(|obj| obj.into_option(Host::from))
})
}
/// Start advertising the data set with [Device.set_advertisement].
pub async fn start_advertising(&mut self, auto_restart: bool) -> PyResult<()> {
Python::with_gil(|py| {
@@ -131,7 +145,7 @@ impl Device {
self.0
.call_method(py, intern!(py, "start_advertising"), (), Some(kwargs))
.and_then(|coroutine| pyo3_asyncio::tokio::into_future(coroutine.as_ref(py)))
.and_then(|coroutine| into_future(coroutine.as_ref(py)))
})?
.await
.map(|_| ())
@@ -142,7 +156,7 @@ impl Device {
Python::with_gil(|py| {
self.0
.call_method0(py, intern!(py, "stop_advertising"))
.and_then(|coroutine| pyo3_asyncio::tokio::into_future(coroutine.as_ref(py)))
.and_then(|coroutine| into_future(coroutine.as_ref(py)))
})?
.await
.map(|_| ())
@@ -173,7 +187,7 @@ impl Peer {
Python::with_gil(|py| {
self.0
.call_method0(py, intern!(py, "discover_services"))
.and_then(|coroutine| pyo3_asyncio::tokio::into_future(coroutine.as_ref(py)))
.and_then(|coroutine| into_future(coroutine.as_ref(py)))
})?
.await
.and_then(|list| {
@@ -207,13 +221,7 @@ impl Peer {
let class = module.getattr(P::PROXY_CLASS_NAME)?;
self.0
.call_method1(py, intern!(py, "create_service_proxy"), (class,))
.map(|obj| {
if obj.is_none(py) {
None
} else {
Some(P::wrap(obj))
}
})
.map(|obj| obj.into_option(P::wrap))
})
}
}
+17
View File
@@ -0,0 +1,17 @@
// 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
//
// http://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.
//! Device drivers
pub mod rtk;
+141
View File
@@ -0,0 +1,141 @@
// 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
//
// http://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.
//! Drivers for Realtek controllers
use crate::wrapper::{host::Host, PyObjectExt};
use pyo3::{intern, types::PyModule, PyObject, PyResult, Python, ToPyObject};
use pyo3_asyncio::tokio::into_future;
pub use crate::internal::drivers::rtk::{Firmware, Patch};
/// Driver for a Realtek controller
pub struct Driver(PyObject);
impl Driver {
/// Locate the driver for the provided host.
pub async fn for_host(host: &Host, force: bool) -> PyResult<Option<Self>> {
Python::with_gil(|py| {
PyModule::import(py, intern!(py, "bumble.drivers.rtk"))?
.getattr(intern!(py, "Driver"))?
.call_method1(intern!(py, "for_host"), (&host.obj, force))
.and_then(into_future)
})?
.await
.map(|obj| obj.into_option(Self))
}
/// Check if the host has a known driver.
pub async fn check(host: &Host) -> PyResult<bool> {
Python::with_gil(|py| {
PyModule::import(py, intern!(py, "bumble.drivers.rtk"))?
.getattr(intern!(py, "Driver"))?
.call_method1(intern!(py, "check"), (&host.obj,))
.and_then(|obj| obj.extract::<bool>())
})
}
/// Find the [DriverInfo] for the host, if one matches
pub async fn driver_info_for_host(host: &Host) -> PyResult<Option<DriverInfo>> {
Python::with_gil(|py| {
PyModule::import(py, intern!(py, "bumble.drivers.rtk"))?
.getattr(intern!(py, "Driver"))?
.call_method1(intern!(py, "driver_info_for_host"), (&host.obj,))
.and_then(into_future)
})?
.await
.map(|obj| obj.into_option(DriverInfo))
}
/// Send a command to the device to drop firmware
pub async fn drop_firmware(host: &mut Host) -> PyResult<()> {
Python::with_gil(|py| {
PyModule::import(py, intern!(py, "bumble.drivers.rtk"))?
.getattr(intern!(py, "Driver"))?
.call_method1(intern!(py, "drop_firmware"), (&host.obj,))
.and_then(into_future)
})?
.await
.map(|_| ())
}
/// Load firmware onto the device.
pub async fn download_firmware(&mut self) -> PyResult<()> {
Python::with_gil(|py| {
self.0
.call_method0(py, intern!(py, "download_firmware"))
.and_then(|coroutine| into_future(coroutine.as_ref(py)))
})?
.await
.map(|_| ())
}
}
/// Metadata about a known driver & applicable device
pub struct DriverInfo(PyObject);
impl DriverInfo {
/// Returns a list of all drivers that Bumble knows how to handle.
pub fn all_drivers() -> PyResult<Vec<DriverInfo>> {
Python::with_gil(|py| {
PyModule::import(py, intern!(py, "bumble.drivers.rtk"))?
.getattr(intern!(py, "Driver"))?
.getattr(intern!(py, "DRIVER_INFOS"))?
.iter()?
.map(|r| r.map(|h| DriverInfo(h.to_object(py))))
.collect::<PyResult<Vec<_>>>()
})
}
/// The firmware file name to load from the filesystem, e.g. `foo_fw.bin`.
pub fn firmware_name(&self) -> PyResult<String> {
Python::with_gil(|py| {
self.0
.getattr(py, intern!(py, "fw_name"))?
.as_ref(py)
.extract::<String>()
})
}
/// The config file name, if any, to load from the filesystem, e.g. `foo_config.bin`.
pub fn config_name(&self) -> PyResult<Option<String>> {
Python::with_gil(|py| {
let obj = self.0.getattr(py, intern!(py, "config_name"))?;
let handle = obj.as_ref(py);
if handle.is_none() {
Ok(None)
} else {
handle
.extract::<String>()
.map(|s| if s.is_empty() { None } else { Some(s) })
}
})
}
/// Whether or not config is required.
pub fn config_needed(&self) -> PyResult<bool> {
Python::with_gil(|py| {
self.0
.getattr(py, intern!(py, "config_needed"))?
.as_ref(py)
.extract::<bool>()
})
}
/// ROM id
pub fn rom(&self) -> PyResult<u32> {
Python::with_gil(|py| self.0.getattr(py, intern!(py, "rom"))?.as_ref(py).extract())
}
}
+71
View File
@@ -0,0 +1,71 @@
// 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
//
// http://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.
//! Host-side types
use crate::wrapper::transport::{Sink, Source};
use pyo3::{intern, prelude::PyModule, types::PyDict, PyObject, PyResult, Python};
/// Host HCI commands
pub struct Host {
pub(crate) obj: PyObject,
}
impl Host {
/// Create a Host that wraps the provided obj
pub(crate) fn from(obj: PyObject) -> Self {
Self { obj }
}
/// Create a new Host
pub fn new(source: Source, sink: Sink) -> PyResult<Self> {
Python::with_gil(|py| {
PyModule::import(py, intern!(py, "bumble.host"))?
.getattr(intern!(py, "Host"))?
.call((source.0, sink.0), None)
.map(|any| Self { obj: any.into() })
})
}
/// Send a reset command and perform other reset tasks.
pub async fn reset(&mut self, driver_factory: DriverFactory) -> PyResult<()> {
Python::with_gil(|py| {
let kwargs = match driver_factory {
DriverFactory::None => {
let kw = PyDict::new(py);
kw.set_item("driver_factory", py.None())?;
Some(kw)
}
DriverFactory::Auto => {
// leave the default in place
None
}
};
self.obj
.call_method(py, intern!(py, "reset"), (), kwargs)
.and_then(|coroutine| pyo3_asyncio::tokio::into_future(coroutine.as_ref(py)))
})?
.await
.map(|_| ())
}
}
/// Driver factory to use when initializing a host
#[derive(Debug, Clone)]
pub enum DriverFactory {
/// Do not load drivers
None,
/// Load appropriate driver, if any is found
Auto,
}
+15 -1
View File
@@ -31,14 +31,17 @@ pub use pyo3_asyncio;
pub mod assigned_numbers;
pub mod core;
pub mod device;
pub mod drivers;
pub mod gatt_client;
pub mod hci;
pub mod host;
pub mod logging;
pub mod profile;
pub mod transport;
/// Convenience extensions to [PyObject]
pub trait PyObjectExt {
pub trait PyObjectExt: Sized {
/// Get a GIL-bound reference
fn gil_ref<'py>(&'py self, py: Python<'py>) -> &'py PyAny;
@@ -49,6 +52,17 @@ pub trait PyObjectExt {
{
Python::with_gil(|py| self.gil_ref(py).extract::<T>())
}
/// If the Python object is a Python `None`, return a Rust `None`, otherwise `Some` with the mapped type
fn into_option<T>(self, map_obj: impl Fn(Self) -> T) -> Option<T> {
Python::with_gil(|py| {
if self.gil_ref(py).is_none() {
None
} else {
Some(map_obj(self))
}
})
}
}
impl PyObjectExt for PyObject {
+5 -8
View File
@@ -14,7 +14,10 @@
//! GATT profiles
use crate::wrapper::gatt_client::{CharacteristicProxy, ProfileServiceProxy};
use crate::wrapper::{
gatt_client::{CharacteristicProxy, ProfileServiceProxy},
PyObjectExt,
};
use pyo3::{intern, PyObject, PyResult, Python};
/// Exposes the battery GATT service
@@ -26,13 +29,7 @@ impl BatteryServiceProxy {
Python::with_gil(|py| {
self.0
.getattr(py, intern!(py, "battery_level"))
.map(|level| {
if level.is_none(py) {
None
} else {
Some(CharacteristicProxy(level))
}
})
.map(|level| level.into_option(CharacteristicProxy))
})
}
}
+9 -8
View File
@@ -32,17 +32,18 @@ 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'
platformdirs == 3.10.0; 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 +82,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
-28
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>
+30
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
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.
+7 -62
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 = []
+123 -5
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
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
+7 -3
View File
@@ -67,8 +67,9 @@ def download_file(base_url, name, remove_suffix):
@click.command
@click.option(
"--output-dir",
default=".",
help="Output directory where the files will be saved",
default="",
help="Output directory where the files will be saved. Defaults to the OS-specific"
"app data dir, which the driver will check when trying to find firmware",
show_default=True,
)
@click.option(
@@ -84,7 +85,10 @@ def main(output_dir, source, single, force, parse):
"""Download RTK firmware images and configs."""
# Check that the output dir exists
output_dir = pathlib.Path(output_dir)
if output_dir == '':
output_dir = rtk.rtk_firmware_dir()
else:
output_dir = pathlib.Path(output_dir)
if not output_dir.is_dir():
print("Output dir does not exist or is not a directory")
return
+2 -3
View File
@@ -61,9 +61,8 @@ async def do_load(usb_transport, force):
# Get the driver.
driver = await rtk.Driver.for_host(host, force)
if driver is None:
if not force:
print("Firmware already loaded or no supported driver for this device.")
return
print("Firmware already loaded or no supported driver for this device.")
return
await driver.download_firmware()
+48
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
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")
});
}
-131
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
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>
+21 -33
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
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
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
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
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
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)