Compare commits

...

21 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
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
24 changed files with 323 additions and 90 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
+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)
+1
View File
@@ -418,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,
+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
+26 -9
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
# -----------------------------------------------------------------------------
@@ -266,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}")
@@ -535,8 +549,8 @@ class UsbPacketSource(asyncio.Protocol, BaseSource):
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):
@@ -582,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[transfer].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[transfer].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:
@@ -704,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()
@@ -715,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.
+36 -22
View File
@@ -24,23 +24,42 @@ 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 >= 9.0",
@@ -48,7 +67,15 @@ test = [
"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")
# -----------------------------------------------------------------------------
+19
View File
@@ -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():
+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()
+75
View File
@@ -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()
+25
View File
@@ -21,6 +21,31 @@ 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