Compare commits

...

33 Commits

Author SHA1 Message Date
uier 13c6090f8c transport/usb: Handle missing interruptEventHandler gracefully
Wrap `interruptEventHandler()` in a try-catch block to handle
unsupported operations on older libusb versions.
2026-07-08 07:22:22 +00:00
Josh Wu 2111c49a2c Merge pull request #897 from zxzxwu/dependency
Refine dependencies
2026-07-02 14:11:46 +08:00
Josh Wu adef270195 Refine dependencies 2026-07-02 13:48:25 +08:00
TzuWei 8015e528f2 Merge pull request #943 from google/cis-fix-unidirectional
CIG: Fix CIG parameters for unidirectional CIS
2026-07-01 14:21:43 +08:00
uier 9f2fdf5309 Add explanatory comments in post init method
Clarify why we reset retransmission count and PHY to 0 when the SDU size
is 0 for a direction.
2026-07-01 05:57:48 +00:00
uier 7d816b0c6c CIG: Fix CIG parameters for unidirectional CIS
When setting up a CIG for a unidirectional CIS (e.g.,
Central-to-Peripheral only), the unused direction's max SDU size is
set to 0. However, the other parameters (retransmission count and PHY)
defaulted to non-zero values. This caused controllers to reject the
configuration during CIG setup or CIS establishment with error 0x30
(Parameter Out Of Mandatory Range).

This change resets the retransmission count and PHY to 0 for any
direction where max SDU is 0. A test_cis_parameters_unidirectional test
is included with this change.
2026-07-01 05:57:33 +00:00
Josh Wu 47fcd8e9ec Merge pull request #944 from zxzxwu/data-length-class
Refactor connection data length to use dataclass
2026-06-29 17:37:57 +08:00
Gilles Boccon-Gibod fe991f67da Merge pull request #947 from greateggsgreg/safe-libusb-callback-scheduling
Bugfix: Ignore libusb callback scheduling after the asyncio loop is closed
2026-06-27 12:46:30 +02:00
Gilles Boccon-Gibod 82a5acc290 Merge pull request #946 from greateggsgreg/hci-num-completed-packets-enabled
Bugfix - Keep HCI Number Of Completed Packets enabled in the host event mask
2026-06-27 12:46:11 +02:00
greateggsgreg 022ccf3d70 Update usb_test.py 2026-06-26 23:25:16 -04:00
greateggsgreg 8a0992c00e Update usb.py 2026-06-26 23:24:41 -04:00
greateggsgreg d7b007f3af Update host_test.py 2026-06-26 23:17:54 -04:00
greateggsgreg 7b0eb93fd8 Update host.py
`Device.power_on()` resets the host and explicitly sends `HCI_Set_Event_Mask`. That explicit mask omitted `HCI_NUMBER_OF_COMPLETED_PACKETS_EVENT`, even though the controller default mask enables it. That disables `HCI_Number_Of_Completed_Packets` events, which are the only path that returns ACL transmit credits to Bumble's data queue:

- The event-mask list is in `bumble/host.py:401`-`bumble/host.py:421`.
- LE buffer sizing reads `HCI_LE_Read_Buffer_Size[_V2]` and stores
  `total_num_le_acl_data_packets` in the queue limit at `bumble/host.py:552`-
  `bumble/host.py:590`.
- `DataPacketQueue` sends only while `_in_flight < max_in_flight` at
  `bumble/host.py:140`-`bumble/host.py:147`.
- Completed-packets credits are consumed by
  `on_hci_number_of_completed_packets_event()` at `bumble/host.py:1169`-
  `bumble/host.py:1175`, which calls `DataPacketQueue.on_packets_completed()`.
- `DataPacketQueue.on_packets_completed()` decrements `_in_flight` and drains
  queued packets at `bumble/host.py:149`-`bumble/host.py:180`.

With `HCI_NUMBER_OF_COMPLETED_PACKETS_EVENT` masked off, `_in_flight` reaches `max_in_flight` and never decreases, so later ACL packets enqueue indefinitely.
2026-06-26 23:16:19 -04:00
Gilles Boccon-Gibod a8851b4759 Merge pull request #939 from google/gbg/update-libusb-package
update libusb-package version
2026-06-25 21:49:06 +02:00
Gilles Boccon-Gibod 7a72b225d4 update libusb-package version 2026-06-25 21:03:08 +02:00
Josh Wu e5e51fd3f2 Refactor connection data length to use dataclass
- Change Connection.data_length from a tuple to a structured LeDataLength dataclass.
- Update apps/bench.py and bumble/device.py to use named attributes instead of tuple indices.

TAG=agy
CONV=484c0d1f-7a90-4920-b8e9-48e1b208535f
2026-06-25 01:14:12 +08:00
TzuWei a889dbae31 Merge pull request #937 from google/l2cap-disconnect-abort-hang
l2cap: Resolve teardown hang on disconnect collision or abort
2026-06-24 17:05:10 +08:00
uier 8a3c8c735e l2cap: Simplify abort and disconnection response handling
Also includes the test_disconnection_collision unit test.
2026-06-24 08:22:56 +00:00
uier 3a7dcd1377 l2cap: Resolve teardown hang on disconnect collision or abort
Resolve a teardown hang in LeCreditBasedChannel. When a disconnection
collision occurs (both DUT and peer call disconnect simultaneously) or the
channel is aborted during disconnection, the connection state transitions
to DISCONNECTED before the peer's response arrives (or is ignored).
In these cases, the `disconnection_result` future remained unresolved,
causing any awaiting teardown task to hang.

This patch ensures that calling abort() or receiving a disconnection request
while in the DISCONNECTING state correctly resolves `disconnection_result` and
cleans up the channel.

Verification:
Verified with a new unit test `test_abort_while_disconnecting` added to
`tests/l2cap_test.py` that stubs a non-responsive peer and calls abort()
during the DISCONNECTING state transition, confirming it completes immediately.
2026-06-24 08:22:45 +00:00
Josh Wu 36c7694c44 Merge pull request #940 from google/gbg/update-pytest
remove trailing commas in pytest parametrize argname
2026-06-24 16:05:53 +08:00
Gilles Boccon-Gibod 16dd5ae63d remove trailing commas in pytest parametrize argname 2026-06-21 13:31:03 +02:00
uier 3c8fe5637d tests: Fix pytest 9.1 compat by removing trailing commas in parametrize and setting asyncio_mode 2026-06-16 12:18:12 +00:00
Gilles Boccon-Gibod 3266d16cf1 Merge pull request #936 from google/gbg/usb-transport-packet-splitter
usb transport packet splitter
2026-06-07 12:52:53 +02:00
Gilles Boccon-Gibod 65c4f9a698 add unit test 2026-06-07 12:30:38 +02:00
Josh Wu 17bc5566aa Merge pull request #932 from zxzxwu/usb-iso-bulk-workaround
fix(usb): support LE ISO data over Bulk endpoints
2026-06-04 15:45:08 +08:00
Gilles Boccon-Gibod b6a21fa3c6 use multiple in transfers for isochronous endpoints 2026-06-03 21:23:34 +02:00
Josh Wu 7a14ebdabe fix(usb): add transport layer support for sending ISO over Bulk Out
This change adds the missing transport-side support for sending HCI ISO Data packets
over the default Bulk Out endpoint when Isochronous endpoints are not enabled.
- Handles HCI_ISO_DATA_PACKET (0x05) in both `usb` and `pyusb` transports.
- Adds unit tests to verify the routing behavior.

TAG=agy
CONV=5502c76b-b272-4e43-a0b9-425a23cf137e
2026-06-03 22:08:39 +08:00
Gilles Boccon-Gibod e44eaf2147 implement packet splitters 2026-06-03 15:55:41 +02:00
zxzxwu 17a202bc13 fix(usb): support LE ISO data over Bulk endpoints
This change implements a complete Bulk-only transport for LE Audio ISO
data (CIS/BIS) on USB controllers (like Intel BE200 and ASUSTek) that
send/expect ISO data over Bulk endpoints. It also improves the stability
and compatibility of periodic advertising sync on newer controllers.

Key Changes:
1. Host Layer Workaround (Bulk In):
   - Intercepts ACL packets using CIS/BIS handles on Bulk In.
   - Adaptively reconstructs them into HCI ISO Data packets:
     * For CIS (Unicast): Dynamically determines if the receiver controller
       includes a Timestamp in the ACL-wrapped payload (Intel does not,
       Realtek does) by checking the controller's company_identifier.
       It then correctly reconstructs either a 4-byte (TS_Flag = 0) or
       8-byte (TS_Flag = 1) ISO header.
     * For BIS (Broadcast): Reconstructs an 8-byte ISO header (TS_Flag = 1)
       as BIS packets always include the Timestamp.
     This vendor-adaptive approach dynamically supports both Unicast and
     Broadcast ISO across different controller hardware (Intel & Realtek) in
     all transmitter/receiver roles.
   - Cleans up the learned TS flags from memory when the link is disconnected.
2. USB Transport Layer (Bulk Out):
   - Adds support for sending HCI ISO Data packets over the default
     Bulk Out endpoint when Isochronous endpoints are not enabled.
3. LE Periodic Sync V2 Event Support:
   - Enables `HCI_LE_PERIODIC_ADVERTISING_SYNC_ESTABLISHED_V2_EVENT` in
     the LE event mask and implements its handler in Host. This supports
     periodic sync on BT 5.4 controllers (like Intel BE200) that use the
     V2 event.

This enables seamless LE Audio Broadcast/Unicast ISO receipt and
transmission on standard USB Bluetooth controllers without requiring
alternate interface activation (+sco is not needed).

TAG=agy
CONV=8b9a01f7-32cb-4a83-9300-23c4b688d861
2026-06-02 16:23:03 +08:00
Gilles Boccon-Gibod ef634953f0 Merge pull request #896 from google/gbg/usb-hci-sco
add basic support for SCO packets over USB
2026-06-01 18:34:59 +02:00
Josh Wu 72d821b1f6 Merge pull request #928 from zxzxwu/avdtp
AVDTP: Avoid explicit in_use management
2026-05-26 16:33:08 +08:00
Josh Wu afe064b4ea AVDTP: Make local stream endpoint in_use dyanmic property 2026-05-22 15:58:11 +08:00
Josh Wu 8d0cef70c2 AVDTP: Add keyword argument to long __init__ 2026-05-20 16:19:06 +08:00
32 changed files with 739 additions and 167 deletions
+1 -1
View File
@@ -12,7 +12,7 @@
// Use 'postCreateCommand' to run commands after the container is created.
"postCreateCommand":
"python -m pip install '.[build,test,development,documentation]'",
"python -m pip install '.[all]' --group dev",
// Configure tool-specific properties.
"customizations": {
+1 -1
View File
@@ -35,7 +35,7 @@ jobs:
- name: Install dependencies
run: |
python -m pip install --upgrade pip
python -m pip install ".[build,test,development]"
python -m pip install ".[all]" --group dev
- name: Check
run: |
invoke project.pre-commit
+1 -1
View File
@@ -34,7 +34,7 @@ jobs:
- name: Install
run: |
python -m pip install --upgrade pip
python -m pip install .[avatar]
python -m pip install .[all,avatar]
- name: Rootcanal
run: nohup python -m rootcanal > rootcanal.log &
- name: Test
+2 -2
View File
@@ -35,7 +35,7 @@ jobs:
- name: Install dependencies
run: |
python -m pip install --upgrade pip
python -m pip install ".[build,test,development,documentation]"
python -m pip install ".[all]" --group dev
- name: Test
run: |
invoke test
@@ -62,7 +62,7 @@ jobs:
- name: Install Python dependencies
run: |
python -m pip install --upgrade pip
python -m pip install ".[build,test,development,documentation]"
python -m pip install ".[all]" --group dev
- name: Install Rust toolchain
uses: actions-rust-lang/setup-rust-toolchain@v1
with:
+1 -1
View File
@@ -33,7 +33,7 @@ To install package dependencies needed to run the bumble examples, execute the f
```
python -m pip install --upgrade pip
python -m pip install ".[test,development,documentation]"
python -m pip install ".[all]" --group dev
```
### Examples
+1 -3
View File
@@ -40,9 +40,7 @@ else:
try:
import lc3 # type: ignore # pylint: disable=E0401
except ImportError as e:
raise ImportError(
"Try `python -m pip install \"git+https://github.com/google/liblc3.git\"`."
) from e
raise ImportError("Try `python -m pip install '.[auracast]'`.") from e
import bumble.device
import bumble.logging
+2 -2
View File
@@ -133,8 +133,8 @@ def print_connection(connection: Connection) -> None:
if connection.transport == PhysicalTransport.LE:
params.append(
'DL=('
f'TX:{connection.data_length[0]}/{connection.data_length[1]},'
f'RX:{connection.data_length[2]}/{connection.data_length[3]}'
f'TX:{connection.data_length.max_tx_octets}/{connection.data_length.max_tx_time},'
f'RX:{connection.data_length.max_rx_octets}/{connection.data_length.max_rx_time}'
')'
)
+1 -1
View File
@@ -30,7 +30,7 @@ from importlib import resources
try:
import lc3 # type: ignore # pylint: disable=E0401
except ImportError as e:
raise ImportError("Try `python -m pip install \".[lc3]\"`.") from e
raise ImportError("Try `python -m pip install \".[auracast]\"`.") from e
import aiohttp.web
import click
+41 -24
View File
@@ -17,6 +17,7 @@
# -----------------------------------------------------------------------------
from __future__ import annotations
import abc
import asyncio
import enum
import logging
@@ -1946,9 +1947,6 @@ class Stream:
await self.rtp_channel.disconnect()
self.rtp_channel = None
# Release the endpoint
self.local_endpoint.in_use = 0
self.change_state(State.IDLE)
async def on_set_configuration_command(
@@ -2039,7 +2037,6 @@ class Stream:
if self.rtp_channel is None:
# No channel to release, we're done
self.local_endpoint.in_use = 0
self.change_state(State.IDLE)
else:
# TODO: set a timer as we wait for the RTP channel to be closed
@@ -2051,7 +2048,6 @@ class Stream:
await self.local_endpoint.on_abort_command()
if self.rtp_channel is None:
# No need to wait
self.local_endpoint.in_use = 0
self.change_state(State.IDLE)
else:
# Wait for the RTP channel to be closed
@@ -2074,7 +2070,6 @@ class Stream:
def on_l2cap_channel_close(self) -> None:
logger.debug(color('<<< stream channel closed', 'magenta'))
self.local_endpoint.on_rtp_channel_close()
self.local_endpoint.in_use = 0
self.rtp_channel = None
if self.state in (State.CLOSING, State.ABORTING):
@@ -2099,7 +2094,6 @@ class Stream:
self.state = State.IDLE
local_endpoint.stream = self
local_endpoint.in_use = 1
def __str__(self) -> str:
return (
@@ -2109,14 +2103,16 @@ class Stream:
# -----------------------------------------------------------------------------
@dataclass
class StreamEndPoint:
class StreamEndPoint(abc.ABC):
seid: int
media_type: MediaType
tsep: StreamEndPointType
in_use: int
capabilities: Iterable[ServiceCapabilities]
@property
def in_use(self) -> int:
raise NotImplementedError()
# -----------------------------------------------------------------------------
class StreamEndPointProxy:
@@ -2156,14 +2152,30 @@ class DiscoveredStreamEndPoint(StreamEndPoint, StreamEndPointProxy):
in_use: int,
capabilities: Iterable[ServiceCapabilities],
) -> None:
StreamEndPoint.__init__(self, seid, media_type, tsep, in_use, capabilities)
StreamEndPointProxy.__init__(self, protocol, seid)
# StreamEndPoint attributes
self.seid = seid
self.media_type = media_type
self.tsep = tsep
self._in_use = in_use
self.capabilities = capabilities
StreamEndPointProxy.__init__(self, protocol=protocol, seid=seid)
@property
def in_use(self) -> int:
return self._in_use
# -----------------------------------------------------------------------------
class LocalStreamEndPoint(StreamEndPoint, utils.EventEmitter):
stream: Stream | None
@property
def in_use(self) -> int:
if self.stream and self.stream.state != State.IDLE:
return 1
return 0
EVENT_CONFIGURATION = "configuration"
EVENT_OPEN = "open"
EVENT_START = "start"
@@ -2186,8 +2198,13 @@ class LocalStreamEndPoint(StreamEndPoint, utils.EventEmitter):
capabilities: Iterable[ServiceCapabilities],
configuration: Iterable[ServiceCapabilities] | None = None,
):
StreamEndPoint.__init__(self, seid, media_type, tsep, 0, capabilities)
utils.EventEmitter.__init__(self)
# StreamEndPoint attributes
self.seid = seid
self.media_type = media_type
self.tsep = tsep
self.capabilities = capabilities
self.protocol = protocol
self.configuration = configuration if configuration is not None else []
self.stream = None
@@ -2273,12 +2290,12 @@ class LocalSource(LocalStreamEndPoint):
codec_capabilities,
] + list(other_capabilities)
super().__init__(
protocol,
seid,
codec_capabilities.media_type,
AVDTP_TSEP_SRC,
capabilities,
capabilities,
protocol=protocol,
seid=seid,
media_type=codec_capabilities.media_type,
tsep=AVDTP_TSEP_SRC,
capabilities=capabilities,
configuration=capabilities,
)
self.packet_pump = packet_pump
@@ -2317,11 +2334,11 @@ class LocalSink(LocalStreamEndPoint):
codec_capabilities,
]
super().__init__(
protocol,
seid,
codec_capabilities.media_type,
AVDTP_TSEP_SNK,
capabilities,
protocol=protocol,
seid=seid,
media_type=codec_capabilities.media_type,
tsep=AVDTP_TSEP_SNK,
capabilities=capabilities,
)
def on_rtp_channel_open(self) -> None:
+25 -6
View File
@@ -1575,6 +1575,18 @@ class CigParameters:
rtn_c_to_p: int = DEVICE_DEFAULT_ISO_CIS_RTN # Number of C->P retransmissions
rtn_p_to_c: int = DEVICE_DEFAULT_ISO_CIS_RTN # Number of P->C retransmissions
def __post_init__(self) -> None:
# For unidirectional CIS (e.g., Central-to-Peripheral only), the unused direction's
# SDU size is 0. If SDU size is 0, the corresponding retransmission count and PHY
# must also be set to 0. Otherwise, some controllers will reject the parameters
# with error 0x30 (Parameter Out Of Mandatory Range).
if self.max_sdu_c_to_p == 0:
self.rtn_c_to_p = 0
self.phy_c_to_p = hci.PhyBit(0)
if self.max_sdu_p_to_c == 0:
self.rtn_p_to_c = 0
self.phy_p_to_c = hci.PhyBit(0)
cig_id: int
cis_parameters: list[CisParameters]
sdu_interval_c_to_p: int # C->P SDU interval, in microseconds
@@ -1804,6 +1816,13 @@ class Connection(utils.CompositeEventEmitter):
subrate_factor: int = 1
continuation_number: int = 0
@dataclass(frozen=True)
class LeDataLength:
max_tx_octets: int
max_tx_time: int
max_rx_octets: int
max_rx_time: int
def __init__(
self,
device: Device,
@@ -1832,7 +1851,7 @@ class Connection(utils.CompositeEventEmitter):
self.authenticated = False
self.sc = False
self.att_mtu = att.ATT_DEFAULT_MTU
self.data_length: tuple[int, int, int, int] = DEVICE_DEFAULT_DATA_LENGTH
self.data_length = self.LeDataLength(*DEVICE_DEFAULT_DATA_LENGTH)
self.gatt_client = gatt_client.Client(self) # Per-connection client
self.gatt_server = (
device.gatt_server
@@ -6794,11 +6813,11 @@ class Device(utils.CompositeEventEmitter):
f'*** Connection Data Length Change: [0x{connection.handle:04X}] '
f'{connection.peer_address} as {connection.role_name}'
)
connection.data_length = (
max_tx_octets,
max_tx_time,
max_rx_octets,
max_rx_time,
connection.data_length = Connection.LeDataLength(
max_tx_octets=max_tx_octets,
max_tx_time=max_tx_time,
max_rx_octets=max_rx_octets,
max_rx_time=max_rx_time,
)
connection.emit(connection.EVENT_CONNECTION_DATA_LENGTH_CHANGE)
+95
View File
@@ -247,6 +247,7 @@ class Host(utils.EventEmitter):
bis_links: dict[int, IsoLink]
sco_links: dict[int, ScoLink]
bigs: dict[int, set[int]]
link_ts_flags: dict[int, int]
acl_packet_queue: DataPacketQueue | None = None
le_acl_packet_queue: DataPacketQueue | None = None
iso_packet_queue: DataPacketQueue | None = None
@@ -269,6 +270,7 @@ class Host(utils.EventEmitter):
self.bis_links = {} # BIS links, by connection handle
self.sco_links = {} # SCO links, by connection handle
self.bigs = {} # BIG Handle to BIS Handles
self.link_ts_flags = {} # TS_Flag for ISO links, by handle
self.pending_command: hci.HCI_SyncCommand | hci.HCI_AsyncCommand | None = None
self.pending_response: (
asyncio.Future[
@@ -416,6 +418,7 @@ class Host(utils.EventEmitter):
hci.HCI_HARDWARE_ERROR_EVENT,
hci.HCI_FLUSH_OCCURRED_EVENT,
hci.HCI_ROLE_CHANGE_EVENT,
hci.HCI_NUMBER_OF_COMPLETED_PACKETS_EVENT,
hci.HCI_MODE_CHANGE_EVENT,
hci.HCI_RETURN_LINK_KEYS_EVENT,
hci.HCI_PIN_CODE_REQUEST_EVENT,
@@ -486,6 +489,7 @@ class Host(utils.EventEmitter):
hci.HCI_LE_PHY_UPDATE_COMPLETE_EVENT,
hci.HCI_LE_EXTENDED_ADVERTISING_REPORT_EVENT,
hci.HCI_LE_PERIODIC_ADVERTISING_SYNC_ESTABLISHED_EVENT,
hci.HCI_LE_PERIODIC_ADVERTISING_SYNC_ESTABLISHED_V2_EVENT,
hci.HCI_LE_PERIODIC_ADVERTISING_REPORT_EVENT,
hci.HCI_LE_PERIODIC_ADVERTISING_SYNC_LOST_EVENT,
hci.HCI_LE_SCAN_TIMEOUT_EVENT,
@@ -1028,6 +1032,82 @@ class Host(utils.EventEmitter):
# Look for the connection to which this data belongs
if connection := self.connections.get(packet.connection_handle):
connection.on_hci_acl_data_packet(packet)
return
# WORKAROUND: Some controllers (e.g. Intel BE200) send ISO data wrapped in ACL packets
# using the CIS handle.
is_cis = packet.connection_handle in self.cis_links
is_bis = packet.connection_handle in self.bis_links
if is_cis or is_bis:
logger.debug(
f"Received ISO data wrapped in ACL packet for handle 0x{packet.connection_handle:04X}"
)
payload = packet.data
ts_flag = self.link_ts_flags.get(packet.connection_handle)
if ts_flag is None:
# Learn TS flag from the first packet on this link
if is_bis:
# BIS packets always have Timestamp according to spec
ts_flag = 1
elif len(payload) < 8:
# Too short to have 8-byte header (TS), must be No TS
ts_flag = 0
else:
psn_no_ts = int.from_bytes(payload[0:2], 'little')
psn_has_ts = int.from_bytes(payload[4:6], 'little')
if psn_has_ts == 0:
ts_flag = 1
elif psn_no_ts == 0:
ts_flag = 0
else:
# Fallback heuristic
ts_flag = 1 if psn_has_ts < psn_no_ts else 0
self.link_ts_flags[packet.connection_handle] = ts_flag
logger.info(
f"Learned TS_Flag = {ts_flag} for handle 0x{packet.connection_handle:04X}"
)
if ts_flag:
header_size = 8
sdu_length_offset = 6
else:
header_size = 4
sdu_length_offset = 2
pb_flag = 0b10
if len(payload) >= header_size:
sdu_length = int.from_bytes(
payload[sdu_length_offset : sdu_length_offset + 2], 'little'
)
if sdu_length == len(payload) - header_size:
pb_flag = 0b10 # Complete SDU
else:
pb_flag = 0b00 # First fragment
else:
pb_flag = 0b01 # Continuation
ts_flag = 0
# Reconstruct the raw ISO packet (excluding packet indicator 0x05)
pdu_info = packet.connection_handle | (pb_flag << 12) | (ts_flag << 14)
header = bytes(
[
pdu_info & 0xFF,
(pdu_info >> 8) & 0xFF,
len(payload) & 0xFF,
(len(payload) >> 8) & 0xFF,
]
)
raw_iso_packet = header + payload
try:
iso_packet = hci.HCI_IsoDataPacket.from_bytes(
bytes([hci.HCI_ISO_DATA_PACKET]) + raw_iso_packet
)
self.on_hci_iso_data_packet(iso_packet)
except Exception as e:
logger.warning(f"Failed to reconstruct ISO packet from ACL: {e}")
def on_hci_sco_data_packet(self, packet: hci.HCI_SynchronousDataPacket) -> None:
# Experimental
@@ -1251,6 +1331,7 @@ class Host(utils.EventEmitter):
self.emit('disconnection', handle, event.reason)
# Remove the handle reference
self.link_ts_flags.pop(handle, None)
_ = (
self.connections.pop(handle, 0)
or self.cis_links.pop(handle, 0)
@@ -1371,6 +1452,20 @@ class Host(utils.EventEmitter):
event.advertiser_clock_accuracy,
)
def on_hci_le_periodic_advertising_sync_established_v2_event(
self, event: hci.HCI_LE_Periodic_Advertising_Sync_Established_V2_Event
):
self.emit(
'periodic_advertising_sync_establishment',
event.status,
event.sync_handle,
event.advertising_sid,
event.advertiser_address,
event.advertiser_phy,
event.periodic_advertising_interval,
event.advertiser_clock_accuracy,
)
def on_hci_le_periodic_advertising_sync_lost_event(
self, event: hci.HCI_LE_Periodic_Advertising_Sync_Lost_Event
):
+32 -18
View File
@@ -1683,6 +1683,10 @@ class LeCreditBasedChannel(utils.EventEmitter):
if identifier in self.manager.le_coc_requests:
raise InvalidStateError('too many concurrent connection requests')
# Create a future to wait for the response
connection_result = asyncio.get_running_loop().create_future()
self.connection_result = connection_result
self._change_state(self.State.CONNECTING)
request = L2CAP_LE_Credit_Based_Connection_Request(
identifier=identifier,
@@ -1695,17 +1699,19 @@ class LeCreditBasedChannel(utils.EventEmitter):
self.manager.le_coc_requests[identifier] = request
self.send_control_frame(request)
# Create a future to wait for the response
self.connection_result = asyncio.get_running_loop().create_future()
# Wait for the connection to succeed or fail
return await self.connection_result
return await connection_result
async def disconnect(self) -> None:
# Check that we're connected
if self.state != self.State.CONNECTED:
raise InvalidStateError('not connected')
# Create a future to wait for the state machine to get to a success or error
# state
disconnection_result = asyncio.get_running_loop().create_future()
self.disconnection_result = disconnection_result
self._change_state(self.State.DISCONNECTING)
self.flush_output()
self.send_control_frame(
@@ -1716,17 +1722,18 @@ class LeCreditBasedChannel(utils.EventEmitter):
)
)
# Create a future to wait for the state machine to get to a success or error
# state
self.disconnection_result = asyncio.get_running_loop().create_future()
return await self.disconnection_result
return await disconnection_result
def abort(self) -> None:
if self.state == self.State.CONNECTED:
if self.state in (self.State.CONNECTED, self.State.DISCONNECTING):
self._change_state(self.State.DISCONNECTED)
if self.state == self.State.CONNECTING:
if self.connection_result is not None:
self.connection_result.cancel()
self.manager.on_channel_closed(self)
if self.connection_result is not None:
self.connection_result.cancel()
self.connection_result = None
if self.disconnection_result is not None:
self.disconnection_result.set_result(None)
self.disconnection_result = None
def on_pdu(self, pdu: bytes) -> None:
if self.sink is None:
@@ -1861,6 +1868,10 @@ class LeCreditBasedChannel(utils.EventEmitter):
)
)
self._change_state(self.State.DISCONNECTED)
self.manager.on_channel_closed(self)
if self.disconnection_result is not None:
self.disconnection_result.set_result(None)
self.disconnection_result = None
self.flush_output()
def on_disconnection_response(self, response: L2CAP_Disconnection_Response) -> None:
@@ -1876,6 +1887,7 @@ class LeCreditBasedChannel(utils.EventEmitter):
return
self._change_state(self.State.DISCONNECTED)
self.manager.on_channel_closed(self)
if self.disconnection_result:
self.disconnection_result.set_result(None)
self.disconnection_result = None
@@ -2461,7 +2473,7 @@ class ChannelManager:
if (
channel := self.find_channel(connection.handle, response.source_cid)
) is None:
logger.warning(
logger.debug(
color(
f'channel {response.source_cid} not found for '
f'0x{connection.handle:04X}:{cid}',
@@ -2879,11 +2891,13 @@ class ChannelManager:
channel.on_credits(credit.credits)
def on_channel_closed(self, channel: ClassicChannel) -> None:
connection_channels = self.channels.get(channel.connection.handle)
if connection_channels:
if channel.source_cid in connection_channels:
del connection_channels[channel.source_cid]
def on_channel_closed(self, channel: ClassicChannel | LeCreditBasedChannel) -> None:
if classic_connection_channels := self.channels.get(channel.connection.handle):
classic_connection_channels.pop(channel.source_cid, None)
elif le_connection_channels := self.le_coc_channels.get(
channel.connection.handle
):
le_connection_channels.pop(channel.destination_cid, None)
async def create_le_credit_based_channel(
self,
+12 -7
View File
@@ -28,13 +28,18 @@ from bumble.transport.common import (
)
# pylint: disable=no-name-in-module
from bumble.transport.grpc_protobuf.emulated_bluetooth_packets_pb2 import HCIPacket
from bumble.transport.grpc_protobuf.emulated_bluetooth_pb2_grpc import (
EmulatedBluetoothServiceStub,
)
from bumble.transport.grpc_protobuf.emulated_bluetooth_vhci_pb2_grpc import (
VhciForwardingServiceStub,
)
try:
from bumble.transport.grpc_protobuf.emulated_bluetooth_packets_pb2 import HCIPacket
from bumble.transport.grpc_protobuf.emulated_bluetooth_pb2_grpc import (
EmulatedBluetoothServiceStub,
)
from bumble.transport.grpc_protobuf.emulated_bluetooth_vhci_pb2_grpc import (
VhciForwardingServiceStub,
)
except ImportError as e:
raise ImportError(
'The bumble[android] extra is required to use the Android emulator transport'
) from e
# -----------------------------------------------------------------------------
# Logging
+21 -12
View File
@@ -37,18 +37,27 @@ from bumble.transport.common import (
)
# pylint: disable=no-name-in-module
from bumble.transport.grpc_protobuf.netsim.common_pb2 import ChipKind
from bumble.transport.grpc_protobuf.netsim.hci_packet_pb2 import HCIPacket
from bumble.transport.grpc_protobuf.netsim.packet_streamer_pb2 import (
PacketRequest,
PacketResponse,
)
from bumble.transport.grpc_protobuf.netsim.packet_streamer_pb2_grpc import (
PacketStreamerServicer,
PacketStreamerStub,
add_PacketStreamerServicer_to_server,
)
from bumble.transport.grpc_protobuf.netsim.startup_pb2 import Chip, ChipInfo, DeviceInfo
try:
from bumble.transport.grpc_protobuf.netsim.common_pb2 import ChipKind
from bumble.transport.grpc_protobuf.netsim.hci_packet_pb2 import HCIPacket
from bumble.transport.grpc_protobuf.netsim.packet_streamer_pb2 import (
PacketRequest,
PacketResponse,
)
from bumble.transport.grpc_protobuf.netsim.packet_streamer_pb2_grpc import (
PacketStreamerServicer,
PacketStreamerStub,
add_PacketStreamerServicer_to_server,
)
from bumble.transport.grpc_protobuf.netsim.startup_pb2 import (
Chip,
ChipInfo,
DeviceInfo,
)
except ImportError as e:
raise ImportError(
'The bumble[android] extra is required to use the Android netsim transport'
) from e
# -----------------------------------------------------------------------------
# Logging
+3
View File
@@ -104,6 +104,9 @@ async def open_pyusb_transport(spec: str) -> Transport:
0,
packet[1:],
)
elif packet_type == hci.HCI_ISO_DATA_PACKET:
# Workaround: Send ISO packets over Bulk Out
self.device.write(USB_ENDPOINT_ACL_OUT, packet[1:])
else:
logger.warning(
color(f'unsupported packet type {packet_type}', 'red')
+121 -37
View File
@@ -37,6 +37,20 @@ from bumble.transport.common import BaseSource, Transport, TransportInitError
logger = logging.getLogger(__name__)
# -----------------------------------------------------------------------------
def _safe_call_soon(loop: asyncio.AbstractEventLoop, callback, *args) -> None:
"""Schedule `callback` on `loop` from the libusb event thread, tolerating the
case where the loop has already been closed during process/transport
teardown. Without this guard, a libusb transfer callback that fires after the
asyncio loop is closed raises 'Event loop is closed' on the C callback
thread, which can escalate to a libusb mutex assertion and crash the process
(SIGABRT). This makes an unclean shutdown a no-op instead of a core dump."""
try:
loop.call_soon_threadsafe(callback, *args)
except RuntimeError:
pass # loop already closed; nothing left to schedule
# -----------------------------------------------------------------------------
# Constants
# -----------------------------------------------------------------------------
@@ -59,6 +73,8 @@ USB_BT_HCI_CLASS_TUPLE = (
)
MAX_SCO_PACKET_SIZE = 1024
MAX_SCO_IN_PACKETS = 128
NUMBER_OF_SCO_IN_TRANSFERS = 2
# -----------------------------------------------------------------------------
@@ -264,7 +280,7 @@ class UsbPacketSink:
self.packets.put_nowait(packet)
def transfer_callback(self, transfer):
self.loop.call_soon_threadsafe(self.out_transfer_ready.release)
_safe_call_soon(self.loop, self.out_transfer_ready.release)
status = transfer.getStatus()
logger.debug(f"OUT CALLBACK: {status}")
@@ -336,6 +352,25 @@ class UsbPacketSink:
)
self.isochronous_out_transfer.submit()
submitted = True
elif packet_type == hci.HCI_ISO_DATA_PACKET:
if self.isochronous_out_transfer is None:
# Workaround: Send ISO packets over Bulk Out when Isochronous endpoints are not enabled
self.bulk_or_control_out_transfer.setBulk(
self.bulk_out.getAddress(),
packet_payload,
callback=self.transfer_callback,
)
self.bulk_or_control_out_transfer.submit()
submitted = True
else:
logger.warning(
color(
'ISO packets over Isochronous endpoints not supported yet',
'red',
)
)
self.out_transfer_ready.release()
continue
else:
logger.warning(
color(f'unsupported packet type {packet_type}', 'red')
@@ -388,20 +423,36 @@ class UsbPacketSink:
READ_SIZE = 4096
class ScoAccumulator:
def __init__(self, emit: Callable[[bytes], Any]) -> None:
class PacketSplitter:
"""Splitter than can parse a byte stream and extract packets that consist of a
header and a body, where the header includes an n-byte 'length' field at a
certain offset.
Extracted packets are emitted by calling a function passed to the constructor,
with the full packet (header + body) as argument.
"""
def __init__(
self, length_offset: int, length_size: int, emit: Callable[[bytes], Any]
) -> None:
self.emit = emit
self.packet = b''
self.length_offset = length_offset
self.length_size = length_size
self.header_size = length_offset + length_size
def feed(self, data: bytes) -> None:
while data:
# Accumulate until we have a complete 3-byte header
if (bytes_needed := 3 - len(self.packet)) > 0:
# Accumulate until we have a complete header
if (bytes_needed := self.header_size - len(self.packet)) > 0:
self.packet += data[:bytes_needed]
data = data[bytes_needed:]
continue
if len(self.packet) < self.header_size:
continue
packet_length = 3 + self.packet[2]
packet_length = self.header_size + int.from_bytes(
self.packet[self.length_offset : self.length_offset + self.length_size],
'little',
)
bytes_needed = packet_length - len(self.packet)
self.packet += data[:bytes_needed]
data = data[bytes_needed:]
@@ -411,6 +462,24 @@ class ScoAccumulator:
self.packet = b''
class ScoPacketSplitter(PacketSplitter):
def __init__(self, emit: Callable[[bytes], Any]) -> None:
# The length field is 1 byte at offset 2 in the HCI SCO packet header
super().__init__(length_offset=2, length_size=1, emit=emit)
class EventPacketSplitter(PacketSplitter):
def __init__(self, emit: Callable[[bytes], Any]) -> None:
# The length field is 1 byte at offset 1 in the HCI Event packet header
super().__init__(length_offset=1, length_size=1, emit=emit)
class AclPacketSplitter(PacketSplitter):
def __init__(self, emit: Callable[[bytes], Any]) -> None:
# The length field is 2 bytes at offset 2 in the HCI ACL packet header
super().__init__(length_offset=2, length_size=2, emit=emit)
class UsbPacketSource(asyncio.Protocol, BaseSource):
def __init__(self, device, metadata, interrupt_in, bulk_in, isochronous_in):
super().__init__()
@@ -421,17 +490,23 @@ class UsbPacketSource(asyncio.Protocol, BaseSource):
self.bulk_in = bulk_in
self.bulk_in_transfer = None
self.isochronous_in = isochronous_in
self.isochronous_in_transfer = None
self.isochronous_accumulator = ScoAccumulator(
lambda packet: self.queue_packet(hci.HCI_SYNCHRONOUS_DATA_PACKET, packet)
)
self.isochronous_in_transfers = []
self.loop = asyncio.get_running_loop()
self.queue = asyncio.Queue()
self.dequeue_task = None
self.done = {
hci.HCI_EVENT_PACKET: asyncio.Event(),
hci.HCI_ACL_DATA_PACKET: asyncio.Event(),
hci.HCI_SYNCHRONOUS_DATA_PACKET: asyncio.Event(),
self.done = {}
self.splitters = {
hci.HCI_EVENT_PACKET: EventPacketSplitter(
lambda packet: self.queue_packet(hci.HCI_EVENT_PACKET, packet)
),
hci.HCI_ACL_DATA_PACKET: AclPacketSplitter(
lambda packet: self.queue_packet(hci.HCI_ACL_DATA_PACKET, packet)
),
hci.HCI_SYNCHRONOUS_DATA_PACKET: ScoPacketSplitter(
lambda packet: self.queue_packet(
hci.HCI_SYNCHRONOUS_DATA_PACKET, packet
)
),
}
self.closed = False
self.lock = threading.Lock()
@@ -445,6 +520,7 @@ class UsbPacketSource(asyncio.Protocol, BaseSource):
callback=self.transfer_callback,
user_data=hci.HCI_EVENT_PACKET,
)
self.done[self.interrupt_in_transfer] = asyncio.Event()
self.interrupt_in_transfer.submit()
self.bulk_in_transfer = self.device.getTransfer()
@@ -454,23 +530,27 @@ class UsbPacketSource(asyncio.Protocol, BaseSource):
callback=self.transfer_callback,
user_data=hci.HCI_ACL_DATA_PACKET,
)
self.done[self.bulk_in_transfer] = asyncio.Event()
self.bulk_in_transfer.submit()
if self.isochronous_in is not None:
self.isochronous_in_transfer = self.device.getTransfer(iso_packets=16)
self.isochronous_in_transfer.setIsochronous(
self.isochronous_in.getAddress(),
16 * self.isochronous_in.getMaxPacketSize(),
callback=self.transfer_callback,
user_data=hci.HCI_SYNCHRONOUS_DATA_PACKET,
)
self.isochronous_in_transfer.submit()
for _ in range(NUMBER_OF_SCO_IN_TRANSFERS):
transfer = self.device.getTransfer(iso_packets=MAX_SCO_IN_PACKETS)
transfer.setIsochronous(
self.isochronous_in.getAddress(),
MAX_SCO_IN_PACKETS * self.isochronous_in.getMaxPacketSize(),
callback=self.transfer_callback,
user_data=hci.HCI_SYNCHRONOUS_DATA_PACKET,
)
self.isochronous_in_transfers.append(transfer)
self.done[transfer] = asyncio.Event()
transfer.submit()
self.dequeue_task = self.loop.create_task(self.dequeue())
def queue_packet(self, packet_type: int, packet_data: bytes) -> None:
self.loop.call_soon_threadsafe(
self.queue.put_nowait, bytes([packet_type]) + packet_data
_safe_call_soon(
self.loop, self.queue.put_nowait, bytes([packet_type]) + packet_data
)
def transfer_callback(self, transfer):
@@ -490,6 +570,8 @@ class UsbPacketSource(asyncio.Protocol, BaseSource):
with self.lock:
if self.closed:
logger.debug("packet source closed, discarding transfer")
elif (splitter := self.splitters.get(packet_type)) is None:
logger.warning(f'no splitter for packet type {packet_type}')
else:
if packet_type == hci.HCI_SYNCHRONOUS_DATA_PACKET:
for iso_status, iso_buffer in transfer.iterISO():
@@ -503,11 +585,10 @@ class UsbPacketSource(asyncio.Protocol, BaseSource):
len(iso_buffer),
iso_buffer.hex(),
)
self.isochronous_accumulator.feed(iso_buffer)
splitter.feed(iso_buffer)
else:
self.queue_packet(
packet_type,
transfer.getBuffer()[: transfer.getActualLength()],
splitter.feed(
transfer.getBuffer()[: transfer.getActualLength()]
)
# Re-submit the transfer so we can receive more data
@@ -515,16 +596,16 @@ class UsbPacketSource(asyncio.Protocol, BaseSource):
transfer.submit()
except usb1.USBError as error:
logger.warning(f"Failed to re-submit transfer: {error}")
self.loop.call_soon_threadsafe(self.on_transport_lost)
_safe_call_soon(self.loop, self.on_transport_lost)
elif status == usb1.TRANSFER_CANCELLED:
logger.debug(f"IN[{packet_type}] transfer canceled")
self.loop.call_soon_threadsafe(self.done[packet_type].set)
_safe_call_soon(self.loop, self.done[transfer].set)
else:
logger.warning(
color(f'!!! IN[{packet_type}] transfer not completed', 'red')
)
self.loop.call_soon_threadsafe(self.done[packet_type].set)
self.loop.call_soon_threadsafe(self.on_transport_lost)
_safe_call_soon(self.loop, self.done[transfer].set)
_safe_call_soon(self.loop, self.on_transport_lost)
async def dequeue(self):
while not self.closed:
@@ -552,7 +633,7 @@ class UsbPacketSource(asyncio.Protocol, BaseSource):
for transfer in (
self.interrupt_in_transfer,
self.bulk_in_transfer,
self.isochronous_in_transfer,
*self.isochronous_in_transfers,
):
if transfer is None:
continue
@@ -568,7 +649,7 @@ class UsbPacketSource(asyncio.Protocol, BaseSource):
f'waiting for IN[{packet_type}] transfer cancellation '
'to be done...'
)
await self.done[packet_type].wait()
await self.done[transfer].wait()
logger.debug(f'IN[{packet_type}] transfer cancellation done')
except usb1.USBError as error:
logger.debug(
@@ -637,7 +718,7 @@ class UsbTransport(Transport):
logger.warning(f'!!! Exception while handling events: {error}')
logger.debug('ending USB event loop')
self.loop.call_soon_threadsafe(self.event_loop_done.set_result, None)
_safe_call_soon(self.loop, self.event_loop_done.set_result, None)
async def close(self):
self.source.close()
@@ -648,7 +729,10 @@ class UsbTransport(Transport):
# We no longer need the event loop to run
with self.lock:
self.event_loop_should_exit = True
self.context.interruptEventHandler()
try:
self.context.interruptEventHandler()
except (AttributeError, usb1.USBError) as error:
logger.warning(f"Failed to interrupt event handler: {error}")
self.device.releaseInterface(self.acl_interface.getNumber())
if self.sco_interface:
+3 -3
View File
@@ -29,10 +29,10 @@ Try installing the optional `[auracast]` dependencies:
## LC3
The `auracast` app depends on the `lc3` python module, which is available
either as PyPI module (currently only available for Linux x86_64).
as a PyPI module `lc3py` (currently available for Linux x86_64 and macOS arm64).
When installing Bumble with the optional `auracast` dependency, the `lc3`
module will be installed from the `lc3py` PyPI package if available.
If not, you will need to install it separately. This can be done with:
module will be installed automatically if your platform is supported.
If not, you will need to install it separately. This can be done by building it from source:
```bash
$ python3 -m pip install "git+https://github.com/google/liblc3.git"
```
+6
View File
@@ -71,6 +71,12 @@ $ python3 -m pip install git+https://github.com/google/bumble.git@27c0551
When you work on the Bumble code itself, and run some of the tests or example apps, or import the
module in your own code, you typically either install the package from source in "development mode" as described above, or you may choose to skip the install phase.
!!! tip "Installing Development Dependencies"
To install all optional dependencies and development tools (like `pytest`, `black`, `invoke`, etc.), run:
```bash
python3 -m pip install -e ".[all]" --group dev
```
If you plan on contributing to the project, please read the [contributing](development/contributing.md) section.
## Without Installing
+14
View File
@@ -24,6 +24,20 @@ stack via a virtual HCI interface.
Both ways are controlled via gRPC requests to the Android emulator controller and/or
from the Android emulator.
## Prerequisites
Using the Android Emulator or Netsim transports requires the `android` optional dependencies (which include `grpcio` and `protobuf`).
If you installed Bumble from PyPI:
```bash
python3 -m pip install "bumble[android]"
```
If you are running from source:
```bash
python3 -m pip install ".[android]"
```
## Launching the Emulator
If the version of the emulator you are running does not yet support enabling
@@ -8,6 +8,9 @@ ANDROID EMULATOR TRANSPORT
Use the `android-netsim` transport name instead.
!!! note
This transport requires the `android` extra. See the [Android Platform](../platforms/android.md) page for installation instructions.
The Android "netsim" transport either connects, as a host, to a **Netsim** virtual controller
("host" mode), or acts as a virtual controller itself ("controller" mode) accepting host
connections.
+39 -25
View File
@@ -24,31 +24,58 @@ dependencies = [
# updated. Relax the version requirement since it's better than being completely unable
# to import the package in case of version mismatch.
"cryptography >= 42.0.8; platform_system=='Android'",
"grpcio >= 1.62.1; 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' and platform_system!='Android'",
"platformdirs >= 3.10.0; platform_system!='Emscripten'",
"prompt_toolkit >= 3.0.16; platform_system!='Emscripten'",
"prettytable >= 3.6.0; platform_system!='Emscripten'",
"protobuf >= 3.12.4; platform_system!='Emscripten'",
"pyee >= 13.0.0",
"pyserial-asyncio >= 0.5; platform_system!='Emscripten'",
"pyserial >= 3.5; platform_system!='Emscripten'",
"pyusb >= 1.2; platform_system!='Emscripten'",
"tomli ~= 2.2.1; platform_system!='Emscripten' and python_version<'3.11'",
"websockets >= 15.0.1; platform_system!='Emscripten'",
# Serial
"pyserial-asyncio >= 0.5; platform_system!='Emscripten'",
"pyserial >= 3.5; platform_system!='Emscripten'",
# USB
"libusb1 >= 2.0.1; platform_system!='Emscripten'",
"libusb-package == 1.0.26.4; platform_system!='Emscripten' and platform_system!='Android'",
"pyusb >= 1.2; platform_system!='Emscripten'",
]
[project.optional-dependencies]
android = [
"grpcio >= 1.62.1; platform_system!='Emscripten'",
"protobuf >= 3.12.4; platform_system!='Emscripten'",
]
avatar = [
"pandora-avatar == 0.0.10",
"rootcanal == 1.11.1 ; python_version>='3.10'",
]
pandora = ["bt-test-interfaces >= 0.0.6"]
auracast = [
"lc3py >= 1.1.3; python_version>='3.10' and ((platform_system=='Linux' and platform_machine=='x86_64') or (platform_system=='Darwin' and platform_machine=='arm64'))",
"sounddevice >= 0.5.1",
]
all = [
"bumble[android]",
"bumble[auracast]",
]
[dependency-groups]
build = ["build >= 0.7"]
test = [
"pytest >= 8.2",
"pytest-asyncio >= 0.23.5",
"pytest-html >= 3.2.0",
"pytest >= 9.0",
"pytest-asyncio >= 1.4",
"pytest-html >= 4.2",
"coverage >= 6.4",
]
development = [
docs = [
"mkdocs >= 1.6.0",
"mkdocs-material >= 9.6",
"mkdocstrings[python] >= 0.27.0",
]
dev = [
{include-group = "build"},
{include-group = "test"},
{include-group = "docs"},
"black ~= 25.1",
"bt-test-interfaces >= 0.0.6",
"grpcio-tools >= 1.62.1",
@@ -63,20 +90,6 @@ development = [
"types-invoke >= 1.7.3",
"types-protobuf >= 4.21.0",
]
avatar = [
"pandora-avatar == 0.0.10",
"rootcanal == 1.11.1 ; python_version>='3.10'",
]
pandora = ["bt-test-interfaces >= 0.0.6"]
documentation = [
"mkdocs >= 1.6.0",
"mkdocs-material >= 9.6",
"mkdocstrings[python] >= 0.27.0",
]
auracast = [
"lc3py >= 1.1.3; python_version>='3.10' and ((platform_system=='Linux' and platform_machine=='x86_64') or (platform_system=='Darwin' and platform_machine=='arm64'))",
"sounddevice >= 0.5.1",
]
[project.scripts]
bumble-auracast = "bumble.apps.auracast:main"
@@ -132,6 +145,7 @@ write_to = "bumble/_version.py"
[tool.pytest.ini_options]
pythonpath = "."
testpaths = ["tests"]
asyncio_mode = "auto"
[tool.pylint.master]
init-hook = 'import sys; sys.path.append(".")'
+1 -1
View File
@@ -47,7 +47,7 @@ def build(ctx, install=False):
if install:
ctx.run('python -m pip install .[build]')
ctx.run("python -m build")
ctx.run("python -I -m build")
# -----------------------------------------------------------------------------
+3 -3
View File
@@ -46,7 +46,7 @@ class TwoDevices(test_utils.TwoDevices):
@pytest.mark.parametrize(
"command,",
"command",
[
avrcp.GetPlayStatusCommand(),
avrcp.GetCapabilitiesCommand(
@@ -132,7 +132,7 @@ def test_command(command: avrcp.Command):
@pytest.mark.parametrize(
"event,",
"event",
[
avrcp.UidsChangedEvent(uid_counter=7),
avrcp.TrackChangedEvent(uid=12356),
@@ -159,7 +159,7 @@ def test_event(event: avrcp.Event):
@pytest.mark.parametrize(
"response,",
"response",
[
avrcp.GetPlayStatusResponse(
song_length=1010, song_position=13, play_status=avrcp.PlayStatus.PAUSED
+1 -1
View File
@@ -72,7 +72,7 @@ def test_sef():
# -----------------------------------------------------------------------------
@pytest.mark.asyncio
@pytest.mark.parametrize(
'sirk_type,', [(csip.SirkType.ENCRYPTED), (csip.SirkType.PLAINTEXT)]
'sirk_type', [(csip.SirkType.ENCRYPTED), (csip.SirkType.PLAINTEXT)]
)
async def test_csis(sirk_type):
SIRK = bytes.fromhex('2f62c8ae41867d1bb619e788a2605faa')
+22 -3
View File
@@ -278,7 +278,7 @@ async def test_legacy_advertising():
# -----------------------------------------------------------------------------
@pytest.mark.parametrize(
'auto_restart,',
'auto_restart',
(True, False),
)
@pytest.mark.asyncio
@@ -357,7 +357,7 @@ async def test_advertising_and_scanning():
# -----------------------------------------------------------------------------
@pytest.mark.parametrize(
'own_address_type,',
'own_address_type',
(OwnAddressType.PUBLIC, OwnAddressType.RANDOM),
)
@pytest.mark.asyncio
@@ -395,7 +395,7 @@ async def test_extended_advertising_connection(own_address_type):
# -----------------------------------------------------------------------------
@pytest.mark.parametrize(
'own_address_type,',
'own_address_type',
(OwnAddressType.PUBLIC, OwnAddressType.RANDOM),
)
@pytest.mark.asyncio
@@ -595,6 +595,25 @@ async def test_cis_setup_failure():
await asyncio.wait_for(cis_create_task, _TIMEOUT)
# -----------------------------------------------------------------------------
def test_cis_parameters_unidirectional():
# Test C2P unidirectional (P to C not used)
cis_c2p = CigParameters.CisParameters(cis_id=1, max_sdu_p_to_c=0)
assert cis_c2p.max_sdu_c_to_p != 0
assert cis_c2p.rtn_c_to_p != 0
assert cis_c2p.phy_c_to_p != hci.PhyBit(0)
assert cis_c2p.rtn_p_to_c == 0
assert cis_c2p.phy_p_to_c == hci.PhyBit(0)
# Test P2C unidirectional (C to P not used)
cis_p2c = CigParameters.CisParameters(cis_id=2, max_sdu_c_to_p=0)
assert cis_p2c.max_sdu_p_to_c != 0
assert cis_p2c.rtn_p_to_c != 0
assert cis_p2c.phy_p_to_c != hci.PhyBit(0)
assert cis_p2c.rtn_c_to_p == 0
assert cis_p2c.phy_c_to_p == hci.PhyBit(0)
# -----------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_enter_and_exit_sniff_mode():
+3 -3
View File
@@ -297,7 +297,7 @@ def test_custom_le_meta_event():
# -----------------------------------------------------------------------------
@pytest.mark.parametrize(
"clazz,",
"clazz",
[
clazz[1]
for clazz in inspect.getmembers(hci)
@@ -313,7 +313,7 @@ def test_hci_command_subclasses_op_code(clazz: type[hci.HCI_Command]):
# -----------------------------------------------------------------------------
@pytest.mark.parametrize(
"clazz,",
"clazz",
[
clazz[1]
for clazz in inspect.getmembers(hci)
@@ -330,7 +330,7 @@ def test_hci_event_subclasses_event_code(clazz: type[hci.HCI_Event]):
# -----------------------------------------------------------------------------
@pytest.mark.parametrize(
"clazz,",
"clazz",
[
clazz[1]
for clazz in inspect.getmembers(hci)
+2 -2
View File
@@ -333,7 +333,7 @@ async def test_query_calls_with_calls(
# -----------------------------------------------------------------------------
@pytest.mark.asyncio
@pytest.mark.parametrize(
"operation,",
"operation",
(
hfp.CallHoldOperation.RELEASE_ALL_HELD_CALLS,
hfp.CallHoldOperation.RELEASE_ALL_ACTIVE_CALLS,
@@ -358,7 +358,7 @@ async def test_hold_call_without_call_index(
# -----------------------------------------------------------------------------
@pytest.mark.asyncio
@pytest.mark.parametrize(
"operation,",
"operation",
(
hfp.CallHoldOperation.RELEASE_SPECIFIC_CALL,
hfp.CallHoldOperation.HOLD_ALL_CALLS_EXCEPT,
+14
View File
@@ -74,6 +74,20 @@ async def test_reset(supported_commands: set[int], max_lmp_features_page_number:
)
@pytest.mark.asyncio
async def test_reset_enables_number_of_completed_packets_event() -> None:
controller = Controller('C')
controller.total_num_le_acl_data_packets = 3
host = Host(controller, AsyncPipeSink(controller))
await host.reset()
completed_packets_event_bit = 1 << (hci.HCI_NUMBER_OF_COMPLETED_PACKETS_EVENT - 1)
assert controller.event_mask & completed_packets_event_bit
assert host.le_acl_packet_queue is not None
assert host.le_acl_packet_queue.max_in_flight == 3
# -----------------------------------------------------------------------------
def test_data_packet_queue():
controller = unittest.mock.Mock()
+80 -5
View File
@@ -49,19 +49,19 @@ def test_helpers():
psm = l2cap.L2CAP_Connection_Request.serialize_psm(0x242311)
assert psm == bytes([0x11, 0x23, 0x24])
(offset, psm) = l2cap.L2CAP_Connection_Request.parse_psm(
offset, psm = l2cap.L2CAP_Connection_Request.parse_psm(
bytes([0x00, 0x01, 0x00, 0x44]), 1
)
assert offset == 3
assert psm == 0x01
(offset, psm) = l2cap.L2CAP_Connection_Request.parse_psm(
offset, psm = l2cap.L2CAP_Connection_Request.parse_psm(
bytes([0x00, 0x23, 0x10, 0x44]), 1
)
assert offset == 3
assert psm == 0x1023
(offset, psm) = l2cap.L2CAP_Connection_Request.parse_psm(
offset, psm = l2cap.L2CAP_Connection_Request.parse_psm(
bytes([0x00, 0x11, 0x23, 0x24, 0x44]), 1
)
assert offset == 4
@@ -197,7 +197,7 @@ async def test_basic_connection():
# -----------------------------------------------------------------------------
@pytest.mark.parametrize("info_type,", list(l2cap.L2CAP_Information_Request.InfoType))
@pytest.mark.parametrize("info_type", list(l2cap.L2CAP_Information_Request.InfoType))
async def test_l2cap_information_request(monkeypatch, info_type):
# TODO: Replace handlers with API when implemented
devices = await TwoDevices.create_with_connection()
@@ -321,7 +321,7 @@ async def test_mtu():
# -----------------------------------------------------------------------------
@pytest.mark.asyncio
@pytest.mark.parametrize("mtu,", (50, 255, 256, 1000))
@pytest.mark.parametrize("mtu", (50, 255, 256, 1000))
async def test_enhanced_retransmission_mode(mtu: int):
devices = TwoDevices()
await devices.setup_connection()
@@ -457,6 +457,81 @@ def test_fcs(cid: int, payload: str, expected: str):
assert pdu.to_bytes(with_fcs=True) == bytes.fromhex(expected)
# -----------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_abort_while_disconnecting():
# Setup client and server channels
devices = TwoDevices()
await devices.setup_connection()
psm = 1234
server_channels = asyncio.Queue[l2cap.LeCreditBasedChannel]()
devices.devices[1].create_l2cap_server(
spec=l2cap.LeCreditBasedChannelSpec(psm=psm),
handler=server_channels.put_nowait,
)
client_channel = await devices.connections[0].create_l2cap_channel(
spec=l2cap.LeCreditBasedChannelSpec(psm)
)
server_channel = await server_channels.get()
# Stub the server's request handler to ignore the disconnection request.
# This keeps the client channel in the DISCONNECTING state, waiting for a response,
# so we can simulate calling abort() during an active disconnection.
server_channel.on_disconnection_request = lambda request: None
# Intercept state change to DISCONNECTING and call abort()
original_change_state = client_channel._change_state
abort_called = False
def intercept_change_state_and_abort(new_state):
nonlocal abort_called
original_change_state(new_state)
if (
new_state == l2cap.LeCreditBasedChannel.State.DISCONNECTING
and not abort_called
):
abort_called = True
client_channel.abort()
client_channel._change_state = intercept_change_state_and_abort
# Start disconnection and wait with a timeout. It should resolve immediately due to the abort.
await asyncio.wait_for(client_channel.disconnect(), timeout=1.0)
assert client_channel.state == l2cap.LeCreditBasedChannel.State.DISCONNECTED
# -----------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_disconnection_collision():
# Setup client and server channels
devices = TwoDevices()
await devices.setup_connection()
psm = 1234
server_channels = asyncio.Queue[l2cap.LeCreditBasedChannel]()
devices.devices[1].create_l2cap_server(
spec=l2cap.LeCreditBasedChannelSpec(psm=psm),
handler=server_channels.put_nowait,
)
client_channel = await devices.connections[0].create_l2cap_channel(
spec=l2cap.LeCreditBasedChannelSpec(psm)
)
server_channel = await server_channels.get()
# Trigger disconnection from both sides concurrently to cause a collision.
# Both channels will transition to DISCONNECTING and send DISCONNECTION_REQUESTs.
# When each side receives the peer's request, it will handle it and resolve the
# disconnection_result future.
await asyncio.gather(
client_channel.disconnect(),
server_channel.disconnect(),
)
assert client_channel.state == l2cap.LeCreditBasedChannel.State.DISCONNECTED
assert server_channel.state == l2cap.LeCreditBasedChannel.State.DISCONNECTED
# -----------------------------------------------------------------------------
async def run():
test_helpers()
+1 -1
View File
@@ -68,7 +68,7 @@ async def test_self_disconnection():
# -----------------------------------------------------------------------------
@pytest.mark.asyncio
@pytest.mark.parametrize(
'responder_role,',
'responder_role',
(Role.CENTRAL, Role.PERIPHERAL),
)
async def test_self_classic_connection(responder_role):
+67 -4
View File
@@ -24,7 +24,7 @@ import sys
import pytest
from bumble import controller, device, hci, link, transport
from bumble.transport import common
from bumble.transport import common, usb
# -----------------------------------------------------------------------------
@@ -102,7 +102,7 @@ def test_parser_extensions():
# -----------------------------------------------------------------------------
@pytest.mark.parametrize(
"address,",
"address",
("127.0.0.1", "::1"),
)
async def test_tcp_connection(address):
@@ -205,7 +205,7 @@ async def test_unix_connection_abstract():
# -----------------------------------------------------------------------------
@pytest.mark.parametrize(
"address,",
"address",
("127.0.0.1", "[::1]"),
)
async def test_android_netsim_connection(address):
@@ -228,7 +228,7 @@ async def test_android_netsim_connection(address):
# -----------------------------------------------------------------------------
@pytest.mark.parametrize(
"spec,",
"spec",
(
"android-netsim:[::1]:{port},mode=host[a=b,c=d]",
"android-netsim:localhost:{port},mode=host[a=b,c=d]",
@@ -252,6 +252,69 @@ async def test_open_transport_with_metadata(spec):
await controller_transport.close()
# -----------------------------------------------------------------------------
def test_packet_splitter_complete():
emitted = []
splitter = usb.AclPacketSplitter(emitted.append)
packet = bytes([0x01, 0x00, 0x04, 0x00, 0x11, 0x22, 0x33, 0x44])
splitter.feed(packet)
assert emitted == [packet]
def test_packet_splitter_chunks():
emitted = []
splitter = usb.AclPacketSplitter(emitted.append)
packet = bytes([0x01, 0x00, 0x04, 0x00, 0x11, 0x22, 0x33, 0x44])
splitter.feed(packet[:4])
assert emitted == []
splitter.feed(packet[4:])
assert emitted == [packet]
def test_packet_splitter_multiple():
emitted = []
splitter = usb.AclPacketSplitter(emitted.append)
packet1 = bytes([0x01, 0x00, 0x04, 0x00, 0x11, 0x22, 0x33, 0x44])
packet2 = bytes([0x02, 0x00, 0x02, 0x00, 0x55, 0x66])
splitter.feed(packet1 + packet2)
assert emitted == [packet1, packet2]
def test_packet_splitter_partial():
emitted = []
splitter = usb.AclPacketSplitter(emitted.append)
packet1 = bytes([0x01, 0x00, 0x04, 0x00, 0x11, 0x22, 0x33, 0x44])
packet2 = bytes([0x02, 0x00, 0x02, 0x00, 0x55, 0x66])
splitter.feed(packet1 + packet2[:4])
assert emitted == [packet1]
splitter.feed(packet2[4:])
assert emitted == [packet1, packet2]
def test_packet_splitter_empty_payload():
emitted = []
splitter = usb.AclPacketSplitter(emitted.append)
packet = bytes([0x01, 0x00, 0x00, 0x00])
splitter.feed(packet)
assert emitted == [packet]
def test_sco_packet_splitter():
emitted = []
splitter = usb.ScoPacketSplitter(emitted.append)
packet = bytes([0x01, 0x00, 0x03, 0x11, 0x22, 0x33])
splitter.feed(packet)
assert emitted == [packet]
def test_event_packet_splitter():
emitted = []
splitter = usb.EventPacketSplitter(emitted.append)
packet = bytes([0x04, 0x02, 0x11, 0x22])
splitter.feed(packet)
assert emitted == [packet]
# -----------------------------------------------------------------------------
if __name__ == '__main__':
test_parser()
+120
View File
@@ -0,0 +1,120 @@
# Copyright 2026 Google LLC
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# https://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
import asyncio
from unittest import mock
import pytest
from bumble import hci
from bumble.transport import usb
def test_safe_call_soon_ignores_closed_loop():
closed_loop = asyncio.new_event_loop()
closed_loop.close()
callback = mock.Mock()
usb._safe_call_soon(closed_loop, callback)
callback.assert_not_called()
@pytest.mark.asyncio
async def test_safe_call_soon_schedules_callback_on_open_loop():
called = asyncio.Event()
args = []
def callback(value):
args.append(value)
called.set()
usb._safe_call_soon(asyncio.get_running_loop(), callback, 'scheduled')
await asyncio.wait_for(called.wait(), timeout=1.0)
assert args == ['scheduled']
@pytest.mark.asyncio
async def test_usb_packet_sink_iso_routing():
# Mock usb1 device and endpoints
mock_device = mock.Mock()
mock_bulk_out = mock.Mock()
mock_bulk_out.getAddress.return_value = 0x02
# Scenario 1: Isochronous endpoints are not enabled (isochronous_out is None)
mock_transfer = mock.Mock()
mock_device.getTransfer.return_value = mock_transfer
sink = usb.UsbPacketSink(mock_device, mock_bulk_out, isochronous_out=None)
sink.start()
# Send HCI_ISO_DATA_PACKET
iso_packet = bytes([hci.HCI_ISO_DATA_PACKET, 0x01, 0x02, 0x03])
sink.on_packet(iso_packet)
# Yield control to let the queue processor run
await asyncio.sleep(0.01)
# Verify it was sent via bulk transfer
mock_transfer.setBulk.assert_called_once_with(
0x02,
bytes([0x01, 0x02, 0x03]),
callback=sink.transfer_callback,
)
mock_transfer.submit.assert_called_once()
if sink.queue_task:
sink.queue_task.cancel()
try:
await sink.queue_task
except asyncio.CancelledError:
pass
@pytest.mark.asyncio
async def test_usb_packet_sink_iso_routing_with_iso_endpoint():
# Mock usb1 device and endpoints
mock_device = mock.Mock()
mock_bulk_out = mock.Mock()
mock_bulk_out.getAddress.return_value = 0x02
mock_iso_out = mock.Mock()
mock_iso_out.getMaxPacketSize.return_value = 64
# Scenario 2: Isochronous endpoints are enabled
mock_transfer_bulk = mock.Mock()
mock_transfer_iso = mock.Mock()
# getTransfer is called twice: once for bulk_or_control and once for isochronous
mock_device.getTransfer.side_effect = [mock_transfer_bulk, mock_transfer_iso]
sink = usb.UsbPacketSink(mock_device, mock_bulk_out, isochronous_out=mock_iso_out)
sink.start()
# Send HCI_ISO_DATA_PACKET
iso_packet = bytes([hci.HCI_ISO_DATA_PACKET, 0x01, 0x02, 0x03])
sink.on_packet(iso_packet)
# Yield control to let the queue processor run
await asyncio.sleep(0.01)
# Verify it was NOT sent via bulk transfer
mock_transfer_bulk.setBulk.assert_not_called()
if sink.queue_task:
sink.queue_task.cancel()
try:
await sink.queue_task
except asyncio.CancelledError:
pass