mirror of
https://github.com/google/bumble.git
synced 2026-07-13 14:00:51 +00:00
Compare commits
40 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 1d26b99865 | |||
| c75402d29a | |||
| c8360b7a06 | |||
| 13c6090f8c | |||
| 6fd08b709a | |||
| 55d8171ad8 | |||
| 2111c49a2c | |||
| adef270195 | |||
| 8015e528f2 | |||
| 9f2fdf5309 | |||
| 7d816b0c6c | |||
| 47fcd8e9ec | |||
| fe991f67da | |||
| 82a5acc290 | |||
| 022ccf3d70 | |||
| 8a0992c00e | |||
| d7b007f3af | |||
| 7b0eb93fd8 | |||
| a8851b4759 | |||
| 7a72b225d4 | |||
| e5e51fd3f2 | |||
| a889dbae31 | |||
| 8a3c8c735e | |||
| 3a7dcd1377 | |||
| 36c7694c44 | |||
| 16dd5ae63d | |||
| 3c8fe5637d | |||
| 3266d16cf1 | |||
| 65c4f9a698 | |||
| 17bc5566aa | |||
| b6a21fa3c6 | |||
| 7a14ebdabe | |||
| e44eaf2147 | |||
| 17a202bc13 | |||
| ef634953f0 | |||
| 71672ec64f | |||
| 5ee2d80ce4 | |||
| 9b2e345a1e | |||
| f9bd3084b9 | |||
| 808ea1abeb |
@@ -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": {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
+3
-3
@@ -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}'
|
||||
')'
|
||||
)
|
||||
|
||||
@@ -489,7 +489,7 @@ class Sender:
|
||||
flags=(
|
||||
Packet.PacketFlags.LAST
|
||||
if tx_i == self.tx_packet_count - 1
|
||||
else 0
|
||||
else Packet.PacketFlags(0)
|
||||
),
|
||||
sequence=tx_i,
|
||||
timestamp=int((time.time() - self.start_time) * 1000000),
|
||||
|
||||
@@ -45,8 +45,10 @@ from bumble.hci import (
|
||||
HCI_Read_Local_Supported_Codecs_Command,
|
||||
HCI_Read_Local_Supported_Codecs_V2_Command,
|
||||
HCI_Read_Local_Version_Information_Command,
|
||||
HCI_Read_Voice_Setting_Command,
|
||||
LeFeature,
|
||||
SpecificationVersion,
|
||||
VoiceSetting,
|
||||
map_null_terminated_utf8_string,
|
||||
)
|
||||
from bumble.host import Host
|
||||
@@ -214,6 +216,16 @@ async def get_codecs_info(host: Host) -> None:
|
||||
if not response2.vendor_specific_codec_ids:
|
||||
print(' No Vendor-specific codecs')
|
||||
|
||||
if host.supports_command(HCI_Read_Voice_Setting_Command.op_code):
|
||||
response3 = await host.send_sync_command(HCI_Read_Voice_Setting_Command())
|
||||
voice_setting = VoiceSetting.from_int(response3.voice_setting)
|
||||
print(color('Voice Setting:', 'yellow'))
|
||||
print(f' Air Coding Format: {voice_setting.air_coding_format.name}')
|
||||
print(f' Linear PCM Bit Position: {voice_setting.linear_pcm_bit_position}')
|
||||
print(f' Input Sample Size: {voice_setting.input_sample_size.name}')
|
||||
print(f' Input Data Format: {voice_setting.input_data_format.name}')
|
||||
print(f' Input Coding Format: {voice_setting.input_coding_format.name}')
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
async def async_main(
|
||||
|
||||
+151
-41
@@ -16,6 +16,8 @@
|
||||
# Imports
|
||||
# -----------------------------------------------------------------------------
|
||||
import asyncio
|
||||
import statistics
|
||||
import struct
|
||||
import time
|
||||
|
||||
import click
|
||||
@@ -25,7 +27,9 @@ from bumble.colors import color
|
||||
from bumble.hci import (
|
||||
HCI_READ_LOOPBACK_MODE_COMMAND,
|
||||
HCI_WRITE_LOOPBACK_MODE_COMMAND,
|
||||
Address,
|
||||
HCI_Read_Loopback_Mode_Command,
|
||||
HCI_SynchronousDataPacket,
|
||||
HCI_Write_Loopback_Mode_Command,
|
||||
LoopbackMode,
|
||||
)
|
||||
@@ -36,55 +40,121 @@ from bumble.transport import open_transport
|
||||
class Loopback:
|
||||
"""Send and receive ACL data packets in local loopback mode"""
|
||||
|
||||
def __init__(self, packet_size: int, packet_count: int, transport: str):
|
||||
def __init__(
|
||||
self,
|
||||
packet_size: int,
|
||||
packet_count: int,
|
||||
connection_type: str,
|
||||
mode: str,
|
||||
interval: int,
|
||||
transport: str,
|
||||
):
|
||||
self.transport = transport
|
||||
self.packet_size = packet_size
|
||||
self.packet_count = packet_count
|
||||
self.connection_handle: int | None = None
|
||||
self.connection_type = connection_type
|
||||
self.connection_event = asyncio.Event()
|
||||
self.mode = mode
|
||||
self.interval = interval
|
||||
self.done = asyncio.Event()
|
||||
self.expected_cid = 0
|
||||
self.expected_counter = 0
|
||||
self.bytes_received = 0
|
||||
self.start_timestamp = 0.0
|
||||
self.last_timestamp = 0.0
|
||||
self.send_timestamps: list[float] = []
|
||||
self.rtts: list[float] = []
|
||||
|
||||
def on_connection(self, connection_handle: int, *args):
|
||||
"""Retrieve connection handle from new connection event"""
|
||||
if not self.connection_event.is_set():
|
||||
# save first connection handle for ACL
|
||||
# subsequent connections are SCO
|
||||
# The first connection handle is of type ACL,
|
||||
# subsequent connections are of type SCO
|
||||
if self.connection_type == "sco" and self.connection_handle is None:
|
||||
self.connection_handle = connection_handle
|
||||
return
|
||||
|
||||
self.connection_handle = connection_handle
|
||||
self.connection_event.set()
|
||||
|
||||
def on_l2cap_pdu(self, connection_handle: int, cid: int, pdu: bytes):
|
||||
def on_sco_connection(
|
||||
self,
|
||||
address: Address,
|
||||
connection_handle: int,
|
||||
link_type,
|
||||
rx_packet_length: int,
|
||||
tx_packet_length: int,
|
||||
air_mode: int,
|
||||
) -> None:
|
||||
self.on_connection(connection_handle)
|
||||
|
||||
def on_packet(self, connection_handle: int, packet: bytes):
|
||||
"""Calculate packet receive speed"""
|
||||
now = time.time()
|
||||
print(f'<<< Received packet {cid}: {len(pdu)} bytes')
|
||||
(counter,) = struct.unpack_from("H", packet, 0)
|
||||
rtt = now - self.send_timestamps[counter]
|
||||
self.rtts.append(rtt)
|
||||
print(f'<<< Received packet {counter}: {len(packet)} bytes, RTT={rtt:.4f}')
|
||||
assert connection_handle == self.connection_handle
|
||||
assert cid == self.expected_cid
|
||||
self.expected_cid += 1
|
||||
if cid == 0:
|
||||
assert counter == self.expected_counter
|
||||
self.expected_counter += 1
|
||||
if counter == 0:
|
||||
self.start_timestamp = now
|
||||
else:
|
||||
elapsed_since_start = now - self.start_timestamp
|
||||
elapsed_since_last = now - self.last_timestamp
|
||||
self.bytes_received += len(pdu)
|
||||
instant_rx_speed = len(pdu) / elapsed_since_last
|
||||
self.bytes_received += len(packet)
|
||||
instant_rx_speed = len(packet) / elapsed_since_last
|
||||
average_rx_speed = self.bytes_received / elapsed_since_start
|
||||
print(
|
||||
color(
|
||||
f'@@@ RX speed: instant={instant_rx_speed:.4f},'
|
||||
f' average={average_rx_speed:.4f}',
|
||||
'cyan',
|
||||
if self.mode == 'throughput':
|
||||
print(
|
||||
color(
|
||||
f'@@@ RX speed: instant={instant_rx_speed:.4f},'
|
||||
f' average={average_rx_speed:.4f},',
|
||||
'cyan',
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
self.last_timestamp = now
|
||||
|
||||
if self.expected_cid == self.packet_count:
|
||||
if self.expected_counter == self.packet_count:
|
||||
print(color('@@@ Received last packet', 'green'))
|
||||
self.done.set()
|
||||
|
||||
def on_l2cap_pdu(self, connection_handle: int, cid: int, pdu: bytes):
|
||||
self.on_packet(connection_handle, pdu)
|
||||
|
||||
def on_sco_packet(self, connection_handle: int, packet) -> None:
|
||||
self.on_packet(connection_handle, packet)
|
||||
|
||||
async def send_acl_packet(self, host: Host, packet: bytes) -> None:
|
||||
assert self.connection_handle
|
||||
host.send_l2cap_pdu(self.connection_handle, 0, packet)
|
||||
|
||||
async def send_sco_packet(self, host: Host, packet: bytes) -> None:
|
||||
assert self.connection_handle
|
||||
host.send_hci_packet(
|
||||
HCI_SynchronousDataPacket(
|
||||
connection_handle=self.connection_handle,
|
||||
packet_status=HCI_SynchronousDataPacket.Status.CORRECTLY_RECEIVED_DATA,
|
||||
data_total_length=len(packet),
|
||||
data=packet,
|
||||
)
|
||||
)
|
||||
|
||||
async def send_loop(self, host: Host, sender) -> None:
|
||||
for counter in range(0, self.packet_count):
|
||||
print(
|
||||
color(
|
||||
f'>>> Sending {self.connection_type.upper()} '
|
||||
f'packet {counter}: {self.packet_size} bytes',
|
||||
'yellow',
|
||||
)
|
||||
)
|
||||
self.send_timestamps.append(time.time())
|
||||
await sender(host, struct.pack("H", counter) + bytes(self.packet_size - 2))
|
||||
await asyncio.sleep(self.interval / 1000 if self.mode == "rtt" else 0)
|
||||
|
||||
async def run(self) -> None:
|
||||
"""Run a loopback throughput test"""
|
||||
print(color('>>> Connecting to HCI...', 'green'))
|
||||
@@ -126,8 +196,11 @@ class Loopback:
|
||||
return
|
||||
|
||||
# set event callbacks
|
||||
host.on('connection', self.on_connection)
|
||||
host.on('classic_connection', self.on_connection)
|
||||
host.on('le_connection', self.on_connection)
|
||||
host.on('sco_connection', self.on_sco_connection)
|
||||
host.on('l2cap_pdu', self.on_l2cap_pdu)
|
||||
host.on('sco_packet', self.on_sco_packet)
|
||||
|
||||
loopback_mode = LoopbackMode.LOCAL
|
||||
|
||||
@@ -148,32 +221,37 @@ class Loopback:
|
||||
|
||||
print(color('=== Start sending', 'magenta'))
|
||||
start_time = time.time()
|
||||
bytes_sent = 0
|
||||
for cid in range(0, self.packet_count):
|
||||
# using the cid as an incremental index
|
||||
host.send_l2cap_pdu(
|
||||
self.connection_handle, cid, bytes(self.packet_size)
|
||||
)
|
||||
print(
|
||||
color(
|
||||
f'>>> Sending packet {cid}: {self.packet_size} bytes', 'yellow'
|
||||
)
|
||||
)
|
||||
bytes_sent += self.packet_size # don't count L2CAP or HCI header sizes
|
||||
await asyncio.sleep(0) # yield to allow packet receive
|
||||
if self.connection_type == "acl":
|
||||
sender = self.send_acl_packet
|
||||
elif self.connection_type == "sco":
|
||||
sender = self.send_sco_packet
|
||||
else:
|
||||
raise ValueError(f'Unknown connection type: {self.connection_type}')
|
||||
await self.send_loop(host, sender)
|
||||
|
||||
await self.done.wait()
|
||||
print(color('=== Done!', 'magenta'))
|
||||
|
||||
bytes_sent = self.packet_size * self.packet_count
|
||||
elapsed = time.time() - start_time
|
||||
average_tx_speed = bytes_sent / elapsed
|
||||
print(
|
||||
color(
|
||||
f'@@@ TX speed: average={average_tx_speed:.4f} ({bytes_sent} bytes'
|
||||
f' in {elapsed:.2f} seconds)',
|
||||
'green',
|
||||
if self.mode == 'throughput':
|
||||
print(
|
||||
color(
|
||||
f'@@@ TX speed: average={average_tx_speed:.4f} '
|
||||
f'({bytes_sent} bytes in {elapsed:.2f} seconds)',
|
||||
'green',
|
||||
)
|
||||
)
|
||||
if self.mode == 'rtt':
|
||||
print(
|
||||
color(
|
||||
f'RTTs: min={min(self.rtts):.4f}, '
|
||||
f'max={max(self.rtts):.4f}, '
|
||||
f'avg={statistics.mean(self.rtts):.4f}',
|
||||
'blue',
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
@@ -194,11 +272,43 @@ class Loopback:
|
||||
default=10,
|
||||
help='Packet count',
|
||||
)
|
||||
@click.option(
|
||||
'--connection-type',
|
||||
'-t',
|
||||
metavar='TYPE',
|
||||
type=click.Choice(['acl', 'sco']),
|
||||
default='acl',
|
||||
help='Connection type',
|
||||
)
|
||||
@click.option(
|
||||
'--mode',
|
||||
'-m',
|
||||
metavar='MODE',
|
||||
type=click.Choice(['throughput', 'rtt']),
|
||||
default='throughput',
|
||||
help='Test mode',
|
||||
)
|
||||
@click.option(
|
||||
'--interval',
|
||||
type=int,
|
||||
default=100,
|
||||
help='Inter-packet interval (ms) [RTT mode only]',
|
||||
)
|
||||
@click.argument('transport')
|
||||
def main(packet_size, packet_count, transport):
|
||||
def main(packet_size, packet_count, connection_type, mode, interval, transport):
|
||||
bumble.logging.setup_basic_logging()
|
||||
loopback = Loopback(packet_size, packet_count, transport)
|
||||
asyncio.run(loopback.run())
|
||||
|
||||
if connection_type == "sco" and packet_size > 255:
|
||||
print("ERROR: the maximum packet size for SCO is 255")
|
||||
return
|
||||
|
||||
async def run():
|
||||
loopback = Loopback(
|
||||
packet_size, packet_count, connection_type, mode, interval, transport
|
||||
)
|
||||
await loopback.run()
|
||||
|
||||
asyncio.run(run())
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
@@ -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
|
||||
|
||||
+6
-1
@@ -111,9 +111,14 @@ def show_device_details(device):
|
||||
if (endpoint.getAddress() & USB_ENDPOINT_IN == 0)
|
||||
else 'IN'
|
||||
)
|
||||
endpoint_details = (
|
||||
f', Max Packet Size = {endpoint.getMaxPacketSize()}'
|
||||
if endpoint_type == 'ISOCHRONOUS'
|
||||
else ''
|
||||
)
|
||||
print(
|
||||
f' Endpoint 0x{endpoint.getAddress():02X}: '
|
||||
f'{endpoint_type} {endpoint_direction}'
|
||||
f'{endpoint_type} {endpoint_direction}{endpoint_details}'
|
||||
)
|
||||
|
||||
|
||||
|
||||
+50
-13
@@ -913,7 +913,8 @@ class PeriodicAdvertisingSync(utils.EventEmitter):
|
||||
)
|
||||
)
|
||||
|
||||
self.state = self.State.PENDING
|
||||
if self.state == self.State.INIT:
|
||||
self.state = self.State.PENDING
|
||||
|
||||
async def terminate(self) -> None:
|
||||
if self.state in (self.State.INIT, self.State.CANCELLED, self.State.TERMINATED):
|
||||
@@ -1423,6 +1424,9 @@ class ScoLink(utils.CompositeEventEmitter):
|
||||
acl_connection: Connection
|
||||
handle: int
|
||||
link_type: int
|
||||
rx_packet_length: int
|
||||
tx_packet_length: int
|
||||
air_mode: hci.CodecID
|
||||
sink: Callable[[hci.HCI_SynchronousDataPacket], Any] | None = None
|
||||
|
||||
EVENT_DISCONNECTION: ClassVar[str] = "disconnection"
|
||||
@@ -1572,6 +1576,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
|
||||
@@ -1801,6 +1817,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,
|
||||
@@ -1829,7 +1852,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
|
||||
@@ -5420,7 +5443,7 @@ class Device(utils.CompositeEventEmitter):
|
||||
role: int = hci.CsRole.INITIATOR,
|
||||
rtt_type: int = hci.RttType.AA_ONLY,
|
||||
cs_sync_phy: int = hci.CsSyncPhy.LE_1M,
|
||||
channel_map: bytes = b'\x54\x55\x55\x54\x55\x55\x55\x55\x55\x15',
|
||||
channel_map: bytes = b'\x54\x55\x55\x54\x55\x55\x55\x55\x55\x05',
|
||||
channel_map_repetition: int = 0x01,
|
||||
channel_selection_type: int = hci.HCI_LE_CS_Create_Config_Command.ChannelSelectionType.ALGO_3B,
|
||||
ch3c_shape: int = hci.HCI_LE_CS_Create_Config_Command.Ch3cShape.HAT,
|
||||
@@ -6049,7 +6072,7 @@ class Device(utils.CompositeEventEmitter):
|
||||
def on_connection_request(
|
||||
self, bd_addr: hci.Address, class_of_device: int, link_type: int
|
||||
):
|
||||
logger.debug(f'*** Connection request: {bd_addr}')
|
||||
logger.debug(f'*** Connection request: {bd_addr} link_type={link_type}')
|
||||
|
||||
# Handle SCO request.
|
||||
if link_type in (
|
||||
@@ -6059,6 +6082,7 @@ class Device(utils.CompositeEventEmitter):
|
||||
if connection := self.find_connection_by_bd_addr(
|
||||
bd_addr, transport=PhysicalTransport.BR_EDR
|
||||
):
|
||||
connection.emit(self.EVENT_SCO_REQUEST, link_type)
|
||||
self.emit(self.EVENT_SCO_REQUEST, connection, link_type)
|
||||
else:
|
||||
logger.error(f'SCO request from a non-connected device {bd_addr}')
|
||||
@@ -6418,8 +6442,7 @@ class Device(utils.CompositeEventEmitter):
|
||||
logger.warning('peer name is not valid UTF-8')
|
||||
if connection:
|
||||
connection.emit(connection.EVENT_REMOTE_NAME_FAILURE, error)
|
||||
else:
|
||||
self.emit(self.EVENT_REMOTE_NAME_FAILURE, address, error)
|
||||
self.emit(self.EVENT_REMOTE_NAME_FAILURE, address, error)
|
||||
|
||||
# [Classic only]
|
||||
@host_event_handler
|
||||
@@ -6436,7 +6459,13 @@ class Device(utils.CompositeEventEmitter):
|
||||
@with_connection_from_address
|
||||
@utils.experimental('Only for testing.')
|
||||
def on_sco_connection(
|
||||
self, acl_connection: Connection, sco_handle: int, link_type: int
|
||||
self,
|
||||
acl_connection: Connection,
|
||||
sco_handle: int,
|
||||
link_type: int,
|
||||
rx_packet_length: int,
|
||||
tx_packet_length: int,
|
||||
air_mode: int,
|
||||
) -> None:
|
||||
logger.debug(
|
||||
f'*** SCO connected: {acl_connection.peer_address}, '
|
||||
@@ -6448,7 +6477,11 @@ class Device(utils.CompositeEventEmitter):
|
||||
acl_connection=acl_connection,
|
||||
handle=sco_handle,
|
||||
link_type=link_type,
|
||||
rx_packet_length=rx_packet_length,
|
||||
tx_packet_length=tx_packet_length,
|
||||
air_mode=hci.CodecID(air_mode),
|
||||
)
|
||||
acl_connection.emit(self.EVENT_SCO_CONNECTION, sco_link)
|
||||
self.emit(self.EVENT_SCO_CONNECTION, sco_link)
|
||||
|
||||
# [Classic only]
|
||||
@@ -6459,7 +6492,8 @@ class Device(utils.CompositeEventEmitter):
|
||||
self, acl_connection: Connection, status: int
|
||||
) -> None:
|
||||
logger.debug(f'*** SCO connection failure: {acl_connection.peer_address}***')
|
||||
self.emit(self.EVENT_SCO_CONNECTION_FAILURE)
|
||||
acl_connection.emit(self.EVENT_SCO_CONNECTION_FAILURE, status)
|
||||
self.emit(self.EVENT_SCO_CONNECTION_FAILURE, status)
|
||||
|
||||
# [Classic only]
|
||||
@host_event_handler
|
||||
@@ -6780,11 +6814,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)
|
||||
|
||||
@@ -6922,15 +6956,18 @@ class Device(utils.CompositeEventEmitter):
|
||||
@with_connection_from_address
|
||||
def on_classic_pairing(self, connection: Connection) -> None:
|
||||
connection.emit(connection.EVENT_CLASSIC_PAIRING)
|
||||
self.emit(connection.EVENT_CLASSIC_PAIRING, connection)
|
||||
|
||||
# [Classic only]
|
||||
@host_event_handler
|
||||
@with_connection_from_address
|
||||
def on_classic_pairing_failure(self, connection: Connection, status: int) -> None:
|
||||
connection.emit(connection.EVENT_CLASSIC_PAIRING_FAILURE, status)
|
||||
self.emit(connection.EVENT_CLASSIC_PAIRING_FAILURE, connection, status)
|
||||
|
||||
def on_pairing_start(self, connection: Connection) -> None:
|
||||
connection.emit(connection.EVENT_PAIRING_START)
|
||||
self.emit(connection.EVENT_PAIRING_START, connection)
|
||||
|
||||
def on_pairing(
|
||||
self,
|
||||
|
||||
+121
-28
@@ -1721,6 +1721,15 @@ class CodecID(SpecableEnum):
|
||||
VENDOR_SPECIFIC = 0xFF
|
||||
|
||||
|
||||
# From Bluetooth Assigned Numbers, 2.10 PCM_Data_Format
|
||||
class PcmDataFormat(SpecableEnum):
|
||||
NA = 0x00
|
||||
ONES_COMPLEMENT = 0x01
|
||||
TWOS_COMPLEMENT = 0x02
|
||||
SIGN_MAGNITUDE = 0x03
|
||||
UNSIGNED = 0x04
|
||||
|
||||
|
||||
@dataclasses.dataclass(frozen=True)
|
||||
class CodingFormat:
|
||||
codec_id: CodecID
|
||||
@@ -1729,7 +1738,7 @@ class CodingFormat:
|
||||
|
||||
@classmethod
|
||||
def parse_from_bytes(cls, data: bytes, offset: int) -> tuple[int, CodingFormat]:
|
||||
(codec_id, company_id, vendor_specific_codec_id) = struct.unpack_from(
|
||||
codec_id, company_id, vendor_specific_codec_id = struct.unpack_from(
|
||||
'<BHH', data, offset
|
||||
)
|
||||
return offset + 5, cls(
|
||||
@@ -1748,6 +1757,61 @@ class CodingFormat:
|
||||
)
|
||||
|
||||
|
||||
@dataclasses.dataclass(frozen=True)
|
||||
class VoiceSetting:
|
||||
class AirCodingFormat(enum.IntEnum):
|
||||
CVSD = 0
|
||||
U_LAW = 1
|
||||
A_LAW = 2
|
||||
TRANSPARENT_DATA = 3
|
||||
|
||||
class InputSampleSize(enum.IntEnum):
|
||||
SIZE_8_BITS = 0
|
||||
SIZE_16_BITS = 1
|
||||
|
||||
class InputDataFormat(enum.IntEnum):
|
||||
ONES_COMPLEMENT = 0
|
||||
TWOS_COMPLEMENT = 1
|
||||
SIGN_AND_MAGNITUDE = 2
|
||||
UNSIGNED = 3
|
||||
|
||||
class InputCodingFormat(enum.IntEnum):
|
||||
LINEAR = 0
|
||||
U_LAW = 1
|
||||
A_LAW = 2
|
||||
RESERVED = 3
|
||||
|
||||
air_coding_format: AirCodingFormat = AirCodingFormat.CVSD
|
||||
linear_pcm_bit_position: int = 0
|
||||
input_sample_size: InputSampleSize = InputSampleSize.SIZE_8_BITS
|
||||
input_data_format: InputDataFormat = InputDataFormat.ONES_COMPLEMENT
|
||||
input_coding_format: InputCodingFormat = InputCodingFormat.LINEAR
|
||||
|
||||
@classmethod
|
||||
def from_int(cls, value: int) -> VoiceSetting:
|
||||
air_coding_format = cls.AirCodingFormat(value & 0b11)
|
||||
linear_pcm_bit_position = (value >> 2) & 0b111
|
||||
input_sample_size = cls.InputSampleSize((value >> 5) & 0b1)
|
||||
input_data_format = cls.InputDataFormat((value >> 6) & 0b11)
|
||||
input_coding_format = cls.InputCodingFormat((value >> 8) & 0b11)
|
||||
return cls(
|
||||
air_coding_format=air_coding_format,
|
||||
linear_pcm_bit_position=linear_pcm_bit_position,
|
||||
input_sample_size=input_sample_size,
|
||||
input_data_format=input_data_format,
|
||||
input_coding_format=input_coding_format,
|
||||
)
|
||||
|
||||
def __int__(self) -> int:
|
||||
return (
|
||||
self.air_coding_format
|
||||
| (self.linear_pcm_bit_position << 2)
|
||||
| (self.input_sample_size << 5)
|
||||
| (self.input_data_format << 6)
|
||||
| (self.input_coding_format << 8)
|
||||
)
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
class HCI_Constant:
|
||||
@staticmethod
|
||||
@@ -2008,7 +2072,7 @@ class HCI_Object:
|
||||
)
|
||||
continue
|
||||
|
||||
(field_name, field_type) = object_field
|
||||
field_name, field_type = object_field
|
||||
result += HCI_Object.serialize_field(hci_object[field_name], field_type)
|
||||
|
||||
return bytes(result)
|
||||
@@ -2886,6 +2950,23 @@ class HCI_Read_Clock_Offset_Command(HCI_AsyncCommand):
|
||||
connection_handle: int = field(metadata=metadata(2))
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
@HCI_Command.command
|
||||
@dataclasses.dataclass
|
||||
class HCI_Accept_Synchronous_Connection_Request_Command(HCI_AsyncCommand):
|
||||
'''
|
||||
See Bluetooth spec @ 7.1.27 Accept Synchronous Connection Request Command
|
||||
'''
|
||||
|
||||
bd_addr: Address = field(metadata=metadata(Address.parse_address))
|
||||
transmit_bandwidth: int = field(metadata=metadata(4))
|
||||
receive_bandwidth: int = field(metadata=metadata(4))
|
||||
max_latency: int = field(metadata=metadata(2))
|
||||
voice_setting: int = field(metadata=metadata(2))
|
||||
retransmission_effort: int = field(metadata=metadata(1))
|
||||
packet_type: int = field(metadata=metadata(2))
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
@HCI_Command.command
|
||||
@dataclasses.dataclass
|
||||
@@ -3034,8 +3115,8 @@ class HCI_Enhanced_Setup_Synchronous_Connection_Command(HCI_AsyncCommand):
|
||||
output_coding_format: int = field(metadata=metadata(CodingFormat.parse_from_bytes))
|
||||
input_coded_data_size: int = field(metadata=metadata(2))
|
||||
output_coded_data_size: int = field(metadata=metadata(2))
|
||||
input_pcm_data_format: int = field(metadata=metadata(1))
|
||||
output_pcm_data_format: int = field(metadata=metadata(1))
|
||||
input_pcm_data_format: int = field(metadata=PcmDataFormat.type_metadata(1))
|
||||
output_pcm_data_format: int = field(metadata=PcmDataFormat.type_metadata(1))
|
||||
input_pcm_sample_payload_msb_position: int = field(metadata=metadata(1))
|
||||
output_pcm_sample_payload_msb_position: int = field(metadata=metadata(1))
|
||||
input_data_path: int = field(metadata=metadata(1))
|
||||
@@ -3046,13 +3127,6 @@ class HCI_Enhanced_Setup_Synchronous_Connection_Command(HCI_AsyncCommand):
|
||||
packet_type: int = field(metadata=metadata(2))
|
||||
retransmission_effort: int = field(metadata=metadata(1))
|
||||
|
||||
class PcmDataFormat(SpecableEnum):
|
||||
NA = 0x00
|
||||
ONES_COMPLEMENT = 0x01
|
||||
TWOS_COMPLEMENT = 0x02
|
||||
SIGN_MAGNITUDE = 0x03
|
||||
UNSIGNED = 0x04
|
||||
|
||||
class DataPath(SpecableEnum):
|
||||
HCI = 0x00
|
||||
PCM = 0x01
|
||||
@@ -3099,8 +3173,8 @@ class HCI_Enhanced_Accept_Synchronous_Connection_Request_Command(HCI_AsyncComman
|
||||
output_coding_format: int = field(metadata=metadata(CodingFormat.parse_from_bytes))
|
||||
input_coded_data_size: int = field(metadata=metadata(2))
|
||||
output_coded_data_size: int = field(metadata=metadata(2))
|
||||
input_pcm_data_format: int = field(metadata=metadata(1))
|
||||
output_pcm_data_format: int = field(metadata=metadata(1))
|
||||
input_pcm_data_format: int = field(metadata=PcmDataFormat.type_metadata(1))
|
||||
output_pcm_data_format: int = field(metadata=PcmDataFormat.type_metadata(1))
|
||||
input_pcm_sample_payload_msb_position: int = field(metadata=metadata(1))
|
||||
output_pcm_sample_payload_msb_position: int = field(metadata=metadata(1))
|
||||
input_data_path: int = field(metadata=metadata(1))
|
||||
@@ -3944,6 +4018,23 @@ class HCI_Read_Local_OOB_Extended_Data_Command(
|
||||
'''
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
@HCI_SyncCommand.sync_command(HCI_StatusReturnParameters)
|
||||
@dataclasses.dataclass
|
||||
class HCI_Configure_Data_Path_Command(HCI_SyncCommand[HCI_StatusReturnParameters]):
|
||||
'''
|
||||
See Bluetooth spec @ 7.3.101 Configure Data Path Command
|
||||
'''
|
||||
|
||||
class DataPathDirection(SpecableEnum):
|
||||
INPUT = 0x00
|
||||
OUTPUT = 0x01
|
||||
|
||||
data_path_direction: DataPathDirection = field(metadata=metadata(1))
|
||||
data_path_id: int = field(metadata=metadata(1))
|
||||
vendor_specific_config: bytes = field(metadata=metadata('*'))
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
@dataclasses.dataclass
|
||||
class HCI_Read_Local_Version_Information_ReturnParameters(HCI_StatusReturnParameters):
|
||||
@@ -7334,7 +7425,7 @@ class HCI_Connection_Complete_Event(HCI_Event):
|
||||
status: int = field(metadata=metadata(STATUS_SPEC))
|
||||
connection_handle: int = field(metadata=metadata(2))
|
||||
bd_addr: Address = field(metadata=metadata(Address.parse_address))
|
||||
link_type: int = field(metadata=LinkType.type_metadata(1))
|
||||
link_type: LinkType = field(metadata=LinkType.type_metadata(1))
|
||||
encryption_enabled: int = field(metadata=metadata(1))
|
||||
|
||||
|
||||
@@ -7730,12 +7821,6 @@ class HCI_Synchronous_Connection_Complete_Event(HCI_Event):
|
||||
SCO = 0x00
|
||||
ESCO = 0x02
|
||||
|
||||
class AirMode(SpecableEnum):
|
||||
U_LAW_LOG = 0x00
|
||||
A_LAW_LOG_AIR_MORE = 0x01
|
||||
CVSD = 0x02
|
||||
TRANSPARENT_DATA = 0x03
|
||||
|
||||
status: int = field(metadata=metadata(STATUS_SPEC))
|
||||
connection_handle: int = field(metadata=metadata(2))
|
||||
bd_addr: Address = field(metadata=metadata(Address.parse_address))
|
||||
@@ -7744,7 +7829,7 @@ class HCI_Synchronous_Connection_Complete_Event(HCI_Event):
|
||||
retransmission_window: int = field(metadata=metadata(1))
|
||||
rx_packet_length: int = field(metadata=metadata(2))
|
||||
tx_packet_length: int = field(metadata=metadata(2))
|
||||
air_mode: int = field(metadata=AirMode.type_metadata(1))
|
||||
air_mode: int = field(metadata=CodecID.type_metadata(1))
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
@@ -7976,7 +8061,9 @@ class HCI_AclDataPacket(HCI_Packet):
|
||||
bc_flag = (h >> 14) & 3
|
||||
data = packet[5:]
|
||||
if len(data) != data_total_length:
|
||||
raise InvalidPacketError('invalid packet length')
|
||||
raise InvalidPacketError(
|
||||
f'invalid packet length {len(data)} != {data_total_length}'
|
||||
)
|
||||
return cls(
|
||||
connection_handle=connection_handle,
|
||||
pb_flag=pb_flag,
|
||||
@@ -8009,10 +8096,16 @@ class HCI_SynchronousDataPacket(HCI_Packet):
|
||||
See Bluetooth spec @ 5.4.3 HCI SCO Data Packets
|
||||
'''
|
||||
|
||||
class Status(enum.IntEnum):
|
||||
CORRECTLY_RECEIVED_DATA = 0b00
|
||||
POSSIBLY_INVALID_DATA = 0b01
|
||||
NO_DATA = 0b10
|
||||
DATA_PARTIALLY_LOST = 0b11
|
||||
|
||||
hci_packet_type = HCI_SYNCHRONOUS_DATA_PACKET
|
||||
|
||||
connection_handle: int
|
||||
packet_status: int
|
||||
packet_status: Status
|
||||
data_total_length: int
|
||||
data: bytes
|
||||
|
||||
@@ -8021,7 +8114,7 @@ class HCI_SynchronousDataPacket(HCI_Packet):
|
||||
# Read the header
|
||||
h, data_total_length = struct.unpack_from('<HB', packet, 1)
|
||||
connection_handle = h & 0xFFF
|
||||
packet_status = (h >> 12) & 0b11
|
||||
packet_status = cls.Status((h >> 12) & 0b11)
|
||||
data = packet[4:]
|
||||
if len(data) != data_total_length:
|
||||
raise InvalidPacketError(
|
||||
@@ -8045,7 +8138,7 @@ class HCI_SynchronousDataPacket(HCI_Packet):
|
||||
return (
|
||||
f'{color("SCO", "blue")}: '
|
||||
f'handle=0x{self.connection_handle:04x}, '
|
||||
f'ps={self.packet_status}, '
|
||||
f'ps={self.packet_status.name}, '
|
||||
f'data_total_length={self.data_total_length}, '
|
||||
f'data={self.data.hex()}'
|
||||
)
|
||||
@@ -8073,8 +8166,8 @@ class HCI_IsoDataPacket(HCI_Packet):
|
||||
def __post_init__(self) -> None:
|
||||
self.ts_flag = self.time_stamp is not None
|
||||
|
||||
@staticmethod
|
||||
def from_bytes(packet: bytes) -> HCI_IsoDataPacket:
|
||||
@classmethod
|
||||
def from_bytes(cls, packet: bytes) -> HCI_IsoDataPacket:
|
||||
time_stamp: int | None = None
|
||||
packet_sequence_number: int | None = None
|
||||
iso_sdu_length: int | None = None
|
||||
@@ -8103,7 +8196,7 @@ class HCI_IsoDataPacket(HCI_Packet):
|
||||
pos += 4
|
||||
|
||||
iso_sdu_fragment = packet[pos:]
|
||||
return HCI_IsoDataPacket(
|
||||
return cls(
|
||||
connection_handle=connection_handle,
|
||||
pb_flag=pb_flag,
|
||||
ts_flag=ts_flag,
|
||||
|
||||
+16
-19
@@ -44,6 +44,7 @@ from bumble.hci import (
|
||||
CodecID,
|
||||
CodingFormat,
|
||||
HCI_Enhanced_Setup_Synchronous_Connection_Command,
|
||||
PcmDataFormat,
|
||||
)
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
@@ -177,7 +178,7 @@ class AgFeature(enum.IntFlag):
|
||||
VOICE_RECOGNITION_TEXT = 0x2000
|
||||
|
||||
|
||||
class AudioCodec(enum.IntEnum):
|
||||
class AudioCodec(utils.OpenIntEnum):
|
||||
"""
|
||||
Audio Codec IDs (normative).
|
||||
|
||||
@@ -189,7 +190,7 @@ class AudioCodec(enum.IntEnum):
|
||||
LC3_SWB = 0x03 # Support for LC3-SWB audio codec
|
||||
|
||||
|
||||
class HfIndicator(enum.IntEnum):
|
||||
class HfIndicator(utils.OpenIntEnum):
|
||||
"""
|
||||
HF Indicators (normative).
|
||||
|
||||
@@ -218,7 +219,7 @@ class CallHoldOperation(enum.Enum):
|
||||
)
|
||||
|
||||
|
||||
class ResponseHoldStatus(enum.IntEnum):
|
||||
class ResponseHoldStatus(utils.OpenIntEnum):
|
||||
"""
|
||||
Response Hold status (normative).
|
||||
|
||||
@@ -246,7 +247,7 @@ class AgIndicator(enum.Enum):
|
||||
BATTERY_CHARGE = 'battchg'
|
||||
|
||||
|
||||
class CallSetupAgIndicator(enum.IntEnum):
|
||||
class CallSetupAgIndicator(utils.OpenIntEnum):
|
||||
"""
|
||||
Values for the Call Setup AG indicator (normative).
|
||||
|
||||
@@ -259,7 +260,7 @@ class CallSetupAgIndicator(enum.IntEnum):
|
||||
REMOTE_ALERTED = 3 # Remote party alerted in an outgoing call
|
||||
|
||||
|
||||
class CallHeldAgIndicator(enum.IntEnum):
|
||||
class CallHeldAgIndicator(utils.OpenIntEnum):
|
||||
"""
|
||||
Values for the Call Held AG indicator (normative).
|
||||
|
||||
@@ -273,7 +274,7 @@ class CallHeldAgIndicator(enum.IntEnum):
|
||||
CALL_ON_HOLD_NO_ACTIVE_CALL = 2 # Call on hold, no active call
|
||||
|
||||
|
||||
class CallInfoDirection(enum.IntEnum):
|
||||
class CallInfoDirection(utils.OpenIntEnum):
|
||||
"""
|
||||
Call Info direction (normative).
|
||||
|
||||
@@ -284,7 +285,7 @@ class CallInfoDirection(enum.IntEnum):
|
||||
MOBILE_TERMINATED_CALL = 1
|
||||
|
||||
|
||||
class CallInfoStatus(enum.IntEnum):
|
||||
class CallInfoStatus(utils.OpenIntEnum):
|
||||
"""
|
||||
Call Info status (normative).
|
||||
|
||||
@@ -299,7 +300,7 @@ class CallInfoStatus(enum.IntEnum):
|
||||
WAITING = 5
|
||||
|
||||
|
||||
class CallInfoMode(enum.IntEnum):
|
||||
class CallInfoMode(utils.OpenIntEnum):
|
||||
"""
|
||||
Call Info mode (normative).
|
||||
|
||||
@@ -312,7 +313,7 @@ class CallInfoMode(enum.IntEnum):
|
||||
UNKNOWN = 9
|
||||
|
||||
|
||||
class CallInfoMultiParty(enum.IntEnum):
|
||||
class CallInfoMultiParty(utils.OpenIntEnum):
|
||||
"""
|
||||
Call Info Multi-Party state (normative).
|
||||
|
||||
@@ -399,7 +400,7 @@ class CallLineIdentification:
|
||||
)
|
||||
|
||||
|
||||
class VoiceRecognitionState(enum.IntEnum):
|
||||
class VoiceRecognitionState(utils.OpenIntEnum):
|
||||
"""
|
||||
vrec values provided in AT+BVRA command.
|
||||
|
||||
@@ -412,7 +413,7 @@ class VoiceRecognitionState(enum.IntEnum):
|
||||
ENHANCED_READY = 2
|
||||
|
||||
|
||||
class CmeError(enum.IntEnum):
|
||||
class CmeError(utils.OpenIntEnum):
|
||||
"""
|
||||
CME ERROR codes (partial listed).
|
||||
|
||||
@@ -1606,7 +1607,7 @@ class AgProtocol(utils.EventEmitter):
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
|
||||
class ProfileVersion(enum.IntEnum):
|
||||
class ProfileVersion(utils.OpenIntEnum):
|
||||
"""
|
||||
Profile version (normative).
|
||||
|
||||
@@ -1954,12 +1955,8 @@ class EscoParameters:
|
||||
output_coding_format: CodingFormat = CodingFormat(CodecID.LINEAR_PCM)
|
||||
input_coded_data_size: int = 16
|
||||
output_coded_data_size: int = 16
|
||||
input_pcm_data_format: (
|
||||
HCI_Enhanced_Setup_Synchronous_Connection_Command.PcmDataFormat
|
||||
) = HCI_Enhanced_Setup_Synchronous_Connection_Command.PcmDataFormat.TWOS_COMPLEMENT
|
||||
output_pcm_data_format: (
|
||||
HCI_Enhanced_Setup_Synchronous_Connection_Command.PcmDataFormat
|
||||
) = HCI_Enhanced_Setup_Synchronous_Connection_Command.PcmDataFormat.TWOS_COMPLEMENT
|
||||
input_pcm_data_format: PcmDataFormat = PcmDataFormat.TWOS_COMPLEMENT
|
||||
output_pcm_data_format: PcmDataFormat = PcmDataFormat.TWOS_COMPLEMENT
|
||||
input_pcm_sample_payload_msb_position: int = 0
|
||||
output_pcm_sample_payload_msb_position: int = 0
|
||||
input_data_path: HCI_Enhanced_Setup_Synchronous_Connection_Command.DataPath = (
|
||||
@@ -2058,6 +2055,7 @@ _ESCO_PARAMETERS_MSBC_T1 = EscoParameters(
|
||||
max_latency=0x0008,
|
||||
packet_type=(
|
||||
HCI_Enhanced_Setup_Synchronous_Connection_Command.PacketType.EV3
|
||||
| HCI_Enhanced_Setup_Synchronous_Connection_Command.PacketType.NO_2_EV3
|
||||
| HCI_Enhanced_Setup_Synchronous_Connection_Command.PacketType.NO_3_EV3
|
||||
| HCI_Enhanced_Setup_Synchronous_Connection_Command.PacketType.NO_2_EV5
|
||||
| HCI_Enhanced_Setup_Synchronous_Connection_Command.PacketType.NO_3_EV5
|
||||
@@ -2073,7 +2071,6 @@ _ESCO_PARAMETERS_MSBC_T2 = EscoParameters(
|
||||
max_latency=0x000D,
|
||||
packet_type=(
|
||||
HCI_Enhanced_Setup_Synchronous_Connection_Command.PacketType.EV3
|
||||
| HCI_Enhanced_Setup_Synchronous_Connection_Command.PacketType.NO_2_EV3
|
||||
| HCI_Enhanced_Setup_Synchronous_Connection_Command.PacketType.NO_3_EV3
|
||||
| HCI_Enhanced_Setup_Synchronous_Connection_Command.PacketType.NO_2_EV5
|
||||
| HCI_Enhanced_Setup_Synchronous_Connection_Command.PacketType.NO_3_EV5
|
||||
|
||||
+120
-3
@@ -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,
|
||||
@@ -686,6 +690,8 @@ class Host(utils.EventEmitter):
|
||||
self.pending_response, timeout=response_timeout
|
||||
)
|
||||
return response
|
||||
except asyncio.TimeoutError:
|
||||
raise
|
||||
except Exception:
|
||||
logger.exception(color("!!! Exception while sending command:", "red"))
|
||||
raise
|
||||
@@ -864,7 +870,7 @@ class Host(utils.EventEmitter):
|
||||
self.send_hci_packet(
|
||||
hci.HCI_SynchronousDataPacket(
|
||||
connection_handle=connection_handle,
|
||||
packet_status=0,
|
||||
packet_status=hci.HCI_SynchronousDataPacket.Status.CORRECTLY_RECEIVED_DATA,
|
||||
data_total_length=len(sdu),
|
||||
data=sdu,
|
||||
)
|
||||
@@ -1026,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
|
||||
@@ -1176,11 +1258,28 @@ class Host(utils.EventEmitter):
|
||||
def on_hci_connection_complete_event(
|
||||
self, event: hci.HCI_Connection_Complete_Event
|
||||
):
|
||||
if event.link_type == hci.HCI_Connection_Complete_Event.LinkType.SCO:
|
||||
# Pass this on to the synchronous connection handler
|
||||
forwarded_event = hci.HCI_Synchronous_Connection_Complete_Event(
|
||||
status=event.status,
|
||||
connection_handle=event.connection_handle,
|
||||
bd_addr=event.bd_addr,
|
||||
link_type=event.link_type,
|
||||
transmission_interval=0,
|
||||
retransmission_window=0,
|
||||
rx_packet_length=0,
|
||||
tx_packet_length=0,
|
||||
air_mode=0,
|
||||
)
|
||||
self.on_hci_synchronous_connection_complete_event(forwarded_event)
|
||||
return
|
||||
|
||||
if event.status == hci.HCI_SUCCESS:
|
||||
# Create/update the connection
|
||||
logger.debug(
|
||||
f'### BR/EDR CONNECTION: [0x{event.connection_handle:04X}] '
|
||||
f'{event.bd_addr}'
|
||||
f'### BR/EDR ACL CONNECTION: [0x{event.connection_handle:04X}] '
|
||||
f'{event.bd_addr} '
|
||||
f'{event.link_type.name}'
|
||||
)
|
||||
|
||||
connection = self.connections.get(event.connection_handle)
|
||||
@@ -1232,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)
|
||||
@@ -1352,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
|
||||
):
|
||||
@@ -1580,6 +1694,9 @@ class Host(utils.EventEmitter):
|
||||
event.bd_addr,
|
||||
event.connection_handle,
|
||||
event.link_type,
|
||||
event.rx_packet_length,
|
||||
event.tx_packet_length,
|
||||
event.air_mode,
|
||||
)
|
||||
else:
|
||||
logger.debug(f'### SCO CONNECTION FAILED: {event.status}')
|
||||
|
||||
+32
-18
@@ -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,
|
||||
|
||||
+1
-1
@@ -110,7 +110,7 @@ RFCOMM_DEFAULT_L2CAP_MTU = 2048
|
||||
RFCOMM_DEFAULT_INITIAL_CREDITS = 7
|
||||
RFCOMM_DEFAULT_MAX_CREDITS = 32
|
||||
RFCOMM_DEFAULT_CREDIT_THRESHOLD = RFCOMM_DEFAULT_MAX_CREDITS // 2
|
||||
RFCOMM_DEFAULT_MAX_FRAME_SIZE = 2000
|
||||
RFCOMM_DEFAULT_MAX_FRAME_SIZE = 1000
|
||||
|
||||
RFCOMM_DYNAMIC_CHANNEL_NUMBER_START = 1
|
||||
RFCOMM_DYNAMIC_CHANNEL_NUMBER_END = 30
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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')
|
||||
|
||||
+737
-353
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||
```
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
+242
-111
@@ -20,17 +20,119 @@ import contextlib
|
||||
import functools
|
||||
import json
|
||||
import sys
|
||||
import wave
|
||||
|
||||
import websockets.asyncio.server
|
||||
|
||||
import bumble.logging
|
||||
from bumble import hci, hfp, rfcomm
|
||||
from bumble.device import Connection, Device
|
||||
from bumble.device import Connection, Device, ScoLink
|
||||
from bumble.hfp import HfProtocol
|
||||
from bumble.transport import open_transport
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
ws: websockets.asyncio.server.ServerConnection | None = None
|
||||
hf_protocol: HfProtocol | None = None
|
||||
input_wav: wave.Wave_read | None = None
|
||||
output_wav: wave.Wave_write | None = None
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
def on_audio_packet(packet: hci.HCI_SynchronousDataPacket) -> None:
|
||||
if (
|
||||
packet.packet_status
|
||||
!= hci.HCI_SynchronousDataPacket.Status.CORRECTLY_RECEIVED_DATA
|
||||
):
|
||||
print('!!! discarding packet with status ', packet.packet_status.name)
|
||||
return
|
||||
|
||||
frame_count = len(packet.data) // 2
|
||||
print(f">>> received {frame_count} PCM samples")
|
||||
|
||||
if output_wav:
|
||||
# Save the PCM audio to the output
|
||||
output_wav.writeframes(packet.data)
|
||||
|
||||
if input_wav and hf_protocol:
|
||||
# Send PCM audio from the input, same amount as what was received
|
||||
while not (pcm_data := input_wav.readframes(frame_count)):
|
||||
input_wav.setpos(0) # Loop
|
||||
print(f">>> sending {frame_count} PCM samples")
|
||||
hf_protocol.dlc.multiplexer.l2cap_channel.connection.device.host.send_sco_sdu(
|
||||
connection_handle=packet.connection_handle,
|
||||
sdu=pcm_data,
|
||||
)
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
def on_sco_connection(link: ScoLink) -> None:
|
||||
print('### SCO connection established:', link)
|
||||
if link.air_mode == hci.CodecID.TRANSPARENT:
|
||||
print("@@@ The controller does not encode/decode voice")
|
||||
return
|
||||
|
||||
link.sink = on_audio_packet
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
def on_sco_request(
|
||||
link_type: int, connection: Connection, protocol: HfProtocol
|
||||
) -> None:
|
||||
if link_type == hci.HCI_Connection_Complete_Event.LinkType.SCO:
|
||||
esco_parameters = hfp.ESCO_PARAMETERS[hfp.DefaultCodecParameters.SCO_CVSD_D1]
|
||||
elif protocol.active_codec == hfp.AudioCodec.MSBC:
|
||||
esco_parameters = hfp.ESCO_PARAMETERS[hfp.DefaultCodecParameters.ESCO_MSBC_T2]
|
||||
elif protocol.active_codec == hfp.AudioCodec.CVSD:
|
||||
esco_parameters = hfp.ESCO_PARAMETERS[hfp.DefaultCodecParameters.ESCO_CVSD_S4]
|
||||
else:
|
||||
raise RuntimeError("unknown active codec")
|
||||
|
||||
if connection.device.host.supports_command(
|
||||
hci.HCI_ENHANCED_ACCEPT_SYNCHRONOUS_CONNECTION_REQUEST_COMMAND
|
||||
):
|
||||
connection.cancel_on_disconnection(
|
||||
connection.device.send_async_command(
|
||||
hci.HCI_Enhanced_Accept_Synchronous_Connection_Request_Command(
|
||||
bd_addr=connection.peer_address, **esco_parameters.asdict()
|
||||
)
|
||||
)
|
||||
)
|
||||
elif connection.device.host.supports_command(
|
||||
hci.HCI_ACCEPT_SYNCHRONOUS_CONNECTION_REQUEST_COMMAND
|
||||
):
|
||||
connection.cancel_on_disconnection(
|
||||
connection.device.send_async_command(
|
||||
hci.HCI_Accept_Synchronous_Connection_Request_Command(
|
||||
bd_addr=connection.peer_address,
|
||||
transmit_bandwidth=esco_parameters.transmit_bandwidth,
|
||||
receive_bandwidth=esco_parameters.receive_bandwidth,
|
||||
max_latency=esco_parameters.max_latency,
|
||||
voice_setting=int(
|
||||
hci.VoiceSetting(
|
||||
input_sample_size=hci.VoiceSetting.InputSampleSize.SIZE_16_BITS,
|
||||
input_data_format=hci.VoiceSetting.InputDataFormat.TWOS_COMPLEMENT,
|
||||
)
|
||||
),
|
||||
retransmission_effort=esco_parameters.retransmission_effort,
|
||||
packet_type=esco_parameters.packet_type,
|
||||
)
|
||||
)
|
||||
)
|
||||
else:
|
||||
print('!!! no supported command for SCO connection request')
|
||||
return
|
||||
|
||||
global output_wav
|
||||
if output_wav:
|
||||
output_wav.setnchannels(1)
|
||||
output_wav.setsampwidth(2)
|
||||
match protocol.active_codec:
|
||||
case hfp.AudioCodec.CVSD:
|
||||
output_wav.setframerate(8000)
|
||||
case hfp.AudioCodec.MSBC:
|
||||
output_wav.setframerate(16000)
|
||||
|
||||
connection.on('sco_connection', on_sco_connection)
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
@@ -40,134 +142,163 @@ def on_dlc(dlc: rfcomm.DLC, configuration: hfp.HfConfiguration):
|
||||
hf_protocol = HfProtocol(dlc, configuration)
|
||||
asyncio.create_task(hf_protocol.run())
|
||||
|
||||
def on_sco_request(connection: Connection, link_type: int, protocol: HfProtocol):
|
||||
if connection == protocol.dlc.multiplexer.l2cap_channel.connection:
|
||||
if link_type == hci.HCI_Connection_Complete_Event.LinkType.SCO:
|
||||
esco_parameters = hfp.ESCO_PARAMETERS[
|
||||
hfp.DefaultCodecParameters.SCO_CVSD_D1
|
||||
]
|
||||
elif protocol.active_codec == hfp.AudioCodec.MSBC:
|
||||
esco_parameters = hfp.ESCO_PARAMETERS[
|
||||
hfp.DefaultCodecParameters.ESCO_MSBC_T2
|
||||
]
|
||||
elif protocol.active_codec == hfp.AudioCodec.CVSD:
|
||||
esco_parameters = hfp.ESCO_PARAMETERS[
|
||||
hfp.DefaultCodecParameters.ESCO_CVSD_S4
|
||||
]
|
||||
else:
|
||||
raise RuntimeError("unknown active codec")
|
||||
|
||||
connection.cancel_on_disconnection(
|
||||
connection.device.send_command(
|
||||
hci.HCI_Enhanced_Accept_Synchronous_Connection_Request_Command(
|
||||
bd_addr=connection.peer_address, **esco_parameters.asdict()
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
handler = functools.partial(on_sco_request, protocol=hf_protocol)
|
||||
dlc.multiplexer.l2cap_channel.connection.device.on('sco_request', handler)
|
||||
connection = dlc.multiplexer.l2cap_channel.connection
|
||||
handler = functools.partial(
|
||||
on_sco_request,
|
||||
connection=connection,
|
||||
protocol=hf_protocol,
|
||||
)
|
||||
connection.on('sco_request', handler)
|
||||
dlc.multiplexer.l2cap_channel.once(
|
||||
'close',
|
||||
lambda: dlc.multiplexer.l2cap_channel.connection.device.remove_listener(
|
||||
'sco_request', handler
|
||||
),
|
||||
lambda: connection.remove_listener('sco_request', handler),
|
||||
)
|
||||
|
||||
def on_ag_indicator(indicator):
|
||||
global ws
|
||||
if ws:
|
||||
asyncio.create_task(ws.send(str(indicator)))
|
||||
|
||||
hf_protocol.on('ag_indicator', on_ag_indicator)
|
||||
hf_protocol.on('codec_negotiation', on_codec_negotiation)
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
def on_ag_indicator(indicator):
|
||||
global ws
|
||||
if ws:
|
||||
asyncio.create_task(ws.send(str(indicator)))
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
def on_codec_negotiation(codec: hfp.AudioCodec):
|
||||
print(f'### Negotiated codec: {codec.name}')
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
async def run(device: Device, codec: str | None) -> None:
|
||||
if codec is None:
|
||||
supported_audio_codecs = [hfp.AudioCodec.CVSD, hfp.AudioCodec.MSBC]
|
||||
else:
|
||||
if codec == 'cvsd':
|
||||
supported_audio_codecs = [hfp.AudioCodec.CVSD]
|
||||
elif codec == 'msbc':
|
||||
supported_audio_codecs = [hfp.AudioCodec.MSBC]
|
||||
else:
|
||||
print('Unknown codec: ', codec)
|
||||
return
|
||||
|
||||
# Hands-Free profile configuration.
|
||||
# TODO: load configuration from file.
|
||||
configuration = hfp.HfConfiguration(
|
||||
supported_hf_features=[
|
||||
hfp.HfFeature.THREE_WAY_CALLING,
|
||||
hfp.HfFeature.REMOTE_VOLUME_CONTROL,
|
||||
hfp.HfFeature.ENHANCED_CALL_STATUS,
|
||||
hfp.HfFeature.ENHANCED_CALL_CONTROL,
|
||||
hfp.HfFeature.CODEC_NEGOTIATION,
|
||||
hfp.HfFeature.HF_INDICATORS,
|
||||
hfp.HfFeature.ESCO_S4_SETTINGS_SUPPORTED,
|
||||
],
|
||||
supported_hf_indicators=[
|
||||
hfp.HfIndicator.BATTERY_LEVEL,
|
||||
],
|
||||
supported_audio_codecs=supported_audio_codecs,
|
||||
)
|
||||
|
||||
# Create and register a server
|
||||
rfcomm_server = rfcomm.Server(device)
|
||||
|
||||
# Listen for incoming DLC connections
|
||||
channel_number = rfcomm_server.listen(lambda dlc: on_dlc(dlc, configuration))
|
||||
print(f'### Listening for connection on channel {channel_number}')
|
||||
|
||||
# Advertise the HFP RFComm channel in the SDP
|
||||
device.sdp_service_records = {
|
||||
0x00010001: hfp.make_hf_sdp_records(0x00010001, channel_number, configuration)
|
||||
}
|
||||
|
||||
# Let's go!
|
||||
await device.power_on()
|
||||
|
||||
# Start being discoverable and connectable
|
||||
await device.set_discoverable(True)
|
||||
await device.set_connectable(True)
|
||||
|
||||
# Start the UI websocket server to offer a few buttons and input boxes
|
||||
async def serve(websocket: websockets.asyncio.server.ServerConnection):
|
||||
global ws
|
||||
ws = websocket
|
||||
async for message in websocket:
|
||||
with contextlib.suppress(websockets.exceptions.ConnectionClosedOK):
|
||||
print('Received: ', str(message))
|
||||
|
||||
parsed = json.loads(message)
|
||||
message_type = parsed['type']
|
||||
if message_type == 'at_command':
|
||||
if hf_protocol is not None:
|
||||
response = str(
|
||||
await hf_protocol.execute_command(
|
||||
parsed['command'],
|
||||
response_type=hfp.AtResponseType.MULTIPLE,
|
||||
)
|
||||
)
|
||||
await websocket.send(response)
|
||||
elif message_type == 'query_call':
|
||||
if hf_protocol:
|
||||
response = str(await hf_protocol.query_current_calls())
|
||||
await websocket.send(response)
|
||||
|
||||
await websockets.asyncio.server.serve(serve, 'localhost', 8989)
|
||||
|
||||
await asyncio.get_running_loop().create_future() # run forever
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
async def main() -> None:
|
||||
if len(sys.argv) < 3:
|
||||
print('Usage: run_classic_hfp.py <device-config> <transport-spec>')
|
||||
print('example: run_classic_hfp.py classic2.json usb:04b4:f901')
|
||||
print(
|
||||
'Usage: run_hfp_handsfree.py <device-config> <transport-spec> '
|
||||
'[codec] [input] [output]'
|
||||
)
|
||||
print('example: run_hfp_handsfree.py classic2.json usb:0')
|
||||
return
|
||||
|
||||
print('<<< connecting to HCI...')
|
||||
async with await open_transport(sys.argv[2]) as hci_transport:
|
||||
print('<<< connected')
|
||||
device_config = sys.argv[1]
|
||||
transport_spec = sys.argv[2]
|
||||
|
||||
# Hands-Free profile configuration.
|
||||
# TODO: load configuration from file.
|
||||
configuration = hfp.HfConfiguration(
|
||||
supported_hf_features=[
|
||||
hfp.HfFeature.THREE_WAY_CALLING,
|
||||
hfp.HfFeature.REMOTE_VOLUME_CONTROL,
|
||||
hfp.HfFeature.ENHANCED_CALL_STATUS,
|
||||
hfp.HfFeature.ENHANCED_CALL_CONTROL,
|
||||
hfp.HfFeature.CODEC_NEGOTIATION,
|
||||
hfp.HfFeature.HF_INDICATORS,
|
||||
hfp.HfFeature.ESCO_S4_SETTINGS_SUPPORTED,
|
||||
],
|
||||
supported_hf_indicators=[
|
||||
hfp.HfIndicator.BATTERY_LEVEL,
|
||||
],
|
||||
supported_audio_codecs=[
|
||||
hfp.AudioCodec.CVSD,
|
||||
hfp.AudioCodec.MSBC,
|
||||
],
|
||||
)
|
||||
codec: str | None = None
|
||||
if len(sys.argv) >= 4:
|
||||
codec = sys.argv[3]
|
||||
|
||||
# Create a device
|
||||
device = Device.from_config_file_with_hci(
|
||||
sys.argv[1], hci_transport.source, hci_transport.sink
|
||||
)
|
||||
device.classic_enabled = True
|
||||
input_file_name: str | None = None
|
||||
if len(sys.argv) >= 5:
|
||||
input_file_name = sys.argv[4]
|
||||
|
||||
# Create and register a server
|
||||
rfcomm_server = rfcomm.Server(device)
|
||||
output_file_name: str | None = None
|
||||
if len(sys.argv) >= 6:
|
||||
output_file_name = sys.argv[5]
|
||||
|
||||
# Listen for incoming DLC connections
|
||||
channel_number = rfcomm_server.listen(lambda dlc: on_dlc(dlc, configuration))
|
||||
print(f'### Listening for connection on channel {channel_number}')
|
||||
global input_wav, output_wav
|
||||
input_cm: contextlib.AbstractContextManager[wave.Wave_read | None] = (
|
||||
wave.open(input_file_name, "rb")
|
||||
if input_file_name
|
||||
else contextlib.nullcontext(None)
|
||||
)
|
||||
output_cm: contextlib.AbstractContextManager[wave.Wave_write | None] = (
|
||||
wave.open(output_file_name, "wb")
|
||||
if output_file_name
|
||||
else contextlib.nullcontext(None)
|
||||
)
|
||||
with input_cm as input_wav, output_cm as output_wav:
|
||||
if input_wav and input_wav.getnchannels() != 1:
|
||||
print("Mono input required")
|
||||
return
|
||||
if input_wav and input_wav.getsampwidth() != 2:
|
||||
print("16-bit input required")
|
||||
return
|
||||
|
||||
# Advertise the HFP RFComm channel in the SDP
|
||||
device.sdp_service_records = {
|
||||
0x00010001: hfp.make_hf_sdp_records(
|
||||
0x00010001, channel_number, configuration
|
||||
async with await open_transport(transport_spec) as transport:
|
||||
device = Device.from_config_file_with_hci(
|
||||
device_config, transport.source, transport.sink
|
||||
)
|
||||
}
|
||||
|
||||
# Let's go!
|
||||
await device.power_on()
|
||||
|
||||
# Start being discoverable and connectable
|
||||
await device.set_discoverable(True)
|
||||
await device.set_connectable(True)
|
||||
|
||||
# Start the UI websocket server to offer a few buttons and input boxes
|
||||
async def serve(websocket: websockets.asyncio.server.ServerConnection):
|
||||
global ws
|
||||
ws = websocket
|
||||
async for message in websocket:
|
||||
with contextlib.suppress(websockets.exceptions.ConnectionClosedOK):
|
||||
print('Received: ', str(message))
|
||||
|
||||
parsed = json.loads(message)
|
||||
message_type = parsed['type']
|
||||
if message_type == 'at_command':
|
||||
if hf_protocol is not None:
|
||||
response = str(
|
||||
await hf_protocol.execute_command(
|
||||
parsed['command'],
|
||||
response_type=hfp.AtResponseType.MULTIPLE,
|
||||
)
|
||||
)
|
||||
await websocket.send(response)
|
||||
elif message_type == 'query_call':
|
||||
if hf_protocol:
|
||||
response = str(await hf_protocol.query_current_calls())
|
||||
await websocket.send(response)
|
||||
|
||||
await websockets.asyncio.server.serve(serve, 'localhost', 8989)
|
||||
|
||||
await hci_transport.source.terminated
|
||||
device.classic_enabled = True
|
||||
await run(device, codec)
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
+39
-25
@@ -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(".")'
|
||||
|
||||
@@ -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")
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
@@ -170,7 +170,9 @@ def format_code(ctx, check=False, diff=False):
|
||||
@task
|
||||
def check_types(ctx):
|
||||
checklist = ["apps", "bumble", "examples", "tests", "tasks.py"]
|
||||
print(">>> Running the type checker...")
|
||||
try:
|
||||
print("+++ Checking with mypy...")
|
||||
ctx.run(f"mypy {' '.join(checklist)}")
|
||||
except UnexpectedExit as exc:
|
||||
print("Please check your code against the mypy messages.")
|
||||
|
||||
+3
-3
@@ -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
@@ -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')
|
||||
|
||||
+42
-3
@@ -17,6 +17,7 @@
|
||||
# -----------------------------------------------------------------------------
|
||||
import asyncio
|
||||
import functools
|
||||
import inspect
|
||||
import logging
|
||||
import os
|
||||
from unittest import mock
|
||||
@@ -278,7 +279,7 @@ async def test_legacy_advertising():
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
@pytest.mark.parametrize(
|
||||
'auto_restart,',
|
||||
'auto_restart',
|
||||
(True, False),
|
||||
)
|
||||
@pytest.mark.asyncio
|
||||
@@ -357,7 +358,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 +396,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 +596,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():
|
||||
@@ -674,6 +694,25 @@ async def test_power_on_default_static_address_should_not_be_any():
|
||||
assert devices[0].static_address != Address.ANY_RANDOM
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
def test_cs_channel_map_excludes_forbidden_channels():
|
||||
forbidden = {0, 1, 23, 24, 25, 76, 77, 78, 79}
|
||||
default_map = (
|
||||
inspect.signature(Device.create_cs_config).parameters['channel_map'].default
|
||||
)
|
||||
|
||||
enabled = {
|
||||
byte_idx * 8 + bit
|
||||
for byte_idx, byte in enumerate(default_map)
|
||||
for bit in range(8)
|
||||
if byte & (1 << bit)
|
||||
}
|
||||
|
||||
assert enabled.isdisjoint(
|
||||
forbidden
|
||||
), f"Default channel_map enables forbidden CS channels: {enabled & forbidden}"
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
def test_gatt_services_with_gas_and_gatt():
|
||||
device = Device(host=Host(None, None))
|
||||
|
||||
+3
-3
@@ -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
@@ -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,
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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()
|
||||
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user