mirror of
https://github.com/google/bumble.git
synced 2026-05-10 04:18:03 +00:00
add basic support for SCO packets over USB
This commit is contained in:
@@ -45,8 +45,10 @@ from bumble.hci import (
|
|||||||
HCI_Read_Local_Supported_Codecs_Command,
|
HCI_Read_Local_Supported_Codecs_Command,
|
||||||
HCI_Read_Local_Supported_Codecs_V2_Command,
|
HCI_Read_Local_Supported_Codecs_V2_Command,
|
||||||
HCI_Read_Local_Version_Information_Command,
|
HCI_Read_Local_Version_Information_Command,
|
||||||
|
HCI_Read_Voice_Setting_Command,
|
||||||
LeFeature,
|
LeFeature,
|
||||||
SpecificationVersion,
|
SpecificationVersion,
|
||||||
|
VoiceSetting,
|
||||||
map_null_terminated_utf8_string,
|
map_null_terminated_utf8_string,
|
||||||
)
|
)
|
||||||
from bumble.host import Host
|
from bumble.host import Host
|
||||||
@@ -214,6 +216,16 @@ async def get_codecs_info(host: Host) -> None:
|
|||||||
if not response2.vendor_specific_codec_ids:
|
if not response2.vendor_specific_codec_ids:
|
||||||
print(' No Vendor-specific codecs')
|
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(
|
async def async_main(
|
||||||
|
|||||||
@@ -16,6 +16,8 @@
|
|||||||
# Imports
|
# Imports
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
import asyncio
|
import asyncio
|
||||||
|
import statistics
|
||||||
|
import struct
|
||||||
import time
|
import time
|
||||||
|
|
||||||
import click
|
import click
|
||||||
@@ -25,7 +27,9 @@ from bumble.colors import color
|
|||||||
from bumble.hci import (
|
from bumble.hci import (
|
||||||
HCI_READ_LOOPBACK_MODE_COMMAND,
|
HCI_READ_LOOPBACK_MODE_COMMAND,
|
||||||
HCI_WRITE_LOOPBACK_MODE_COMMAND,
|
HCI_WRITE_LOOPBACK_MODE_COMMAND,
|
||||||
|
Address,
|
||||||
HCI_Read_Loopback_Mode_Command,
|
HCI_Read_Loopback_Mode_Command,
|
||||||
|
HCI_SynchronousDataPacket,
|
||||||
HCI_Write_Loopback_Mode_Command,
|
HCI_Write_Loopback_Mode_Command,
|
||||||
LoopbackMode,
|
LoopbackMode,
|
||||||
)
|
)
|
||||||
@@ -36,34 +40,59 @@ from bumble.transport import open_transport
|
|||||||
class Loopback:
|
class Loopback:
|
||||||
"""Send and receive ACL data packets in local loopback mode"""
|
"""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.transport = transport
|
||||||
self.packet_size = packet_size
|
self.packet_size = packet_size
|
||||||
self.packet_count = packet_count
|
self.packet_count = packet_count
|
||||||
self.connection_handle: int | None = None
|
self.connection_handle: int | None = None
|
||||||
|
self.connection_type = connection_type
|
||||||
self.connection_event = asyncio.Event()
|
self.connection_event = asyncio.Event()
|
||||||
|
self.mode = mode
|
||||||
|
self.interval = interval
|
||||||
self.done = asyncio.Event()
|
self.done = asyncio.Event()
|
||||||
self.expected_cid = 0
|
self.expected_counter = 0
|
||||||
self.bytes_received = 0
|
self.bytes_received = 0
|
||||||
self.start_timestamp = 0.0
|
self.start_timestamp = 0.0
|
||||||
self.last_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):
|
def on_connection(self, connection_handle: int, *args):
|
||||||
"""Retrieve connection handle from new connection event"""
|
"""Retrieve connection handle from new connection event"""
|
||||||
if not self.connection_event.is_set():
|
if not self.connection_event.is_set():
|
||||||
# save first connection handle for ACL
|
# The first connection handle is of type ACL,
|
||||||
# subsequent connections are SCO
|
# 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_handle = connection_handle
|
||||||
self.connection_event.set()
|
self.connection_event.set()
|
||||||
|
|
||||||
|
def on_sco_connection(
|
||||||
|
self, address: Address, connection_handle: int, link_type: int
|
||||||
|
):
|
||||||
|
self.on_connection(connection_handle)
|
||||||
|
|
||||||
def on_l2cap_pdu(self, connection_handle: int, cid: int, pdu: bytes):
|
def on_l2cap_pdu(self, connection_handle: int, cid: int, pdu: bytes):
|
||||||
"""Calculate packet receive speed"""
|
"""Calculate packet receive speed"""
|
||||||
now = time.time()
|
now = time.time()
|
||||||
print(f'<<< Received packet {cid}: {len(pdu)} bytes')
|
(counter,) = struct.unpack_from("H", pdu, 0)
|
||||||
|
rtt = now - self.send_timestamps[counter]
|
||||||
|
self.rtts.append(rtt)
|
||||||
|
print(f'<<< Received packet {counter}: {len(pdu)} bytes, RTT={rtt:.4f}')
|
||||||
assert connection_handle == self.connection_handle
|
assert connection_handle == self.connection_handle
|
||||||
assert cid == self.expected_cid
|
assert counter == self.expected_counter
|
||||||
self.expected_cid += 1
|
self.expected_counter += 1
|
||||||
if cid == 0:
|
if counter == 0:
|
||||||
self.start_timestamp = now
|
self.start_timestamp = now
|
||||||
else:
|
else:
|
||||||
elapsed_since_start = now - self.start_timestamp
|
elapsed_since_start = now - self.start_timestamp
|
||||||
@@ -71,20 +100,52 @@ class Loopback:
|
|||||||
self.bytes_received += len(pdu)
|
self.bytes_received += len(pdu)
|
||||||
instant_rx_speed = len(pdu) / elapsed_since_last
|
instant_rx_speed = len(pdu) / elapsed_since_last
|
||||||
average_rx_speed = self.bytes_received / elapsed_since_start
|
average_rx_speed = self.bytes_received / elapsed_since_start
|
||||||
print(
|
if self.mode == 'throughput':
|
||||||
color(
|
print(
|
||||||
f'@@@ RX speed: instant={instant_rx_speed:.4f},'
|
color(
|
||||||
f' average={average_rx_speed:.4f}',
|
f'@@@ RX speed: instant={instant_rx_speed:.4f},'
|
||||||
'cyan',
|
f' average={average_rx_speed:.4f},',
|
||||||
|
'cyan',
|
||||||
|
)
|
||||||
)
|
)
|
||||||
)
|
|
||||||
|
|
||||||
self.last_timestamp = now
|
self.last_timestamp = now
|
||||||
|
|
||||||
if self.expected_cid == self.packet_count:
|
if self.expected_counter == self.packet_count:
|
||||||
print(color('@@@ Received last packet', 'green'))
|
print(color('@@@ Received last packet', 'green'))
|
||||||
self.done.set()
|
self.done.set()
|
||||||
|
|
||||||
|
def on_sco_packet(self, connection_handle: int, packet) -> None:
|
||||||
|
print("---", 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:
|
async def run(self) -> None:
|
||||||
"""Run a loopback throughput test"""
|
"""Run a loopback throughput test"""
|
||||||
print(color('>>> Connecting to HCI...', 'green'))
|
print(color('>>> Connecting to HCI...', 'green'))
|
||||||
@@ -126,8 +187,11 @@ class Loopback:
|
|||||||
return
|
return
|
||||||
|
|
||||||
# set event callbacks
|
# 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('l2cap_pdu', self.on_l2cap_pdu)
|
||||||
|
host.on('sco_packet', self.on_sco_packet)
|
||||||
|
|
||||||
loopback_mode = LoopbackMode.LOCAL
|
loopback_mode = LoopbackMode.LOCAL
|
||||||
|
|
||||||
@@ -148,32 +212,37 @@ class Loopback:
|
|||||||
|
|
||||||
print(color('=== Start sending', 'magenta'))
|
print(color('=== Start sending', 'magenta'))
|
||||||
start_time = time.time()
|
start_time = time.time()
|
||||||
bytes_sent = 0
|
if self.connection_type == "acl":
|
||||||
for cid in range(0, self.packet_count):
|
sender = self.send_acl_packet
|
||||||
# using the cid as an incremental index
|
elif self.connection_type == "sco":
|
||||||
host.send_l2cap_pdu(
|
sender = self.send_sco_packet
|
||||||
self.connection_handle, cid, bytes(self.packet_size)
|
else:
|
||||||
)
|
raise ValueError(f'Unknown connection type: {self.connection_type}')
|
||||||
print(
|
await self.send_loop(host, sender)
|
||||||
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
|
|
||||||
|
|
||||||
await self.done.wait()
|
await self.done.wait()
|
||||||
print(color('=== Done!', 'magenta'))
|
print(color('=== Done!', 'magenta'))
|
||||||
|
|
||||||
|
bytes_sent = self.packet_size * self.packet_count
|
||||||
elapsed = time.time() - start_time
|
elapsed = time.time() - start_time
|
||||||
average_tx_speed = bytes_sent / elapsed
|
average_tx_speed = bytes_sent / elapsed
|
||||||
print(
|
if self.mode == 'throughput':
|
||||||
color(
|
print(
|
||||||
f'@@@ TX speed: average={average_tx_speed:.4f} ({bytes_sent} bytes'
|
color(
|
||||||
f' in {elapsed:.2f} seconds)',
|
f'@@@ TX speed: average={average_tx_speed:.4f} '
|
||||||
'green',
|
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 +263,43 @@ class Loopback:
|
|||||||
default=10,
|
default=10,
|
||||||
help='Packet count',
|
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')
|
@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()
|
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())
|
||||||
|
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -111,9 +111,14 @@ def show_device_details(device):
|
|||||||
if (endpoint.getAddress() & USB_ENDPOINT_IN == 0)
|
if (endpoint.getAddress() & USB_ENDPOINT_IN == 0)
|
||||||
else 'IN'
|
else 'IN'
|
||||||
)
|
)
|
||||||
|
endpoint_details = (
|
||||||
|
f', Max Packet Size = {endpoint.getMaxPacketSize()}'
|
||||||
|
if endpoint_type == 'ISOCHRONOUS'
|
||||||
|
else ''
|
||||||
|
)
|
||||||
print(
|
print(
|
||||||
f' Endpoint 0x{endpoint.getAddress():02X}: '
|
f' Endpoint 0x{endpoint.getAddress():02X}: '
|
||||||
f'{endpoint_type} {endpoint_direction}'
|
f'{endpoint_type} {endpoint_direction}{endpoint_details}'
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1423,6 +1423,9 @@ class ScoLink(utils.CompositeEventEmitter):
|
|||||||
acl_connection: Connection
|
acl_connection: Connection
|
||||||
handle: int
|
handle: int
|
||||||
link_type: int
|
link_type: int
|
||||||
|
rx_packet_length: int
|
||||||
|
tx_packet_length: int
|
||||||
|
air_mode: hci.CodecID
|
||||||
sink: Callable[[hci.HCI_SynchronousDataPacket], Any] | None = None
|
sink: Callable[[hci.HCI_SynchronousDataPacket], Any] | None = None
|
||||||
|
|
||||||
EVENT_DISCONNECTION: ClassVar[str] = "disconnection"
|
EVENT_DISCONNECTION: ClassVar[str] = "disconnection"
|
||||||
@@ -5968,7 +5971,7 @@ class Device(utils.CompositeEventEmitter):
|
|||||||
def on_connection_request(
|
def on_connection_request(
|
||||||
self, bd_addr: hci.Address, class_of_device: int, link_type: int
|
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.
|
# Handle SCO request.
|
||||||
if link_type in (
|
if link_type in (
|
||||||
@@ -5978,6 +5981,7 @@ class Device(utils.CompositeEventEmitter):
|
|||||||
if connection := self.find_connection_by_bd_addr(
|
if connection := self.find_connection_by_bd_addr(
|
||||||
bd_addr, transport=PhysicalTransport.BR_EDR
|
bd_addr, transport=PhysicalTransport.BR_EDR
|
||||||
):
|
):
|
||||||
|
connection.emit(self.EVENT_SCO_REQUEST, link_type)
|
||||||
self.emit(self.EVENT_SCO_REQUEST, connection, link_type)
|
self.emit(self.EVENT_SCO_REQUEST, connection, link_type)
|
||||||
else:
|
else:
|
||||||
logger.error(f'SCO request from a non-connected device {bd_addr}')
|
logger.error(f'SCO request from a non-connected device {bd_addr}')
|
||||||
@@ -6337,8 +6341,7 @@ class Device(utils.CompositeEventEmitter):
|
|||||||
logger.warning('peer name is not valid UTF-8')
|
logger.warning('peer name is not valid UTF-8')
|
||||||
if connection:
|
if connection:
|
||||||
connection.emit(connection.EVENT_REMOTE_NAME_FAILURE, error)
|
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]
|
# [Classic only]
|
||||||
@host_event_handler
|
@host_event_handler
|
||||||
@@ -6355,7 +6358,13 @@ class Device(utils.CompositeEventEmitter):
|
|||||||
@with_connection_from_address
|
@with_connection_from_address
|
||||||
@utils.experimental('Only for testing.')
|
@utils.experimental('Only for testing.')
|
||||||
def on_sco_connection(
|
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:
|
) -> None:
|
||||||
logger.debug(
|
logger.debug(
|
||||||
f'*** SCO connected: {acl_connection.peer_address}, '
|
f'*** SCO connected: {acl_connection.peer_address}, '
|
||||||
@@ -6367,7 +6376,11 @@ class Device(utils.CompositeEventEmitter):
|
|||||||
acl_connection=acl_connection,
|
acl_connection=acl_connection,
|
||||||
handle=sco_handle,
|
handle=sco_handle,
|
||||||
link_type=link_type,
|
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)
|
self.emit(self.EVENT_SCO_CONNECTION, sco_link)
|
||||||
|
|
||||||
# [Classic only]
|
# [Classic only]
|
||||||
@@ -6378,7 +6391,8 @@ class Device(utils.CompositeEventEmitter):
|
|||||||
self, acl_connection: Connection, status: int
|
self, acl_connection: Connection, status: int
|
||||||
) -> None:
|
) -> None:
|
||||||
logger.debug(f'*** SCO connection failure: {acl_connection.peer_address}***')
|
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]
|
# [Classic only]
|
||||||
@host_event_handler
|
@host_event_handler
|
||||||
@@ -6841,15 +6855,18 @@ class Device(utils.CompositeEventEmitter):
|
|||||||
@with_connection_from_address
|
@with_connection_from_address
|
||||||
def on_classic_pairing(self, connection: Connection) -> None:
|
def on_classic_pairing(self, connection: Connection) -> None:
|
||||||
connection.emit(connection.EVENT_CLASSIC_PAIRING)
|
connection.emit(connection.EVENT_CLASSIC_PAIRING)
|
||||||
|
self.emit(connection.EVENT_CLASSIC_PAIRING, connection)
|
||||||
|
|
||||||
# [Classic only]
|
# [Classic only]
|
||||||
@host_event_handler
|
@host_event_handler
|
||||||
@with_connection_from_address
|
@with_connection_from_address
|
||||||
def on_classic_pairing_failure(self, connection: Connection, status: int) -> None:
|
def on_classic_pairing_failure(self, connection: Connection, status: int) -> None:
|
||||||
connection.emit(connection.EVENT_CLASSIC_PAIRING_FAILURE, status)
|
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:
|
def on_pairing_start(self, connection: Connection) -> None:
|
||||||
connection.emit(connection.EVENT_PAIRING_START)
|
connection.emit(connection.EVENT_PAIRING_START)
|
||||||
|
self.emit(connection.EVENT_PAIRING_START, connection)
|
||||||
|
|
||||||
def on_pairing(
|
def on_pairing(
|
||||||
self,
|
self,
|
||||||
|
|||||||
121
bumble/hci.py
121
bumble/hci.py
@@ -1769,6 +1769,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:
|
class HCI_Constant:
|
||||||
@staticmethod
|
@staticmethod
|
||||||
@@ -2907,6 +2962,23 @@ class HCI_Read_Clock_Offset_Command(HCI_AsyncCommand):
|
|||||||
connection_handle: int = field(metadata=metadata(2))
|
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
|
@HCI_Command.command
|
||||||
@dataclasses.dataclass
|
@dataclasses.dataclass
|
||||||
@@ -3965,6 +4037,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
|
@dataclasses.dataclass
|
||||||
class HCI_Read_Local_Version_Information_ReturnParameters(HCI_StatusReturnParameters):
|
class HCI_Read_Local_Version_Information_ReturnParameters(HCI_StatusReturnParameters):
|
||||||
@@ -7355,7 +7444,7 @@ class HCI_Connection_Complete_Event(HCI_Event):
|
|||||||
status: int = field(metadata=metadata(STATUS_SPEC))
|
status: int = field(metadata=metadata(STATUS_SPEC))
|
||||||
connection_handle: int = field(metadata=metadata(2))
|
connection_handle: int = field(metadata=metadata(2))
|
||||||
bd_addr: Address = field(metadata=metadata(Address.parse_address))
|
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))
|
encryption_enabled: int = field(metadata=metadata(1))
|
||||||
|
|
||||||
|
|
||||||
@@ -7751,12 +7840,6 @@ class HCI_Synchronous_Connection_Complete_Event(HCI_Event):
|
|||||||
SCO = 0x00
|
SCO = 0x00
|
||||||
ESCO = 0x02
|
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))
|
status: int = field(metadata=metadata(STATUS_SPEC))
|
||||||
connection_handle: int = field(metadata=metadata(2))
|
connection_handle: int = field(metadata=metadata(2))
|
||||||
bd_addr: Address = field(metadata=metadata(Address.parse_address))
|
bd_addr: Address = field(metadata=metadata(Address.parse_address))
|
||||||
@@ -7765,7 +7848,7 @@ class HCI_Synchronous_Connection_Complete_Event(HCI_Event):
|
|||||||
retransmission_window: int = field(metadata=metadata(1))
|
retransmission_window: int = field(metadata=metadata(1))
|
||||||
rx_packet_length: int = field(metadata=metadata(2))
|
rx_packet_length: int = field(metadata=metadata(2))
|
||||||
tx_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))
|
||||||
|
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
@@ -7997,7 +8080,9 @@ class HCI_AclDataPacket(HCI_Packet):
|
|||||||
bc_flag = (h >> 14) & 3
|
bc_flag = (h >> 14) & 3
|
||||||
data = packet[5:]
|
data = packet[5:]
|
||||||
if len(data) != data_total_length:
|
if len(data) != data_total_length:
|
||||||
raise InvalidPacketError('invalid packet length')
|
raise InvalidPacketError(
|
||||||
|
f'invalid packet length {len(data)} != {data_total_length}'
|
||||||
|
)
|
||||||
return cls(
|
return cls(
|
||||||
connection_handle=connection_handle,
|
connection_handle=connection_handle,
|
||||||
pb_flag=pb_flag,
|
pb_flag=pb_flag,
|
||||||
@@ -8030,10 +8115,16 @@ class HCI_SynchronousDataPacket(HCI_Packet):
|
|||||||
See Bluetooth spec @ 5.4.3 HCI SCO Data Packets
|
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
|
hci_packet_type = HCI_SYNCHRONOUS_DATA_PACKET
|
||||||
|
|
||||||
connection_handle: int
|
connection_handle: int
|
||||||
packet_status: int
|
packet_status: Status
|
||||||
data_total_length: int
|
data_total_length: int
|
||||||
data: bytes
|
data: bytes
|
||||||
|
|
||||||
@@ -8042,7 +8133,7 @@ class HCI_SynchronousDataPacket(HCI_Packet):
|
|||||||
# Read the header
|
# Read the header
|
||||||
h, data_total_length = struct.unpack_from('<HB', packet, 1)
|
h, data_total_length = struct.unpack_from('<HB', packet, 1)
|
||||||
connection_handle = h & 0xFFF
|
connection_handle = h & 0xFFF
|
||||||
packet_status = (h >> 12) & 0b11
|
packet_status = cls.Status((h >> 12) & 0b11)
|
||||||
data = packet[4:]
|
data = packet[4:]
|
||||||
if len(data) != data_total_length:
|
if len(data) != data_total_length:
|
||||||
raise InvalidPacketError(
|
raise InvalidPacketError(
|
||||||
@@ -8066,7 +8157,7 @@ class HCI_SynchronousDataPacket(HCI_Packet):
|
|||||||
return (
|
return (
|
||||||
f'{color("SCO", "blue")}: '
|
f'{color("SCO", "blue")}: '
|
||||||
f'handle=0x{self.connection_handle:04x}, '
|
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_total_length={self.data_total_length}, '
|
||||||
f'data={self.data.hex()}'
|
f'data={self.data.hex()}'
|
||||||
)
|
)
|
||||||
@@ -8094,8 +8185,8 @@ class HCI_IsoDataPacket(HCI_Packet):
|
|||||||
def __post_init__(self) -> None:
|
def __post_init__(self) -> None:
|
||||||
self.ts_flag = self.time_stamp is not None
|
self.ts_flag = self.time_stamp is not None
|
||||||
|
|
||||||
@staticmethod
|
@classmethod
|
||||||
def from_bytes(packet: bytes) -> HCI_IsoDataPacket:
|
def from_bytes(cls, packet: bytes) -> HCI_IsoDataPacket:
|
||||||
time_stamp: int | None = None
|
time_stamp: int | None = None
|
||||||
packet_sequence_number: int | None = None
|
packet_sequence_number: int | None = None
|
||||||
iso_sdu_length: int | None = None
|
iso_sdu_length: int | None = None
|
||||||
@@ -8124,7 +8215,7 @@ class HCI_IsoDataPacket(HCI_Packet):
|
|||||||
pos += 4
|
pos += 4
|
||||||
|
|
||||||
iso_sdu_fragment = packet[pos:]
|
iso_sdu_fragment = packet[pos:]
|
||||||
return HCI_IsoDataPacket(
|
return cls(
|
||||||
connection_handle=connection_handle,
|
connection_handle=connection_handle,
|
||||||
pb_flag=pb_flag,
|
pb_flag=pb_flag,
|
||||||
ts_flag=ts_flag,
|
ts_flag=ts_flag,
|
||||||
|
|||||||
@@ -166,7 +166,7 @@ class AgFeature(enum.IntFlag):
|
|||||||
VOICE_RECOGNITION_TEXT = 0x2000
|
VOICE_RECOGNITION_TEXT = 0x2000
|
||||||
|
|
||||||
|
|
||||||
class AudioCodec(enum.IntEnum):
|
class AudioCodec(utils.OpenIntEnum):
|
||||||
"""
|
"""
|
||||||
Audio Codec IDs (normative).
|
Audio Codec IDs (normative).
|
||||||
|
|
||||||
@@ -178,7 +178,7 @@ class AudioCodec(enum.IntEnum):
|
|||||||
LC3_SWB = 0x03 # Support for LC3-SWB audio codec
|
LC3_SWB = 0x03 # Support for LC3-SWB audio codec
|
||||||
|
|
||||||
|
|
||||||
class HfIndicator(enum.IntEnum):
|
class HfIndicator(utils.OpenIntEnum):
|
||||||
"""
|
"""
|
||||||
HF Indicators (normative).
|
HF Indicators (normative).
|
||||||
|
|
||||||
@@ -207,7 +207,7 @@ class CallHoldOperation(enum.Enum):
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
class ResponseHoldStatus(enum.IntEnum):
|
class ResponseHoldStatus(utils.OpenIntEnum):
|
||||||
"""
|
"""
|
||||||
Response Hold status (normative).
|
Response Hold status (normative).
|
||||||
|
|
||||||
@@ -235,7 +235,7 @@ class AgIndicator(enum.Enum):
|
|||||||
BATTERY_CHARGE = 'battchg'
|
BATTERY_CHARGE = 'battchg'
|
||||||
|
|
||||||
|
|
||||||
class CallSetupAgIndicator(enum.IntEnum):
|
class CallSetupAgIndicator(utils.OpenIntEnum):
|
||||||
"""
|
"""
|
||||||
Values for the Call Setup AG indicator (normative).
|
Values for the Call Setup AG indicator (normative).
|
||||||
|
|
||||||
@@ -248,7 +248,7 @@ class CallSetupAgIndicator(enum.IntEnum):
|
|||||||
REMOTE_ALERTED = 3 # Remote party alerted in an outgoing call
|
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).
|
Values for the Call Held AG indicator (normative).
|
||||||
|
|
||||||
@@ -262,7 +262,7 @@ class CallHeldAgIndicator(enum.IntEnum):
|
|||||||
CALL_ON_HOLD_NO_ACTIVE_CALL = 2 # Call on hold, no active call
|
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).
|
Call Info direction (normative).
|
||||||
|
|
||||||
@@ -273,7 +273,7 @@ class CallInfoDirection(enum.IntEnum):
|
|||||||
MOBILE_TERMINATED_CALL = 1
|
MOBILE_TERMINATED_CALL = 1
|
||||||
|
|
||||||
|
|
||||||
class CallInfoStatus(enum.IntEnum):
|
class CallInfoStatus(utils.OpenIntEnum):
|
||||||
"""
|
"""
|
||||||
Call Info status (normative).
|
Call Info status (normative).
|
||||||
|
|
||||||
@@ -288,7 +288,7 @@ class CallInfoStatus(enum.IntEnum):
|
|||||||
WAITING = 5
|
WAITING = 5
|
||||||
|
|
||||||
|
|
||||||
class CallInfoMode(enum.IntEnum):
|
class CallInfoMode(utils.OpenIntEnum):
|
||||||
"""
|
"""
|
||||||
Call Info mode (normative).
|
Call Info mode (normative).
|
||||||
|
|
||||||
@@ -301,7 +301,7 @@ class CallInfoMode(enum.IntEnum):
|
|||||||
UNKNOWN = 9
|
UNKNOWN = 9
|
||||||
|
|
||||||
|
|
||||||
class CallInfoMultiParty(enum.IntEnum):
|
class CallInfoMultiParty(utils.OpenIntEnum):
|
||||||
"""
|
"""
|
||||||
Call Info Multi-Party state (normative).
|
Call Info Multi-Party state (normative).
|
||||||
|
|
||||||
@@ -388,7 +388,7 @@ class CallLineIdentification:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
class VoiceRecognitionState(enum.IntEnum):
|
class VoiceRecognitionState(utils.OpenIntEnum):
|
||||||
"""
|
"""
|
||||||
vrec values provided in AT+BVRA command.
|
vrec values provided in AT+BVRA command.
|
||||||
|
|
||||||
@@ -401,7 +401,7 @@ class VoiceRecognitionState(enum.IntEnum):
|
|||||||
ENHANCED_READY = 2
|
ENHANCED_READY = 2
|
||||||
|
|
||||||
|
|
||||||
class CmeError(enum.IntEnum):
|
class CmeError(utils.OpenIntEnum):
|
||||||
"""
|
"""
|
||||||
CME ERROR codes (partial listed).
|
CME ERROR codes (partial listed).
|
||||||
|
|
||||||
@@ -1624,7 +1624,7 @@ class AgProtocol(utils.EventEmitter):
|
|||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
class ProfileVersion(enum.IntEnum):
|
class ProfileVersion(utils.OpenIntEnum):
|
||||||
"""
|
"""
|
||||||
Profile version (normative).
|
Profile version (normative).
|
||||||
|
|
||||||
@@ -2076,6 +2076,7 @@ _ESCO_PARAMETERS_MSBC_T1 = EscoParameters(
|
|||||||
max_latency=0x0008,
|
max_latency=0x0008,
|
||||||
packet_type=(
|
packet_type=(
|
||||||
HCI_Enhanced_Setup_Synchronous_Connection_Command.PacketType.EV3
|
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_3_EV3
|
||||||
| HCI_Enhanced_Setup_Synchronous_Connection_Command.PacketType.NO_2_EV5
|
| HCI_Enhanced_Setup_Synchronous_Connection_Command.PacketType.NO_2_EV5
|
||||||
| HCI_Enhanced_Setup_Synchronous_Connection_Command.PacketType.NO_3_EV5
|
| HCI_Enhanced_Setup_Synchronous_Connection_Command.PacketType.NO_3_EV5
|
||||||
@@ -2091,7 +2092,6 @@ _ESCO_PARAMETERS_MSBC_T2 = EscoParameters(
|
|||||||
max_latency=0x000D,
|
max_latency=0x000D,
|
||||||
packet_type=(
|
packet_type=(
|
||||||
HCI_Enhanced_Setup_Synchronous_Connection_Command.PacketType.EV3
|
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_3_EV3
|
||||||
| HCI_Enhanced_Setup_Synchronous_Connection_Command.PacketType.NO_2_EV5
|
| HCI_Enhanced_Setup_Synchronous_Connection_Command.PacketType.NO_2_EV5
|
||||||
| HCI_Enhanced_Setup_Synchronous_Connection_Command.PacketType.NO_3_EV5
|
| HCI_Enhanced_Setup_Synchronous_Connection_Command.PacketType.NO_3_EV5
|
||||||
|
|||||||
@@ -686,6 +686,8 @@ class Host(utils.EventEmitter):
|
|||||||
self.pending_response, timeout=response_timeout
|
self.pending_response, timeout=response_timeout
|
||||||
)
|
)
|
||||||
return response
|
return response
|
||||||
|
except asyncio.TimeoutError:
|
||||||
|
raise
|
||||||
except Exception:
|
except Exception:
|
||||||
logger.exception(color("!!! Exception while sending command:", "red"))
|
logger.exception(color("!!! Exception while sending command:", "red"))
|
||||||
raise
|
raise
|
||||||
@@ -866,7 +868,7 @@ class Host(utils.EventEmitter):
|
|||||||
self.send_hci_packet(
|
self.send_hci_packet(
|
||||||
hci.HCI_SynchronousDataPacket(
|
hci.HCI_SynchronousDataPacket(
|
||||||
connection_handle=connection_handle,
|
connection_handle=connection_handle,
|
||||||
packet_status=0,
|
packet_status=hci.HCI_SynchronousDataPacket.Status.CORRECTLY_RECEIVED_DATA,
|
||||||
data_total_length=len(sdu),
|
data_total_length=len(sdu),
|
||||||
data=sdu,
|
data=sdu,
|
||||||
)
|
)
|
||||||
@@ -1177,11 +1179,28 @@ class Host(utils.EventEmitter):
|
|||||||
def on_hci_connection_complete_event(
|
def on_hci_connection_complete_event(
|
||||||
self, event: hci.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:
|
if event.status == hci.HCI_SUCCESS:
|
||||||
# Create/update the connection
|
# Create/update the connection
|
||||||
logger.debug(
|
logger.debug(
|
||||||
f'### BR/EDR CONNECTION: [0x{event.connection_handle:04X}] '
|
f'### BR/EDR ACL CONNECTION: [0x{event.connection_handle:04X}] '
|
||||||
f'{event.bd_addr}'
|
f'{event.bd_addr} '
|
||||||
|
f'{event.link_type.name}'
|
||||||
)
|
)
|
||||||
|
|
||||||
connection = self.connections.get(event.connection_handle)
|
connection = self.connections.get(event.connection_handle)
|
||||||
@@ -1581,6 +1600,9 @@ class Host(utils.EventEmitter):
|
|||||||
event.bd_addr,
|
event.bd_addr,
|
||||||
event.connection_handle,
|
event.connection_handle,
|
||||||
event.link_type,
|
event.link_type,
|
||||||
|
event.rx_packet_length,
|
||||||
|
event.tx_packet_length,
|
||||||
|
event.air_mode,
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
logger.debug(f'### SCO CONNECTION FAILED: {event.status}')
|
logger.debug(f'### SCO CONNECTION FAILED: {event.status}')
|
||||||
|
|||||||
@@ -110,7 +110,7 @@ RFCOMM_DEFAULT_L2CAP_MTU = 2048
|
|||||||
RFCOMM_DEFAULT_INITIAL_CREDITS = 7
|
RFCOMM_DEFAULT_INITIAL_CREDITS = 7
|
||||||
RFCOMM_DEFAULT_MAX_CREDITS = 32
|
RFCOMM_DEFAULT_MAX_CREDITS = 32
|
||||||
RFCOMM_DEFAULT_CREDIT_THRESHOLD = RFCOMM_DEFAULT_MAX_CREDITS // 2
|
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_START = 1
|
||||||
RFCOMM_DYNAMIC_CHANNEL_NUMBER_END = 30
|
RFCOMM_DYNAMIC_CHANNEL_NUMBER_END = 30
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -20,17 +20,110 @@ import contextlib
|
|||||||
import functools
|
import functools
|
||||||
import json
|
import json
|
||||||
import sys
|
import sys
|
||||||
|
import wave
|
||||||
|
|
||||||
import websockets.asyncio.server
|
import websockets.asyncio.server
|
||||||
|
|
||||||
import bumble.logging
|
import bumble.logging
|
||||||
from bumble import hci, hfp, rfcomm
|
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.hfp import HfProtocol
|
||||||
from bumble.transport import open_transport
|
from bumble.transport import open_transport
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
ws: websockets.asyncio.server.ServerConnection | None = None
|
ws: websockets.asyncio.server.ServerConnection | None = None
|
||||||
hf_protocol: HfProtocol | 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
|
||||||
|
):
|
||||||
|
if output_wav:
|
||||||
|
# Save the PCM audio to the output
|
||||||
|
output_wav.writeframes(packet.data)
|
||||||
|
else:
|
||||||
|
print('!!! discarding packet with status ', packet.packet_status.name)
|
||||||
|
|
||||||
|
if input_wav and hf_protocol:
|
||||||
|
# Send PCM audio from the input
|
||||||
|
frame_count = len(packet.data) // 2
|
||||||
|
while frame_count:
|
||||||
|
# NOTE: we use a fixed number of frames here, this should likely be adjusted
|
||||||
|
# based on the transport parameters (like the USB max packet size)
|
||||||
|
chunk_size = min(frame_count, 16)
|
||||||
|
if not (pcm_data := input_wav.readframes(chunk_size)):
|
||||||
|
return
|
||||||
|
frame_count -= chunk_size
|
||||||
|
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
|
||||||
|
|
||||||
|
connection.on('sco_connection', on_sco_connection)
|
||||||
|
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
@@ -40,134 +133,173 @@ def on_dlc(dlc: rfcomm.DLC, configuration: hfp.HfConfiguration):
|
|||||||
hf_protocol = HfProtocol(dlc, configuration)
|
hf_protocol = HfProtocol(dlc, configuration)
|
||||||
asyncio.create_task(hf_protocol.run())
|
asyncio.create_task(hf_protocol.run())
|
||||||
|
|
||||||
def on_sco_request(connection: Connection, link_type: int, protocol: HfProtocol):
|
connection = dlc.multiplexer.l2cap_channel.connection
|
||||||
if connection == protocol.dlc.multiplexer.l2cap_channel.connection:
|
handler = functools.partial(
|
||||||
if link_type == hci.HCI_Connection_Complete_Event.LinkType.SCO:
|
on_sco_request,
|
||||||
esco_parameters = hfp.ESCO_PARAMETERS[
|
connection=connection,
|
||||||
hfp.DefaultCodecParameters.SCO_CVSD_D1
|
protocol=hf_protocol,
|
||||||
]
|
)
|
||||||
elif protocol.active_codec == hfp.AudioCodec.MSBC:
|
connection.on('sco_request', handler)
|
||||||
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)
|
|
||||||
dlc.multiplexer.l2cap_channel.once(
|
dlc.multiplexer.l2cap_channel.once(
|
||||||
'close',
|
'close',
|
||||||
lambda: dlc.multiplexer.l2cap_channel.connection.device.remove_listener(
|
lambda: connection.remove_listener('sco_request', handler),
|
||||||
'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('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}')
|
||||||
|
global output_wav
|
||||||
|
if output_wav:
|
||||||
|
output_wav.setnchannels(1)
|
||||||
|
output_wav.setsampwidth(2)
|
||||||
|
match codec:
|
||||||
|
case hfp.AudioCodec.CVSD:
|
||||||
|
output_wav.setframerate(8000)
|
||||||
|
case hfp.AudioCodec.MSBC:
|
||||||
|
output_wav.setframerate(16000)
|
||||||
|
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
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:
|
async def main() -> None:
|
||||||
if len(sys.argv) < 3:
|
if len(sys.argv) < 3:
|
||||||
print('Usage: run_classic_hfp.py <device-config> <transport-spec>')
|
print(
|
||||||
print('example: run_classic_hfp.py classic2.json usb:04b4:f901')
|
'Usage: run_hfp_handsfree.py <device-config> <transport-spec> '
|
||||||
|
'[codec] [input] [output]'
|
||||||
|
)
|
||||||
|
print('example: run_hfp_handsfree.py classic2.json usb:0')
|
||||||
return
|
return
|
||||||
|
|
||||||
print('<<< connecting to HCI...')
|
device_config = sys.argv[1]
|
||||||
async with await open_transport(sys.argv[2]) as hci_transport:
|
transport_spec = sys.argv[2]
|
||||||
print('<<< connected')
|
|
||||||
|
|
||||||
# Hands-Free profile configuration.
|
codec: str | None = None
|
||||||
# TODO: load configuration from file.
|
if len(sys.argv) >= 4:
|
||||||
configuration = hfp.HfConfiguration(
|
codec = sys.argv[3]
|
||||||
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,
|
|
||||||
],
|
|
||||||
)
|
|
||||||
|
|
||||||
# Create a device
|
input_file_name: str | None = None
|
||||||
device = Device.from_config_file_with_hci(
|
if len(sys.argv) >= 5:
|
||||||
sys.argv[1], hci_transport.source, hci_transport.sink
|
input_file_name = sys.argv[4]
|
||||||
)
|
|
||||||
device.classic_enabled = True
|
|
||||||
|
|
||||||
# Create and register a server
|
output_file_name: str | None = None
|
||||||
rfcomm_server = rfcomm.Server(device)
|
if len(sys.argv) >= 6:
|
||||||
|
output_file_name = sys.argv[5]
|
||||||
|
|
||||||
# Listen for incoming DLC connections
|
global input_wav, output_wav
|
||||||
channel_number = rfcomm_server.listen(lambda dlc: on_dlc(dlc, configuration))
|
with (
|
||||||
print(f'### Listening for connection on channel {channel_number}')
|
(
|
||||||
|
wave.open(input_file_name, "rb")
|
||||||
|
if input_file_name
|
||||||
|
else contextlib.nullcontext(None)
|
||||||
|
) as input_wav,
|
||||||
|
(
|
||||||
|
wave.open(output_file_name, "wb")
|
||||||
|
if output_file_name
|
||||||
|
else contextlib.nullcontext(None)
|
||||||
|
) 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
|
async with await open_transport(transport_spec) as transport:
|
||||||
device.sdp_service_records = {
|
device = Device.from_config_file_with_hci(
|
||||||
0x00010001: hfp.make_hf_sdp_records(
|
device_config, transport.source, transport.sink
|
||||||
0x00010001, channel_number, configuration
|
|
||||||
)
|
)
|
||||||
}
|
device.classic_enabled = True
|
||||||
|
await run(device, codec)
|
||||||
# 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
|
|
||||||
|
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ dependencies = [
|
|||||||
"grpcio >= 1.62.1; platform_system!='Emscripten'",
|
"grpcio >= 1.62.1; platform_system!='Emscripten'",
|
||||||
"humanize >= 4.6.0; platform_system!='Emscripten'",
|
"humanize >= 4.6.0; platform_system!='Emscripten'",
|
||||||
"libusb1 >= 2.0.1; platform_system!='Emscripten'",
|
"libusb1 >= 2.0.1; platform_system!='Emscripten'",
|
||||||
"libusb-package == 1.0.26.1; platform_system!='Emscripten' and platform_system!='Android'",
|
"libusb-package == 1.0.26.3; platform_system!='Emscripten' and platform_system!='Android'",
|
||||||
"platformdirs >= 3.10.0; platform_system!='Emscripten'",
|
"platformdirs >= 3.10.0; platform_system!='Emscripten'",
|
||||||
"prompt_toolkit >= 3.0.16; platform_system!='Emscripten'",
|
"prompt_toolkit >= 3.0.16; platform_system!='Emscripten'",
|
||||||
"prettytable >= 3.6.0; platform_system!='Emscripten'",
|
"prettytable >= 3.6.0; platform_system!='Emscripten'",
|
||||||
|
|||||||
Reference in New Issue
Block a user