forked from auracaster/bumble_mirror
Compare commits
17 Commits
gbg/extend
...
gbg/bench-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d7489a644a | ||
|
|
a877283360 | ||
|
|
6d91e7e79b | ||
|
|
567146b143 | ||
|
|
1a3272d7ca | ||
|
|
1ee1ff0b62 | ||
|
|
729fd97748 | ||
|
|
e308051885 | ||
|
|
10e53553d7 | ||
|
|
ef0b30d059 | ||
|
|
c6cfd101df | ||
|
|
d2dcf063ee | ||
|
|
d15bc7d664 | ||
|
|
e4364d18a7 | ||
|
|
6a34c9f224 | ||
|
|
2a764fd6bb | ||
|
|
3e8ce38eba |
2
.vscode/settings.json
vendored
2
.vscode/settings.json
vendored
@@ -74,6 +74,8 @@
|
||||
"substates",
|
||||
"tobytes",
|
||||
"tsep",
|
||||
"UNMUTE",
|
||||
"unmuted",
|
||||
"usbmodem",
|
||||
"vhci",
|
||||
"websockets",
|
||||
|
||||
107
apps/bench.py
107
apps/bench.py
@@ -50,10 +50,8 @@ from bumble.sdp import (
|
||||
SDP_PUBLIC_BROWSE_ROOT,
|
||||
SDP_SERVICE_CLASS_ID_LIST_ATTRIBUTE_ID,
|
||||
SDP_SERVICE_RECORD_HANDLE_ATTRIBUTE_ID,
|
||||
SDP_BLUETOOTH_PROFILE_DESCRIPTOR_LIST_ATTRIBUTE_ID,
|
||||
DataElement,
|
||||
ServiceAttribute,
|
||||
Client as SdpClient,
|
||||
)
|
||||
from bumble.transport import open_transport_or_link
|
||||
import bumble.rfcomm
|
||||
@@ -89,6 +87,7 @@ DEFAULT_LINGER_TIME = 1.0
|
||||
DEFAULT_POST_CONNECTION_WAIT_TIME = 1.0
|
||||
|
||||
DEFAULT_RFCOMM_CHANNEL = 8
|
||||
DEFAULT_RFCOMM_MTU = 2048
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
@@ -198,48 +197,6 @@ def make_sdp_records(channel):
|
||||
}
|
||||
|
||||
|
||||
async def find_rfcomm_channel_with_uuid(connection: Connection, uuid: str) -> int:
|
||||
# Connect to the SDP Server
|
||||
sdp_client = SdpClient(connection)
|
||||
await sdp_client.connect()
|
||||
|
||||
# Search for services with an L2CAP service attribute
|
||||
search_result = await sdp_client.search_attributes(
|
||||
[BT_L2CAP_PROTOCOL_ID],
|
||||
[
|
||||
SDP_PROTOCOL_DESCRIPTOR_LIST_ATTRIBUTE_ID,
|
||||
SDP_BLUETOOTH_PROFILE_DESCRIPTOR_LIST_ATTRIBUTE_ID,
|
||||
SDP_SERVICE_CLASS_ID_LIST_ATTRIBUTE_ID,
|
||||
],
|
||||
)
|
||||
for attribute_list in search_result:
|
||||
service_uuid = None
|
||||
service_class_id_list = ServiceAttribute.find_attribute_in_list(
|
||||
attribute_list, SDP_SERVICE_CLASS_ID_LIST_ATTRIBUTE_ID
|
||||
)
|
||||
if service_class_id_list:
|
||||
if service_class_id_list.value:
|
||||
for service_class_id in service_class_id_list.value:
|
||||
service_uuid = service_class_id.value
|
||||
if str(service_uuid) != uuid:
|
||||
# This service doesn't have a UUID or isn't the right one.
|
||||
continue
|
||||
|
||||
# Look for the RFCOMM Channel number
|
||||
protocol_descriptor_list = ServiceAttribute.find_attribute_in_list(
|
||||
attribute_list, SDP_PROTOCOL_DESCRIPTOR_LIST_ATTRIBUTE_ID
|
||||
)
|
||||
if protocol_descriptor_list:
|
||||
for protocol_descriptor in protocol_descriptor_list.value:
|
||||
if len(protocol_descriptor.value) >= 2:
|
||||
if protocol_descriptor.value[0].value == BT_RFCOMM_PROTOCOL_ID:
|
||||
await sdp_client.disconnect()
|
||||
return protocol_descriptor.value[1].value
|
||||
|
||||
await sdp_client.disconnect()
|
||||
return 0
|
||||
|
||||
|
||||
def log_stats(title, stats):
|
||||
stats_min = min(stats)
|
||||
stats_max = max(stats)
|
||||
@@ -940,11 +897,14 @@ class L2capServer(StreamedPacketIO):
|
||||
# RfcommClient
|
||||
# -----------------------------------------------------------------------------
|
||||
class RfcommClient(StreamedPacketIO):
|
||||
def __init__(self, device, channel, uuid):
|
||||
def __init__(self, device, channel, uuid, l2cap_mtu, max_frame_size, window_size):
|
||||
super().__init__()
|
||||
self.device = device
|
||||
self.channel = channel
|
||||
self.uuid = uuid
|
||||
self.l2cap_mtu = l2cap_mtu
|
||||
self.max_frame_size = max_frame_size
|
||||
self.window_size = window_size
|
||||
self.rfcomm_session = None
|
||||
self.ready = asyncio.Event()
|
||||
|
||||
@@ -957,7 +917,9 @@ class RfcommClient(StreamedPacketIO):
|
||||
logging.info(
|
||||
color(f'@@@ Discovering channel number from UUID {self.uuid}', 'cyan')
|
||||
)
|
||||
channel = await find_rfcomm_channel_with_uuid(connection, self.uuid)
|
||||
channel = await bumble.rfcomm.find_rfcomm_channel_with_uuid(
|
||||
connection, self.uuid
|
||||
)
|
||||
logging.info(color(f'@@@ Channel number = {channel}', 'cyan'))
|
||||
if channel == 0:
|
||||
logging.info(color('!!! No RFComm service with this UUID found', 'red'))
|
||||
@@ -966,13 +928,21 @@ class RfcommClient(StreamedPacketIO):
|
||||
|
||||
# Create a client and start it
|
||||
logging.info(color('*** Starting RFCOMM client...', 'blue'))
|
||||
rfcomm_client = bumble.rfcomm.Client(connection)
|
||||
rfcomm_options = {}
|
||||
if self.l2cap_mtu:
|
||||
rfcomm_options['l2cap_mtu'] = self.l2cap_mtu
|
||||
rfcomm_client = bumble.rfcomm.Client(connection, **rfcomm_options)
|
||||
rfcomm_mux = await rfcomm_client.start()
|
||||
logging.info(color('*** Started', 'blue'))
|
||||
|
||||
logging.info(color(f'### Opening session for channel {channel}...', 'yellow'))
|
||||
try:
|
||||
rfcomm_session = await rfcomm_mux.open_dlc(channel)
|
||||
dlc_options = {}
|
||||
if self.max_frame_size:
|
||||
dlc_options['max_frame_size'] = self.max_frame_size
|
||||
if self.window_size:
|
||||
dlc_options['window_size'] = self.window_size
|
||||
rfcomm_session = await rfcomm_mux.open_dlc(channel, **dlc_options)
|
||||
logging.info(color(f'### Session open: {rfcomm_session}', 'yellow'))
|
||||
except bumble.core.ConnectionError as error:
|
||||
logging.info(color(f'!!! Session open failed: {error}', 'red'))
|
||||
@@ -997,13 +967,16 @@ class RfcommClient(StreamedPacketIO):
|
||||
# RfcommServer
|
||||
# -----------------------------------------------------------------------------
|
||||
class RfcommServer(StreamedPacketIO):
|
||||
def __init__(self, device, channel):
|
||||
def __init__(self, device, channel, l2cap_mtu):
|
||||
super().__init__()
|
||||
self.dlc = None
|
||||
self.ready = asyncio.Event()
|
||||
|
||||
# Create and register a server
|
||||
rfcomm_server = bumble.rfcomm.Server(device)
|
||||
server_options = {}
|
||||
if l2cap_mtu:
|
||||
server_options['l2cap_mtu'] = l2cap_mtu
|
||||
rfcomm_server = bumble.rfcomm.Server(device, **server_options)
|
||||
|
||||
# Listen for incoming DLC connections
|
||||
channel_number = rfcomm_server.listen(self.on_dlc, channel)
|
||||
@@ -1340,11 +1313,20 @@ def create_mode_factory(ctx, default_mode):
|
||||
|
||||
if mode == 'rfcomm-client':
|
||||
return RfcommClient(
|
||||
device, channel=ctx.obj['rfcomm_channel'], uuid=ctx.obj['rfcomm_uuid']
|
||||
device,
|
||||
channel=ctx.obj['rfcomm_channel'],
|
||||
uuid=ctx.obj['rfcomm_uuid'],
|
||||
l2cap_mtu=ctx.obj['rfcomm_l2cap_mtu'],
|
||||
max_frame_size=ctx.obj['rfcomm_max_frame_size'],
|
||||
window_size=ctx.obj['rfcomm_window_size'],
|
||||
)
|
||||
|
||||
if mode == 'rfcomm-server':
|
||||
return RfcommServer(device, channel=ctx.obj['rfcomm_channel'])
|
||||
return RfcommServer(
|
||||
device,
|
||||
channel=ctx.obj['rfcomm_channel'],
|
||||
l2cap_mtu=ctx.obj['rfcomm_l2cap_mtu'],
|
||||
)
|
||||
|
||||
raise ValueError('invalid mode')
|
||||
|
||||
@@ -1431,6 +1413,21 @@ def create_role_factory(ctx, default_role):
|
||||
default=DEFAULT_RFCOMM_UUID,
|
||||
help='RFComm service UUID to use (ignored if --rfcomm-channel is not 0)',
|
||||
)
|
||||
@click.option(
|
||||
'--rfcomm-l2cap-mtu',
|
||||
type=int,
|
||||
help='RFComm L2CAP MTU',
|
||||
)
|
||||
@click.option(
|
||||
'--rfcomm-max-frame-size',
|
||||
type=int,
|
||||
help='RFComm maximum frame size',
|
||||
)
|
||||
@click.option(
|
||||
'--rfcomm-window-size',
|
||||
type=int,
|
||||
help='RFComm window size',
|
||||
)
|
||||
@click.option(
|
||||
'--l2cap-psm',
|
||||
type=int,
|
||||
@@ -1528,6 +1525,9 @@ def bench(
|
||||
linger,
|
||||
rfcomm_channel,
|
||||
rfcomm_uuid,
|
||||
rfcomm_l2cap_mtu,
|
||||
rfcomm_max_frame_size,
|
||||
rfcomm_window_size,
|
||||
l2cap_psm,
|
||||
l2cap_mtu,
|
||||
l2cap_mps,
|
||||
@@ -1540,6 +1540,9 @@ def bench(
|
||||
ctx.obj['att_mtu'] = att_mtu
|
||||
ctx.obj['rfcomm_channel'] = rfcomm_channel
|
||||
ctx.obj['rfcomm_uuid'] = rfcomm_uuid
|
||||
ctx.obj['rfcomm_l2cap_mtu'] = rfcomm_l2cap_mtu
|
||||
ctx.obj['rfcomm_max_frame_size'] = rfcomm_max_frame_size
|
||||
ctx.obj['rfcomm_window_size'] = rfcomm_window_size
|
||||
ctx.obj['l2cap_psm'] = l2cap_psm
|
||||
ctx.obj['l2cap_mtu'] = l2cap_mtu
|
||||
ctx.obj['l2cap_mps'] = l2cap_mps
|
||||
|
||||
@@ -99,7 +99,12 @@ class Loopback:
|
||||
|
||||
# make sure data can fit in one l2cap pdu
|
||||
l2cap_header_size = 4
|
||||
max_packet_size = host.acl_packet_queue.max_packet_size - l2cap_header_size
|
||||
|
||||
max_packet_size = (
|
||||
host.acl_packet_queue
|
||||
if host.acl_packet_queue
|
||||
else host.le_acl_packet_queue
|
||||
).max_packet_size - l2cap_header_size
|
||||
if self.packet_size > max_packet_size:
|
||||
print(
|
||||
color(
|
||||
@@ -183,7 +188,7 @@ class Loopback:
|
||||
'--packet-count',
|
||||
'-c',
|
||||
metavar='COUNT',
|
||||
type=int,
|
||||
type=click.IntRange(1, 65535),
|
||||
default=10,
|
||||
help='Packet count',
|
||||
)
|
||||
|
||||
@@ -1470,10 +1470,10 @@ class Protocol(EventEmitter):
|
||||
f'[{transaction_label}] {message}'
|
||||
)
|
||||
max_fragment_size = (
|
||||
self.l2cap_channel.mtu - 3
|
||||
self.l2cap_channel.peer_mtu - 3
|
||||
) # Enough space for a 3-byte start packet header
|
||||
payload = message.payload
|
||||
if len(payload) + 2 <= self.l2cap_channel.mtu:
|
||||
if len(payload) + 2 <= self.l2cap_channel.peer_mtu:
|
||||
# Fits in a single packet
|
||||
packet_type = self.PacketType.SINGLE_PACKET
|
||||
else:
|
||||
|
||||
@@ -1151,7 +1151,28 @@ class Controller:
|
||||
'''
|
||||
See Bluetooth spec Vol 4, Part E - 7.4.3 Read Local Supported Features Command
|
||||
'''
|
||||
return bytes([HCI_SUCCESS]) + self.lmp_features
|
||||
return bytes([HCI_SUCCESS]) + self.lmp_features[:8]
|
||||
|
||||
def on_hci_read_local_extended_features_command(self, command):
|
||||
'''
|
||||
See Bluetooth spec Vol 4, Part E - 7.4.4 Read Local Extended Features Command
|
||||
'''
|
||||
if command.page_number * 8 > len(self.lmp_features):
|
||||
return bytes([HCI_INVALID_HCI_COMMAND_PARAMETERS_ERROR])
|
||||
return (
|
||||
bytes(
|
||||
[
|
||||
# Status
|
||||
HCI_SUCCESS,
|
||||
# Page number
|
||||
command.page_number,
|
||||
# Max page number
|
||||
len(self.lmp_features) // 8 - 1,
|
||||
]
|
||||
)
|
||||
# Features of the current page
|
||||
+ self.lmp_features[command.page_number * 8 : (command.page_number + 1) * 8]
|
||||
)
|
||||
|
||||
def on_hci_read_buffer_size_command(self, _command):
|
||||
'''
|
||||
@@ -1522,6 +1543,20 @@ class Controller:
|
||||
}
|
||||
return bytes([HCI_SUCCESS])
|
||||
|
||||
def on_hci_le_read_maximum_advertising_data_length_command(self, _command):
|
||||
'''
|
||||
See Bluetooth spec Vol 4, Part E - 7.8.57 LE Read Maximum Advertising Data
|
||||
Length Command
|
||||
'''
|
||||
return struct.pack('<BH', HCI_SUCCESS, 0x0672)
|
||||
|
||||
def on_hci_le_read_number_of_supported_advertising_sets_command(self, _command):
|
||||
'''
|
||||
See Bluetooth spec Vol 4, Part E - 7.8.58 LE Read Number of Supported
|
||||
Advertising Set Command
|
||||
'''
|
||||
return struct.pack('<BB', HCI_SUCCESS, 0xF0)
|
||||
|
||||
def on_hci_le_read_transmit_power_command(self, _command):
|
||||
'''
|
||||
See Bluetooth spec Vol 4, Part E - 7.8.74 LE Read Transmit Power Command
|
||||
|
||||
181
bumble/hci.py
181
bumble/hci.py
@@ -1417,6 +1417,180 @@ class LeFeatureMask(enum.IntFlag):
|
||||
PERIODIC_ADVERTISING_WITH_RESPONSES_ADVERTISER = 1 << LeFeature.PERIODIC_ADVERTISING_WITH_RESPONSES_ADVERTISER
|
||||
PERIODIC_ADVERTISING_WITH_RESPONSES_SCANNER = 1 << LeFeature.PERIODIC_ADVERTISING_WITH_RESPONSES_SCANNER
|
||||
|
||||
class LmpFeature(enum.IntEnum):
|
||||
# Page 0 (Legacy LMP features)
|
||||
LMP_3_SLOT_PACKETS = 0
|
||||
LMP_5_SLOT_PACKETS = 1
|
||||
ENCRYPTION = 2
|
||||
SLOT_OFFSET = 3
|
||||
TIMING_ACCURACY = 4
|
||||
ROLE_SWITCH = 5
|
||||
HOLD_MODE = 6
|
||||
SNIFF_MODE = 7
|
||||
# PREVIOUSLY_USED = 8
|
||||
POWER_CONTROL_REQUESTS = 9
|
||||
CHANNEL_QUALITY_DRIVEN_DATA_RATE_CQDDR = 10
|
||||
SCO_LINK = 11
|
||||
HV2_PACKETS = 12
|
||||
HV3_PACKETS = 13
|
||||
U_LAW_LOG_SYNCHRONOUS_DATA = 14
|
||||
A_LAW_LOG_SYNCHRONOUS_DATA = 15
|
||||
CVSD_SYNCHRONOUS_DATA = 16
|
||||
PAGING_PARAMETER_NEGOTIATION = 17
|
||||
POWER_CONTROL = 18
|
||||
TRANSPARENT_SYNCHRONOUS_DATA = 19
|
||||
FLOW_CONTROL_LAG_LEAST_SIGNIFICANT_BIT = 20
|
||||
FLOW_CONTROL_LAG_MIDDLE_BIT = 21
|
||||
FLOW_CONTROL_LAG_MOST_SIGNIFICANT_BIT = 22
|
||||
BROADCAST_ENCRYPTION = 23
|
||||
# RESERVED_FOR_FUTURE_USE = 24
|
||||
ENHANCED_DATA_RATE_ACL_2_MBPS_MODE = 25
|
||||
ENHANCED_DATA_RATE_ACL_3_MBPS_MODE = 26
|
||||
ENHANCED_INQUIRY_SCAN = 27
|
||||
INTERLACED_INQUIRY_SCAN = 28
|
||||
INTERLACED_PAGE_SCAN = 29
|
||||
RSSI_WITH_INQUIRY_RESULTS = 30
|
||||
EXTENDED_SCO_LINK_EV3_PACKETS = 31
|
||||
EV4_PACKETS = 32
|
||||
EV5_PACKETS = 33
|
||||
# RESERVED_FOR_FUTURE_USE = 34
|
||||
AFH_CAPABLE_PERIPHERAL = 35
|
||||
AFH_CLASSIFICATION_PERIPHERAL = 36
|
||||
BR_EDR_NOT_SUPPORTED = 37
|
||||
LE_SUPPORTED_CONTROLLER = 38
|
||||
LMP_3_SLOT_ENHANCED_DATA_RATE_ACL_PACKETS = 39
|
||||
LMP_5_SLOT_ENHANCED_DATA_RATE_ACL_PACKETS = 40
|
||||
SNIFF_SUBRATING = 41
|
||||
PAUSE_ENCRYPTION = 42
|
||||
AFH_CAPABLE_CENTRAL = 43
|
||||
AFH_CLASSIFICATION_CENTRAL = 44
|
||||
ENHANCED_DATA_RATE_ESCO_2_MBPS_MODE = 45
|
||||
ENHANCED_DATA_RATE_ESCO_3_MBPS_MODE = 46
|
||||
LMP_3_SLOT_ENHANCED_DATA_RATE_ESCO_PACKETS = 47
|
||||
EXTENDED_INQUIRY_RESPONSE = 48
|
||||
SIMULTANEOUS_LE_AND_BR_EDR_TO_SAME_DEVICE_CAPABLE_CONTROLLER = 49
|
||||
# RESERVED_FOR_FUTURE_USE = 50
|
||||
SECURE_SIMPLE_PAIRING_CONTROLLER_SUPPORT = 51
|
||||
ENCAPSULATED_PDU = 52
|
||||
ERRONEOUS_DATA_REPORTING = 53
|
||||
NON_FLUSHABLE_PACKET_BOUNDARY_FLAG = 54
|
||||
# RESERVED_FOR_FUTURE_USE = 55
|
||||
HCI_LINK_SUPERVISION_TIMEOUT_CHANGED_EVENT = 56
|
||||
VARIABLE_INQUIRY_TX_POWER_LEVEL = 57
|
||||
ENHANCED_POWER_CONTROL = 58
|
||||
# RESERVED_FOR_FUTURE_USE = 59
|
||||
# RESERVED_FOR_FUTURE_USE = 60
|
||||
# RESERVED_FOR_FUTURE_USE = 61
|
||||
# RESERVED_FOR_FUTURE_USE = 62
|
||||
EXTENDED_FEATURES = 63
|
||||
|
||||
# Page 1
|
||||
SECURE_SIMPLE_PAIRING_HOST_SUPPORT = 64
|
||||
LE_SUPPORTED_HOST = 65
|
||||
# PREVIOUSLY_USED = 66
|
||||
SECURE_CONNECTIONS_HOST_SUPPORT = 67
|
||||
|
||||
# Page 2
|
||||
CONNECTIONLESS_PERIPHERAL_BROADCAST_TRANSMITTER_OPERATION = 128
|
||||
CONNECTIONLESS_PERIPHERAL_BROADCAST_RECEIVER_OPERATION = 129
|
||||
SYNCHRONIZATION_TRAIN = 130
|
||||
SYNCHRONIZATION_SCAN = 131
|
||||
HCI_INQUIRY_RESPONSE_NOTIFICATION_EVENT = 132
|
||||
GENERALIZED_INTERLACED_SCAN = 133
|
||||
COARSE_CLOCK_ADJUSTMENT = 134
|
||||
RESERVED_FOR_FUTURE_USE = 135
|
||||
SECURE_CONNECTIONS_CONTROLLER_SUPPORT = 136
|
||||
PING = 137
|
||||
SLOT_AVAILABILITY_MASK = 138
|
||||
TRAIN_NUDGING = 139
|
||||
|
||||
class LmpFeatureMask(enum.IntFlag):
|
||||
# Page 0 (Legacy LMP features)
|
||||
LMP_3_SLOT_PACKETS = (1 << LmpFeature.LMP_3_SLOT_PACKETS)
|
||||
LMP_5_SLOT_PACKETS = (1 << LmpFeature.LMP_5_SLOT_PACKETS)
|
||||
ENCRYPTION = (1 << LmpFeature.ENCRYPTION)
|
||||
SLOT_OFFSET = (1 << LmpFeature.SLOT_OFFSET)
|
||||
TIMING_ACCURACY = (1 << LmpFeature.TIMING_ACCURACY)
|
||||
ROLE_SWITCH = (1 << LmpFeature.ROLE_SWITCH)
|
||||
HOLD_MODE = (1 << LmpFeature.HOLD_MODE)
|
||||
SNIFF_MODE = (1 << LmpFeature.SNIFF_MODE)
|
||||
# PREVIOUSLY_USED = (1 << LmpFeature.PREVIOUSLY_USED)
|
||||
POWER_CONTROL_REQUESTS = (1 << LmpFeature.POWER_CONTROL_REQUESTS)
|
||||
CHANNEL_QUALITY_DRIVEN_DATA_RATE_CQDDR = (1 << LmpFeature.CHANNEL_QUALITY_DRIVEN_DATA_RATE_CQDDR)
|
||||
SCO_LINK = (1 << LmpFeature.SCO_LINK)
|
||||
HV2_PACKETS = (1 << LmpFeature.HV2_PACKETS)
|
||||
HV3_PACKETS = (1 << LmpFeature.HV3_PACKETS)
|
||||
U_LAW_LOG_SYNCHRONOUS_DATA = (1 << LmpFeature.U_LAW_LOG_SYNCHRONOUS_DATA)
|
||||
A_LAW_LOG_SYNCHRONOUS_DATA = (1 << LmpFeature.A_LAW_LOG_SYNCHRONOUS_DATA)
|
||||
CVSD_SYNCHRONOUS_DATA = (1 << LmpFeature.CVSD_SYNCHRONOUS_DATA)
|
||||
PAGING_PARAMETER_NEGOTIATION = (1 << LmpFeature.PAGING_PARAMETER_NEGOTIATION)
|
||||
POWER_CONTROL = (1 << LmpFeature.POWER_CONTROL)
|
||||
TRANSPARENT_SYNCHRONOUS_DATA = (1 << LmpFeature.TRANSPARENT_SYNCHRONOUS_DATA)
|
||||
FLOW_CONTROL_LAG_LEAST_SIGNIFICANT_BIT = (1 << LmpFeature.FLOW_CONTROL_LAG_LEAST_SIGNIFICANT_BIT)
|
||||
FLOW_CONTROL_LAG_MIDDLE_BIT = (1 << LmpFeature.FLOW_CONTROL_LAG_MIDDLE_BIT)
|
||||
FLOW_CONTROL_LAG_MOST_SIGNIFICANT_BIT = (1 << LmpFeature.FLOW_CONTROL_LAG_MOST_SIGNIFICANT_BIT)
|
||||
BROADCAST_ENCRYPTION = (1 << LmpFeature.BROADCAST_ENCRYPTION)
|
||||
# RESERVED_FOR_FUTURE_USE = (1 << LmpFeature.RESERVED_FOR_FUTURE_USE)
|
||||
ENHANCED_DATA_RATE_ACL_2_MBPS_MODE = (1 << LmpFeature.ENHANCED_DATA_RATE_ACL_2_MBPS_MODE)
|
||||
ENHANCED_DATA_RATE_ACL_3_MBPS_MODE = (1 << LmpFeature.ENHANCED_DATA_RATE_ACL_3_MBPS_MODE)
|
||||
ENHANCED_INQUIRY_SCAN = (1 << LmpFeature.ENHANCED_INQUIRY_SCAN)
|
||||
INTERLACED_INQUIRY_SCAN = (1 << LmpFeature.INTERLACED_INQUIRY_SCAN)
|
||||
INTERLACED_PAGE_SCAN = (1 << LmpFeature.INTERLACED_PAGE_SCAN)
|
||||
RSSI_WITH_INQUIRY_RESULTS = (1 << LmpFeature.RSSI_WITH_INQUIRY_RESULTS)
|
||||
EXTENDED_SCO_LINK_EV3_PACKETS = (1 << LmpFeature.EXTENDED_SCO_LINK_EV3_PACKETS)
|
||||
EV4_PACKETS = (1 << LmpFeature.EV4_PACKETS)
|
||||
EV5_PACKETS = (1 << LmpFeature.EV5_PACKETS)
|
||||
# RESERVED_FOR_FUTURE_USE = (1 << LmpFeature.RESERVED_FOR_FUTURE_USE)
|
||||
AFH_CAPABLE_PERIPHERAL = (1 << LmpFeature.AFH_CAPABLE_PERIPHERAL)
|
||||
AFH_CLASSIFICATION_PERIPHERAL = (1 << LmpFeature.AFH_CLASSIFICATION_PERIPHERAL)
|
||||
BR_EDR_NOT_SUPPORTED = (1 << LmpFeature.BR_EDR_NOT_SUPPORTED)
|
||||
LE_SUPPORTED_CONTROLLER = (1 << LmpFeature.LE_SUPPORTED_CONTROLLER)
|
||||
LMP_3_SLOT_ENHANCED_DATA_RATE_ACL_PACKETS = (1 << LmpFeature.LMP_3_SLOT_ENHANCED_DATA_RATE_ACL_PACKETS)
|
||||
LMP_5_SLOT_ENHANCED_DATA_RATE_ACL_PACKETS = (1 << LmpFeature.LMP_5_SLOT_ENHANCED_DATA_RATE_ACL_PACKETS)
|
||||
SNIFF_SUBRATING = (1 << LmpFeature.SNIFF_SUBRATING)
|
||||
PAUSE_ENCRYPTION = (1 << LmpFeature.PAUSE_ENCRYPTION)
|
||||
AFH_CAPABLE_CENTRAL = (1 << LmpFeature.AFH_CAPABLE_CENTRAL)
|
||||
AFH_CLASSIFICATION_CENTRAL = (1 << LmpFeature.AFH_CLASSIFICATION_CENTRAL)
|
||||
ENHANCED_DATA_RATE_ESCO_2_MBPS_MODE = (1 << LmpFeature.ENHANCED_DATA_RATE_ESCO_2_MBPS_MODE)
|
||||
ENHANCED_DATA_RATE_ESCO_3_MBPS_MODE = (1 << LmpFeature.ENHANCED_DATA_RATE_ESCO_3_MBPS_MODE)
|
||||
LMP_3_SLOT_ENHANCED_DATA_RATE_ESCO_PACKETS = (1 << LmpFeature.LMP_3_SLOT_ENHANCED_DATA_RATE_ESCO_PACKETS)
|
||||
EXTENDED_INQUIRY_RESPONSE = (1 << LmpFeature.EXTENDED_INQUIRY_RESPONSE)
|
||||
SIMULTANEOUS_LE_AND_BR_EDR_TO_SAME_DEVICE_CAPABLE_CONTROLLER = (1 << LmpFeature.SIMULTANEOUS_LE_AND_BR_EDR_TO_SAME_DEVICE_CAPABLE_CONTROLLER)
|
||||
# RESERVED_FOR_FUTURE_USE = (1 << LmpFeature.RESERVED_FOR_FUTURE_USE)
|
||||
SECURE_SIMPLE_PAIRING_CONTROLLER_SUPPORT = (1 << LmpFeature.SECURE_SIMPLE_PAIRING_CONTROLLER_SUPPORT)
|
||||
ENCAPSULATED_PDU = (1 << LmpFeature.ENCAPSULATED_PDU)
|
||||
ERRONEOUS_DATA_REPORTING = (1 << LmpFeature.ERRONEOUS_DATA_REPORTING)
|
||||
NON_FLUSHABLE_PACKET_BOUNDARY_FLAG = (1 << LmpFeature.NON_FLUSHABLE_PACKET_BOUNDARY_FLAG)
|
||||
# RESERVED_FOR_FUTURE_USE = (1 << LmpFeature.RESERVED_FOR_FUTURE_USE)
|
||||
HCI_LINK_SUPERVISION_TIMEOUT_CHANGED_EVENT = (1 << LmpFeature.HCI_LINK_SUPERVISION_TIMEOUT_CHANGED_EVENT)
|
||||
VARIABLE_INQUIRY_TX_POWER_LEVEL = (1 << LmpFeature.VARIABLE_INQUIRY_TX_POWER_LEVEL)
|
||||
ENHANCED_POWER_CONTROL = (1 << LmpFeature.ENHANCED_POWER_CONTROL)
|
||||
# RESERVED_FOR_FUTURE_USE = (1 << LmpFeature.RESERVED_FOR_FUTURE_USE)
|
||||
# RESERVED_FOR_FUTURE_USE = (1 << LmpFeature.RESERVED_FOR_FUTURE_USE)
|
||||
# RESERVED_FOR_FUTURE_USE = (1 << LmpFeature.RESERVED_FOR_FUTURE_USE)
|
||||
# RESERVED_FOR_FUTURE_USE = (1 << LmpFeature.RESERVED_FOR_FUTURE_USE)
|
||||
EXTENDED_FEATURES = (1 << LmpFeature.EXTENDED_FEATURES)
|
||||
|
||||
# Page 1
|
||||
SECURE_SIMPLE_PAIRING_HOST_SUPPORT = (1 << LmpFeature.SECURE_SIMPLE_PAIRING_HOST_SUPPORT)
|
||||
LE_SUPPORTED_HOST = (1 << LmpFeature.LE_SUPPORTED_HOST)
|
||||
# PREVIOUSLY_USED = (1 << LmpFeature.PREVIOUSLY_USED)
|
||||
SECURE_CONNECTIONS_HOST_SUPPORT = (1 << LmpFeature.SECURE_CONNECTIONS_HOST_SUPPORT)
|
||||
|
||||
# Page 2
|
||||
CONNECTIONLESS_PERIPHERAL_BROADCAST_TRANSMITTER_OPERATION = (1 << LmpFeature.CONNECTIONLESS_PERIPHERAL_BROADCAST_TRANSMITTER_OPERATION)
|
||||
CONNECTIONLESS_PERIPHERAL_BROADCAST_RECEIVER_OPERATION = (1 << LmpFeature.CONNECTIONLESS_PERIPHERAL_BROADCAST_RECEIVER_OPERATION)
|
||||
SYNCHRONIZATION_TRAIN = (1 << LmpFeature.SYNCHRONIZATION_TRAIN)
|
||||
SYNCHRONIZATION_SCAN = (1 << LmpFeature.SYNCHRONIZATION_SCAN)
|
||||
HCI_INQUIRY_RESPONSE_NOTIFICATION_EVENT = (1 << LmpFeature.HCI_INQUIRY_RESPONSE_NOTIFICATION_EVENT)
|
||||
GENERALIZED_INTERLACED_SCAN = (1 << LmpFeature.GENERALIZED_INTERLACED_SCAN)
|
||||
COARSE_CLOCK_ADJUSTMENT = (1 << LmpFeature.COARSE_CLOCK_ADJUSTMENT)
|
||||
RESERVED_FOR_FUTURE_USE = (1 << LmpFeature.RESERVED_FOR_FUTURE_USE)
|
||||
SECURE_CONNECTIONS_CONTROLLER_SUPPORT = (1 << LmpFeature.SECURE_CONNECTIONS_CONTROLLER_SUPPORT)
|
||||
PING = (1 << LmpFeature.PING)
|
||||
SLOT_AVAILABILITY_MASK = (1 << LmpFeature.SLOT_AVAILABILITY_MASK)
|
||||
TRAIN_NUDGING = (1 << LmpFeature.TRAIN_NUDGING)
|
||||
|
||||
|
||||
# fmt: on
|
||||
# pylint: enable=line-too-long
|
||||
@@ -3298,7 +3472,12 @@ class HCI_Read_Local_Supported_Commands_Command(HCI_Command):
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
@HCI_Command.command()
|
||||
@HCI_Command.command(
|
||||
return_parameters_fields=[
|
||||
('status', STATUS_SPEC),
|
||||
('lmp_features', 8),
|
||||
]
|
||||
)
|
||||
class HCI_Read_Local_Supported_Features_Command(HCI_Command):
|
||||
'''
|
||||
See Bluetooth spec @ 7.4.3 Read Local Supported Features Command
|
||||
|
||||
284
bumble/hfp.py
284
bumble/hfp.py
@@ -21,12 +21,11 @@ import asyncio
|
||||
import dataclasses
|
||||
import enum
|
||||
import traceback
|
||||
import warnings
|
||||
from typing import Dict, List, Union, Set, Any, TYPE_CHECKING
|
||||
|
||||
from . import at
|
||||
from . import rfcomm
|
||||
import pyee
|
||||
from typing import Dict, List, Union, Set, Any, Optional, TYPE_CHECKING
|
||||
|
||||
from bumble import at
|
||||
from bumble import rfcomm
|
||||
from bumble.colors import color
|
||||
from bumble.core import (
|
||||
ProtocolError,
|
||||
@@ -79,7 +78,6 @@ class HfpProtocol:
|
||||
lines_available: asyncio.Event
|
||||
|
||||
def __init__(self, dlc: rfcomm.DLC) -> None:
|
||||
warnings.warn("See HfProtocol", DeprecationWarning)
|
||||
self.dlc = dlc
|
||||
self.buffer = ''
|
||||
self.lines = collections.deque()
|
||||
@@ -128,10 +126,13 @@ class HfpProtocol:
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
|
||||
# HF supported features (AT+BRSF=) (normative).
|
||||
# Hands-Free Profile v1.8, 4.34.2, AT Capabilities Re-Used from GSM 07.07
|
||||
# and 3GPP 27.007
|
||||
class HfFeature(enum.IntFlag):
|
||||
"""
|
||||
HF supported features (AT+BRSF=) (normative).
|
||||
|
||||
Hands-Free Profile v1.8, 4.34.2, AT Capabilities Re-Used from GSM 07.07 and 3GPP 27.007.
|
||||
"""
|
||||
|
||||
EC_NR = 0x001 # Echo Cancel & Noise reduction
|
||||
THREE_WAY_CALLING = 0x002
|
||||
CLI_PRESENTATION_CAPABILITY = 0x004
|
||||
@@ -146,10 +147,13 @@ class HfFeature(enum.IntFlag):
|
||||
VOICE_RECOGNITION_TEST = 0x800
|
||||
|
||||
|
||||
# AG supported features (+BRSF:) (normative).
|
||||
# Hands-Free Profile v1.8, 4.34.2, AT Capabilities Re-Used from GSM 07.07
|
||||
# and 3GPP 27.007
|
||||
class AgFeature(enum.IntFlag):
|
||||
"""
|
||||
AG supported features (+BRSF:) (normative).
|
||||
|
||||
Hands-Free Profile v1.8, 4.34.2, AT Capabilities Re-Used from GSM 07.07 and 3GPP 27.007.
|
||||
"""
|
||||
|
||||
THREE_WAY_CALLING = 0x001
|
||||
EC_NR = 0x002 # Echo Cancel & Noise reduction
|
||||
VOICE_RECOGNITION_FUNCTION = 0x004
|
||||
@@ -166,52 +170,90 @@ class AgFeature(enum.IntFlag):
|
||||
VOICE_RECOGNITION_TEST = 0x2000
|
||||
|
||||
|
||||
# Audio Codec IDs (normative).
|
||||
# Hands-Free Profile v1.8, 10 Appendix B
|
||||
class AudioCodec(enum.IntEnum):
|
||||
"""
|
||||
Audio Codec IDs (normative).
|
||||
|
||||
Hands-Free Profile v1.9, 11 Appendix B
|
||||
"""
|
||||
|
||||
CVSD = 0x01 # Support for CVSD audio codec
|
||||
MSBC = 0x02 # Support for mSBC audio codec
|
||||
LC3_SWB = 0x03 # Support for LC3-SWB audio codec
|
||||
|
||||
|
||||
# HF Indicators (normative).
|
||||
# Bluetooth Assigned Numbers, 6.10.1 HF Indicators
|
||||
class HfIndicator(enum.IntEnum):
|
||||
"""
|
||||
HF Indicators (normative).
|
||||
|
||||
Bluetooth Assigned Numbers, 6.10.1 HF Indicators.
|
||||
"""
|
||||
|
||||
ENHANCED_SAFETY = 0x01 # Enhanced safety feature
|
||||
BATTERY_LEVEL = 0x02 # Battery level feature
|
||||
|
||||
|
||||
# Call Hold supported operations (normative).
|
||||
# AT Commands Reference Guide, 3.5.2.3.12 +CHLD - Call Holding Services
|
||||
class CallHoldOperation(enum.IntEnum):
|
||||
"""
|
||||
Call Hold supported operations (normative).
|
||||
|
||||
AT Commands Reference Guide, 3.5.2.3.12 +CHLD - Call Holding Services.
|
||||
"""
|
||||
|
||||
RELEASE_ALL_HELD_CALLS = 0 # Release all held calls
|
||||
RELEASE_ALL_ACTIVE_CALLS = 1 # Release all active calls, accept other
|
||||
HOLD_ALL_ACTIVE_CALLS = 2 # Place all active calls on hold, accept other
|
||||
ADD_HELD_CALL = 3 # Adds a held call to conversation
|
||||
|
||||
|
||||
# Response Hold status (normative).
|
||||
# Hands-Free Profile v1.8, 4.34.2, AT Capabilities Re-Used from GSM 07.07
|
||||
# and 3GPP 27.007
|
||||
class ResponseHoldStatus(enum.IntEnum):
|
||||
"""
|
||||
Response Hold status (normative).
|
||||
|
||||
Hands-Free Profile v1.8, 4.34.2, AT Capabilities Re-Used from GSM 07.07 and 3GPP 27.007.
|
||||
"""
|
||||
|
||||
INC_CALL_HELD = 0 # Put incoming call on hold
|
||||
HELD_CALL_ACC = 1 # Accept a held incoming call
|
||||
HELD_CALL_REJ = 2 # Reject a held incoming call
|
||||
|
||||
|
||||
# Values for the Call Setup AG indicator (normative).
|
||||
# Hands-Free Profile v1.8, 4.34.2, AT Capabilities Re-Used from GSM 07.07
|
||||
# and 3GPP 27.007
|
||||
class AgIndicator(enum.Enum):
|
||||
"""
|
||||
Values for the AG indicator (normative).
|
||||
|
||||
Hands-Free Profile v1.8, 4.34.2, AT Capabilities Re-Used from GSM 07.07 and 3GPP 27.007.
|
||||
"""
|
||||
|
||||
SERVICE = 'service'
|
||||
CALL = 'call'
|
||||
CALL_SETUP = 'callsetup'
|
||||
CALL_HELD = 'callheld'
|
||||
SIGNAL = 'signal'
|
||||
ROAM = 'roam'
|
||||
BATTERY_CHARGE = 'battchg'
|
||||
|
||||
|
||||
class CallSetupAgIndicator(enum.IntEnum):
|
||||
"""
|
||||
Values for the Call Setup AG indicator (normative).
|
||||
|
||||
Hands-Free Profile v1.8, 4.34.2, AT Capabilities Re-Used from GSM 07.07 and 3GPP 27.007.
|
||||
"""
|
||||
|
||||
NOT_IN_CALL_SETUP = 0
|
||||
INCOMING_CALL_PROCESS = 1
|
||||
OUTGOING_CALL_SETUP = 2
|
||||
REMOTE_ALERTED = 3 # Remote party alerted in an outgoing call
|
||||
|
||||
|
||||
# Values for the Call Held AG indicator (normative).
|
||||
# Hands-Free Profile v1.8, 4.34.2, AT Capabilities Re-Used from GSM 07.07
|
||||
# and 3GPP 27.007
|
||||
class CallHeldAgIndicator(enum.IntEnum):
|
||||
"""
|
||||
Values for the Call Held AG indicator (normative).
|
||||
|
||||
Hands-Free Profile v1.8, 4.34.2, AT Capabilities Re-Used from GSM 07.07 and 3GPP 27.007.
|
||||
"""
|
||||
|
||||
NO_CALLS_HELD = 0
|
||||
# Call is placed on hold or active/held calls swapped
|
||||
# (The AG has both an active AND a held call)
|
||||
@@ -219,16 +261,24 @@ class CallHeldAgIndicator(enum.IntEnum):
|
||||
CALL_ON_HOLD_NO_ACTIVE_CALL = 2 # Call on hold, no active call
|
||||
|
||||
|
||||
# Call Info direction (normative).
|
||||
# AT Commands Reference Guide, 3.5.2.3.15 +CLCC - List Current Calls
|
||||
class CallInfoDirection(enum.IntEnum):
|
||||
"""
|
||||
Call Info direction (normative).
|
||||
|
||||
AT Commands Reference Guide, 3.5.2.3.15 +CLCC - List Current Calls.
|
||||
"""
|
||||
|
||||
MOBILE_ORIGINATED_CALL = 0
|
||||
MOBILE_TERMINATED_CALL = 1
|
||||
|
||||
|
||||
# Call Info status (normative).
|
||||
# AT Commands Reference Guide, 3.5.2.3.15 +CLCC - List Current Calls
|
||||
class CallInfoStatus(enum.IntEnum):
|
||||
"""
|
||||
Call Info status (normative).
|
||||
|
||||
AT Commands Reference Guide, 3.5.2.3.15 +CLCC - List Current Calls.
|
||||
"""
|
||||
|
||||
ACTIVE = 0
|
||||
HELD = 1
|
||||
DIALING = 2
|
||||
@@ -237,15 +287,47 @@ class CallInfoStatus(enum.IntEnum):
|
||||
WAITING = 5
|
||||
|
||||
|
||||
# Call Info mode (normative).
|
||||
# AT Commands Reference Guide, 3.5.2.3.15 +CLCC - List Current Calls
|
||||
class CallInfoMode(enum.IntEnum):
|
||||
"""
|
||||
Call Info mode (normative).
|
||||
|
||||
AT Commands Reference Guide, 3.5.2.3.15 +CLCC - List Current Calls.
|
||||
"""
|
||||
|
||||
VOICE = 0
|
||||
DATA = 1
|
||||
FAX = 2
|
||||
UNKNOWN = 9
|
||||
|
||||
|
||||
class CallInfoMultiParty(enum.IntEnum):
|
||||
"""
|
||||
Call Info Multi-Party state (normative).
|
||||
|
||||
AT Commands Reference Guide, 3.5.2.3.15 +CLCC - List Current Calls.
|
||||
"""
|
||||
|
||||
NOT_IN_CONFERENCE = 0
|
||||
IN_CONFERENCE = 1
|
||||
|
||||
|
||||
@dataclasses.dataclass
|
||||
class CallInfo:
|
||||
"""
|
||||
Enhanced call status.
|
||||
|
||||
AT Commands Reference Guide, 3.5.2.3.15 +CLCC - List Current Calls.
|
||||
"""
|
||||
|
||||
index: int
|
||||
direction: CallInfoDirection
|
||||
status: CallInfoStatus
|
||||
mode: CallInfoMode
|
||||
multi_party: CallInfoMultiParty
|
||||
number: Optional[int] = None
|
||||
type: Optional[int] = None
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Hands-Free Control Interoperability Requirements
|
||||
# -----------------------------------------------------------------------------
|
||||
@@ -326,8 +408,9 @@ class Configuration:
|
||||
|
||||
|
||||
class AtResponseType(enum.Enum):
|
||||
"""Indicate if a response is expected from an AT command, and if multiple
|
||||
responses are accepted."""
|
||||
"""
|
||||
Indicates if a response is expected from an AT command, and if multiple responses are accepted.
|
||||
"""
|
||||
|
||||
NONE = 0
|
||||
SINGLE = 1
|
||||
@@ -361,9 +444,20 @@ class HfIndicatorState:
|
||||
enabled: bool = False
|
||||
|
||||
|
||||
class HfProtocol:
|
||||
"""Implementation for the Hands-Free side of the Hands-Free profile.
|
||||
Reference specification Hands-Free Profile v1.8"""
|
||||
class HfProtocol(pyee.EventEmitter):
|
||||
"""
|
||||
Implementation for the Hands-Free side of the Hands-Free profile.
|
||||
|
||||
Reference specification Hands-Free Profile v1.8.
|
||||
|
||||
Emitted events:
|
||||
codec_negotiation: When codec is renegotiated, notify the new codec.
|
||||
Args:
|
||||
active_codec: AudioCodec
|
||||
ag_indicator: When AG update their indicators, notify the new state.
|
||||
Args:
|
||||
ag_indicator: AgIndicator
|
||||
"""
|
||||
|
||||
supported_hf_features: int
|
||||
supported_audio_codecs: List[AudioCodec]
|
||||
@@ -383,14 +477,18 @@ class HfProtocol:
|
||||
response_queue: asyncio.Queue
|
||||
unsolicited_queue: asyncio.Queue
|
||||
read_buffer: bytearray
|
||||
active_codec: AudioCodec
|
||||
|
||||
def __init__(self, dlc: rfcomm.DLC, configuration: Configuration) -> None:
|
||||
super().__init__()
|
||||
|
||||
def __init__(self, dlc: rfcomm.DLC, configuration: Configuration):
|
||||
# Configure internal state.
|
||||
self.dlc = dlc
|
||||
self.command_lock = asyncio.Lock()
|
||||
self.response_queue = asyncio.Queue()
|
||||
self.unsolicited_queue = asyncio.Queue()
|
||||
self.read_buffer = bytearray()
|
||||
self.active_codec = AudioCodec.CVSD
|
||||
|
||||
# Build local features.
|
||||
self.supported_hf_features = sum(configuration.supported_hf_features)
|
||||
@@ -415,10 +513,12 @@ class HfProtocol:
|
||||
def supports_ag_feature(self, feature: AgFeature) -> bool:
|
||||
return (self.supported_ag_features & feature) != 0
|
||||
|
||||
# Read AT messages from the RFCOMM channel.
|
||||
# Enqueue AT commands, responses, unsolicited responses to their
|
||||
# respective queues, and set the corresponding event.
|
||||
def _read_at(self, data: bytes):
|
||||
"""
|
||||
Reads AT messages from the RFCOMM channel.
|
||||
|
||||
Enqueues AT commands, responses, unsolicited responses to their respective queues, and set the corresponding event.
|
||||
"""
|
||||
# Append to the read buffer.
|
||||
self.read_buffer.extend(data)
|
||||
|
||||
@@ -446,17 +546,25 @@ class HfProtocol:
|
||||
else:
|
||||
logger.warning(f"dropping unexpected response with code '{response.code}'")
|
||||
|
||||
# Send an AT command and wait for the peer response.
|
||||
# Wait for the AT responses sent by the peer, to the status code.
|
||||
# Raises asyncio.TimeoutError if the status is not received
|
||||
# after a timeout (default 1 second).
|
||||
# Raises ProtocolError if the status is not OK.
|
||||
async def execute_command(
|
||||
self,
|
||||
cmd: str,
|
||||
timeout: float = 1.0,
|
||||
response_type: AtResponseType = AtResponseType.NONE,
|
||||
) -> Union[None, AtResponse, List[AtResponse]]:
|
||||
"""
|
||||
Sends an AT command and wait for the peer response.
|
||||
Wait for the AT responses sent by the peer, to the status code.
|
||||
|
||||
Args:
|
||||
cmd: the AT command in string to execute.
|
||||
timeout: timeout in float seconds.
|
||||
response_type: type of response.
|
||||
|
||||
Raises:
|
||||
asyncio.TimeoutError: the status is not received after a timeout (default 1 second).
|
||||
ProtocolError: the status is not OK.
|
||||
"""
|
||||
async with self.command_lock:
|
||||
logger.debug(f">>> {cmd}")
|
||||
self.dlc.write(cmd + '\r')
|
||||
@@ -479,8 +587,9 @@ class HfProtocol:
|
||||
raise HfpProtocolError(result.code)
|
||||
responses.append(result)
|
||||
|
||||
# 4.2.1 Service Level Connection Initialization.
|
||||
async def initiate_slc(self):
|
||||
"""4.2.1 Service Level Connection Initialization."""
|
||||
|
||||
# 4.2.1.1 Supported features exchange
|
||||
# First, in the initialization procedure, the HF shall send the
|
||||
# AT+BRSF=<HF supported features> command to the AG to both notify
|
||||
@@ -620,16 +729,17 @@ class HfProtocol:
|
||||
|
||||
logger.info("SLC setup completed")
|
||||
|
||||
# 4.11.2 Audio Connection Setup by HF
|
||||
async def setup_audio_connection(self):
|
||||
"""4.11.2 Audio Connection Setup by HF."""
|
||||
|
||||
# When the HF triggers the establishment of the Codec Connection it
|
||||
# shall send the AT command AT+BCC to the AG. The AG shall respond with
|
||||
# OK if it will start the Codec Connection procedure, and with ERROR
|
||||
# if it cannot start the Codec Connection procedure.
|
||||
await self.execute_command("AT+BCC")
|
||||
|
||||
# 4.11.3 Codec Connection Setup
|
||||
async def setup_codec_connection(self, codec_id: int):
|
||||
"""4.11.3 Codec Connection Setup."""
|
||||
# The AG shall send a +BCS=<Codec ID> unsolicited response to the HF.
|
||||
# The HF shall then respond to the incoming unsolicited response with
|
||||
# the AT command AT+BCS=<Codec ID>. The ID shall be the same as in the
|
||||
@@ -647,27 +757,29 @@ class HfProtocol:
|
||||
# Synchronous Connection with the settings that are determined by the
|
||||
# ID. The HF shall be ready to accept the synchronous connection
|
||||
# establishment as soon as it has sent the AT commands AT+BCS=<Codec ID>.
|
||||
self.active_codec = AudioCodec(codec_id)
|
||||
self.emit('codec_negotiation', self.active_codec)
|
||||
|
||||
logger.info("codec connection setup completed")
|
||||
|
||||
# 4.13.1 Answer Incoming Call from the HF – In-Band Ringing
|
||||
async def answer_incoming_call(self):
|
||||
"""4.13.1 Answer Incoming Call from the HF - In-Band Ringing."""
|
||||
# The user accepts the incoming voice call by using the proper means
|
||||
# provided by the HF. The HF shall then send the ATA command
|
||||
# (see Section 4.34) to the AG. The AG shall then begin the procedure for
|
||||
# accepting the incoming call.
|
||||
await self.execute_command("ATA")
|
||||
|
||||
# 4.14.1 Reject an Incoming Call from the HF
|
||||
async def reject_incoming_call(self):
|
||||
"""4.14.1 Reject an Incoming Call from the HF."""
|
||||
# The user rejects the incoming call by using the User Interface on the
|
||||
# Hands-Free unit. The HF shall then send the AT+CHUP command
|
||||
# (see Section 4.34) to the AG. This may happen at any time during the
|
||||
# procedures described in Sections 4.13.1 and 4.13.2.
|
||||
await self.execute_command("AT+CHUP")
|
||||
|
||||
# 4.15.1 Terminate a Call Process from the HF
|
||||
async def terminate_call(self):
|
||||
"""4.15.1 Terminate a Call Process from the HF."""
|
||||
# The user may abort the ongoing call process using whatever means
|
||||
# provided by the Hands-Free unit. The HF shall send AT+CHUP command
|
||||
# (see Section 4.34) to the AG, and the AG shall then start the
|
||||
@@ -676,8 +788,35 @@ class HfProtocol:
|
||||
# code, with the value indicating (call=0).
|
||||
await self.execute_command("AT+CHUP")
|
||||
|
||||
async def query_current_calls(self) -> List[CallInfo]:
|
||||
"""4.32.1 Query List of Current Calls in AG.
|
||||
|
||||
Return:
|
||||
List of current calls in AG.
|
||||
"""
|
||||
responses = await self.execute_command(
|
||||
"AT+CLCC", response_type=AtResponseType.MULTIPLE
|
||||
)
|
||||
assert isinstance(responses, list)
|
||||
|
||||
calls = []
|
||||
for response in responses:
|
||||
call_info = CallInfo(
|
||||
index=int(response.parameters[0]),
|
||||
direction=CallInfoDirection(int(response.parameters[1])),
|
||||
status=CallInfoStatus(int(response.parameters[2])),
|
||||
mode=CallInfoMode(int(response.parameters[3])),
|
||||
multi_party=CallInfoMultiParty(int(response.parameters[4])),
|
||||
)
|
||||
if len(response.parameters) >= 7:
|
||||
call_info.number = int(response.parameters[5])
|
||||
call_info.type = int(response.parameters[6])
|
||||
calls.append(call_info)
|
||||
return calls
|
||||
|
||||
async def update_ag_indicator(self, index: int, value: int):
|
||||
self.ag_indicators[index].current_status = value
|
||||
self.emit('ag_indicator', self.ag_indicators[index])
|
||||
logger.info(
|
||||
f"AG indicator updated: {self.ag_indicators[index].description}, {value}"
|
||||
)
|
||||
@@ -695,9 +834,11 @@ class HfProtocol:
|
||||
logging.info(f"unhandled unsolicited response {result.code}")
|
||||
|
||||
async def run(self):
|
||||
"""Main rountine for the Hands-Free side of the HFP protocol.
|
||||
Initiates the service level connection then loops handling
|
||||
unsolicited AG responses."""
|
||||
"""
|
||||
Main routine for the Hands-Free side of the HFP protocol.
|
||||
|
||||
Initiates the service level connection then loops handling unsolicited AG responses.
|
||||
"""
|
||||
|
||||
try:
|
||||
await self.initiate_slc()
|
||||
@@ -713,9 +854,13 @@ class HfProtocol:
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
|
||||
# Profile version (normative).
|
||||
# Hands-Free Profile v1.8, 5.3 SDP Interoperability Requirements
|
||||
class ProfileVersion(enum.IntEnum):
|
||||
"""
|
||||
Profile version (normative).
|
||||
|
||||
Hands-Free Profile v1.8, 5.3 SDP Interoperability Requirements.
|
||||
"""
|
||||
|
||||
V1_5 = 0x0105
|
||||
V1_6 = 0x0106
|
||||
V1_7 = 0x0107
|
||||
@@ -723,9 +868,13 @@ class ProfileVersion(enum.IntEnum):
|
||||
V1_9 = 0x0109
|
||||
|
||||
|
||||
# HF supported features (normative).
|
||||
# Hands-Free Profile v1.8, 5.3 SDP Interoperability Requirements
|
||||
class HfSdpFeature(enum.IntFlag):
|
||||
"""
|
||||
HF supported features (normative).
|
||||
|
||||
Hands-Free Profile v1.8, 5.3 SDP Interoperability Requirements.
|
||||
"""
|
||||
|
||||
EC_NR = 0x01 # Echo Cancel & Noise reduction
|
||||
THREE_WAY_CALLING = 0x02
|
||||
CLI_PRESENTATION_CAPABILITY = 0x04
|
||||
@@ -736,9 +885,13 @@ class HfSdpFeature(enum.IntFlag):
|
||||
VOICE_RECOGNITION_TEST = 0x80
|
||||
|
||||
|
||||
# AG supported features (normative).
|
||||
# Hands-Free Profile v1.8, 5.3 SDP Interoperability Requirements
|
||||
class AgSdpFeature(enum.IntFlag):
|
||||
"""
|
||||
AG supported features (normative).
|
||||
|
||||
Hands-Free Profile v1.8, 5.3 SDP Interoperability Requirements.
|
||||
"""
|
||||
|
||||
THREE_WAY_CALLING = 0x01
|
||||
EC_NR = 0x02 # Echo Cancel & Noise reduction
|
||||
VOICE_RECOGNITION_FUNCTION = 0x04
|
||||
@@ -752,9 +905,12 @@ class AgSdpFeature(enum.IntFlag):
|
||||
def sdp_records(
|
||||
service_record_handle: int, rfcomm_channel: int, configuration: Configuration
|
||||
) -> List[ServiceAttribute]:
|
||||
"""Generate the SDP record for HFP Hands-Free support.
|
||||
"""
|
||||
Generates the SDP record for HFP Hands-Free support.
|
||||
|
||||
The record exposes the features supported in the input configuration,
|
||||
and the allocated RFCOMM channel."""
|
||||
and the allocated RFCOMM channel.
|
||||
"""
|
||||
|
||||
hf_supported_features = 0
|
||||
|
||||
|
||||
@@ -416,7 +416,7 @@ class Device(HID):
|
||||
data = bytearray()
|
||||
data.append(report_id)
|
||||
data.extend(ret.data)
|
||||
if len(data) < self.l2cap_ctrl_channel.mtu: # type: ignore[union-attr]
|
||||
if len(data) < self.l2cap_ctrl_channel.peer_mtu: # type: ignore[union-attr]
|
||||
self.send_control_data(report_type=report_type, data=data)
|
||||
else:
|
||||
self.send_handshake_message(Message.Handshake.ERR_INVALID_PARAMETER)
|
||||
|
||||
@@ -167,6 +167,7 @@ class Host(AbortableEventEmitter):
|
||||
self.local_version = None
|
||||
self.local_supported_commands = bytes(64)
|
||||
self.local_le_features = 0
|
||||
self.local_lmp_features = hci.LmpFeatureMask(0) # Classic LMP features
|
||||
self.suggested_max_tx_octets = 251 # Max allowed
|
||||
self.suggested_max_tx_time = 2120 # Max allowed
|
||||
self.command_semaphore = asyncio.Semaphore(1)
|
||||
@@ -247,6 +248,32 @@ class Host(AbortableEventEmitter):
|
||||
)
|
||||
self.local_version = response.return_parameters
|
||||
|
||||
if self.supports_command(hci.HCI_READ_LOCAL_EXTENDED_FEATURES_COMMAND):
|
||||
max_page_number = 0
|
||||
page_number = 0
|
||||
lmp_features = 0
|
||||
while page_number <= max_page_number:
|
||||
response = await self.send_command(
|
||||
hci.HCI_Read_Local_Extended_Features_Command(
|
||||
page_number=page_number
|
||||
),
|
||||
check_result=True,
|
||||
)
|
||||
lmp_features |= int.from_bytes(
|
||||
response.return_parameters.extended_lmp_features, 'little'
|
||||
) << (64 * page_number)
|
||||
max_page_number = response.return_parameters.maximum_page_number
|
||||
page_number += 1
|
||||
self.local_lmp_features = hci.LmpFeatureMask(lmp_features)
|
||||
|
||||
elif self.supports_command(hci.HCI_READ_LOCAL_SUPPORTED_FEATURES_COMMAND):
|
||||
response = await self.send_command(
|
||||
hci.HCI_Read_Local_Supported_Features_Command(), check_result=True
|
||||
)
|
||||
self.local_lmp_features = hci.LmpFeatureMask(
|
||||
int.from_bytes(response.return_parameters.lmp_features, 'little')
|
||||
)
|
||||
|
||||
await self.send_command(
|
||||
hci.HCI_Set_Event_Mask_Command(
|
||||
event_mask=hci.HCI_Set_Event_Mask_Command.mask(
|
||||
@@ -585,6 +612,9 @@ class Host(AbortableEventEmitter):
|
||||
def supports_le_features(self, feature: hci.LeFeatureMask) -> bool:
|
||||
return (self.local_le_features & feature) == feature
|
||||
|
||||
def supports_lmp_features(self, feature: hci.LmpFeatureMask) -> bool:
|
||||
return self.local_lmp_features & (feature) == feature
|
||||
|
||||
@property
|
||||
def supported_le_features(self):
|
||||
return [
|
||||
|
||||
@@ -173,7 +173,7 @@ L2CAP_MTU_CONFIGURATION_PARAMETER_TYPE = 0x01
|
||||
@dataclasses.dataclass
|
||||
class ClassicChannelSpec:
|
||||
psm: Optional[int] = None
|
||||
mtu: int = L2CAP_MIN_BR_EDR_MTU
|
||||
mtu: int = L2CAP_DEFAULT_MTU
|
||||
|
||||
|
||||
@dataclasses.dataclass
|
||||
@@ -749,6 +749,8 @@ class ClassicChannel(EventEmitter):
|
||||
sink: Optional[Callable[[bytes], Any]]
|
||||
state: State
|
||||
connection: Connection
|
||||
mtu: int
|
||||
peer_mtu: int
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
@@ -765,6 +767,7 @@ class ClassicChannel(EventEmitter):
|
||||
self.signaling_cid = signaling_cid
|
||||
self.state = self.State.CLOSED
|
||||
self.mtu = mtu
|
||||
self.peer_mtu = L2CAP_MIN_BR_EDR_MTU
|
||||
self.psm = psm
|
||||
self.source_cid = source_cid
|
||||
self.destination_cid = 0
|
||||
@@ -861,7 +864,7 @@ class ClassicChannel(EventEmitter):
|
||||
[
|
||||
(
|
||||
L2CAP_MAXIMUM_TRANSMISSION_UNIT_CONFIGURATION_OPTION_TYPE,
|
||||
struct.pack('<H', L2CAP_DEFAULT_MTU),
|
||||
struct.pack('<H', self.mtu),
|
||||
)
|
||||
]
|
||||
)
|
||||
@@ -926,8 +929,8 @@ class ClassicChannel(EventEmitter):
|
||||
options = L2CAP_Control_Frame.decode_configuration_options(request.options)
|
||||
for option in options:
|
||||
if option[0] == L2CAP_MTU_CONFIGURATION_PARAMETER_TYPE:
|
||||
self.mtu = struct.unpack('<H', option[1])[0]
|
||||
logger.debug(f'MTU = {self.mtu}')
|
||||
self.peer_mtu = struct.unpack('<H', option[1])[0]
|
||||
logger.debug(f'peer MTU = {self.peer_mtu}')
|
||||
|
||||
self.send_control_frame(
|
||||
L2CAP_Configure_Response(
|
||||
@@ -1026,7 +1029,7 @@ class ClassicChannel(EventEmitter):
|
||||
return (
|
||||
f'Channel({self.source_cid}->{self.destination_cid}, '
|
||||
f'PSM={self.psm}, '
|
||||
f'MTU={self.mtu}, '
|
||||
f'MTU={self.mtu}/{self.peer_mtu}, '
|
||||
f'state={self.state.name})'
|
||||
)
|
||||
|
||||
|
||||
228
bumble/profiles/vcp.py
Normal file
228
bumble/profiles/vcp.py
Normal file
@@ -0,0 +1,228 @@
|
||||
# Copyright 2021-2024 Google LLC
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# https://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Imports
|
||||
# -----------------------------------------------------------------------------
|
||||
from __future__ import annotations
|
||||
import enum
|
||||
|
||||
from bumble import att
|
||||
from bumble import device
|
||||
from bumble import gatt
|
||||
from bumble import gatt_client
|
||||
|
||||
from typing import Optional
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Constants
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
MIN_VOLUME = 0
|
||||
MAX_VOLUME = 255
|
||||
|
||||
|
||||
class ErrorCode(enum.IntEnum):
|
||||
'''
|
||||
See Volume Control Service 1.6. Application error codes.
|
||||
'''
|
||||
|
||||
INVALID_CHANGE_COUNTER = 0x80
|
||||
OPCODE_NOT_SUPPORTED = 0x81
|
||||
|
||||
|
||||
class VolumeFlags(enum.IntFlag):
|
||||
'''
|
||||
See Volume Control Service 3.3. Volume Flags.
|
||||
'''
|
||||
|
||||
VOLUME_SETTING_PERSISTED = 0x01
|
||||
# RFU
|
||||
|
||||
|
||||
class VolumeControlPointOpcode(enum.IntEnum):
|
||||
'''
|
||||
See Volume Control Service Table 3.3: Volume Control Point procedure requirements.
|
||||
'''
|
||||
|
||||
# fmt: off
|
||||
RELATIVE_VOLUME_DOWN = 0x00
|
||||
RELATIVE_VOLUME_UP = 0x01
|
||||
UNMUTE_RELATIVE_VOLUME_DOWN = 0x02
|
||||
UNMUTE_RELATIVE_VOLUME_UP = 0x03
|
||||
SET_ABSOLUTE_VOLUME = 0x04
|
||||
UNMUTE = 0x05
|
||||
MUTE = 0x06
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Server
|
||||
# -----------------------------------------------------------------------------
|
||||
class VolumeControlService(gatt.TemplateService):
|
||||
UUID = gatt.GATT_VOLUME_CONTROL_SERVICE
|
||||
|
||||
volume_state: gatt.Characteristic
|
||||
volume_control_point: gatt.Characteristic
|
||||
volume_flags: gatt.Characteristic
|
||||
|
||||
volume_setting: int
|
||||
muted: int
|
||||
change_counter: int
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
step_size: int = 16,
|
||||
volume_setting: int = 0,
|
||||
muted: int = 0,
|
||||
change_counter: int = 0,
|
||||
volume_flags: int = 0,
|
||||
) -> None:
|
||||
self.step_size = step_size
|
||||
self.volume_setting = volume_setting
|
||||
self.muted = muted
|
||||
self.change_counter = change_counter
|
||||
|
||||
self.volume_state = gatt.Characteristic(
|
||||
uuid=gatt.GATT_VOLUME_STATE_CHARACTERISTIC,
|
||||
properties=(
|
||||
gatt.Characteristic.Properties.READ
|
||||
| gatt.Characteristic.Properties.NOTIFY
|
||||
),
|
||||
permissions=gatt.Characteristic.Permissions.READ_REQUIRES_ENCRYPTION,
|
||||
value=gatt.CharacteristicValue(read=self._on_read_volume_state),
|
||||
)
|
||||
self.volume_control_point = gatt.Characteristic(
|
||||
uuid=gatt.GATT_VOLUME_CONTROL_POINT_CHARACTERISTIC,
|
||||
properties=gatt.Characteristic.Properties.WRITE,
|
||||
permissions=gatt.Characteristic.Permissions.WRITE_REQUIRES_ENCRYPTION,
|
||||
value=gatt.CharacteristicValue(write=self._on_write_volume_control_point),
|
||||
)
|
||||
self.volume_flags = gatt.Characteristic(
|
||||
uuid=gatt.GATT_VOLUME_FLAGS_CHARACTERISTIC,
|
||||
properties=gatt.Characteristic.Properties.READ,
|
||||
permissions=gatt.Characteristic.Permissions.READ_REQUIRES_ENCRYPTION,
|
||||
value=bytes([volume_flags]),
|
||||
)
|
||||
|
||||
super().__init__(
|
||||
[
|
||||
self.volume_state,
|
||||
self.volume_control_point,
|
||||
self.volume_flags,
|
||||
]
|
||||
)
|
||||
|
||||
@property
|
||||
def volume_state_bytes(self) -> bytes:
|
||||
return bytes([self.volume_setting, self.muted, self.change_counter])
|
||||
|
||||
@volume_state_bytes.setter
|
||||
def volume_state_bytes(self, new_value: bytes) -> None:
|
||||
self.volume_setting, self.muted, self.change_counter = new_value
|
||||
|
||||
def _on_read_volume_state(self, _connection: Optional[device.Connection]) -> bytes:
|
||||
return self.volume_state_bytes
|
||||
|
||||
def _on_write_volume_control_point(
|
||||
self, connection: Optional[device.Connection], value: bytes
|
||||
) -> None:
|
||||
assert connection
|
||||
|
||||
opcode = VolumeControlPointOpcode(value[0])
|
||||
change_counter = value[1]
|
||||
|
||||
if change_counter != self.change_counter:
|
||||
raise att.ATT_Error(ErrorCode.INVALID_CHANGE_COUNTER)
|
||||
|
||||
handler = getattr(self, '_on_' + opcode.name.lower())
|
||||
if handler(*value[2:]):
|
||||
self.change_counter = (self.change_counter + 1) % 256
|
||||
connection.abort_on(
|
||||
'disconnection',
|
||||
connection.device.notify_subscribers(
|
||||
attribute=self.volume_state,
|
||||
value=self.volume_state_bytes,
|
||||
),
|
||||
)
|
||||
self.emit(
|
||||
'volume_state', self.volume_setting, self.muted, self.change_counter
|
||||
)
|
||||
|
||||
def _on_relative_volume_down(self) -> bool:
|
||||
old_volume = self.volume_setting
|
||||
self.volume_setting = max(self.volume_setting - self.step_size, MIN_VOLUME)
|
||||
return self.volume_setting != old_volume
|
||||
|
||||
def _on_relative_volume_up(self) -> bool:
|
||||
old_volume = self.volume_setting
|
||||
self.volume_setting = min(self.volume_setting + self.step_size, MAX_VOLUME)
|
||||
return self.volume_setting != old_volume
|
||||
|
||||
def _on_unmute_relative_volume_down(self) -> bool:
|
||||
old_volume, old_muted_state = self.volume_setting, self.muted
|
||||
self.volume_setting = max(self.volume_setting - self.step_size, MIN_VOLUME)
|
||||
self.muted = 0
|
||||
return (self.volume_setting, self.muted) != (old_volume, old_muted_state)
|
||||
|
||||
def _on_unmute_relative_volume_up(self) -> bool:
|
||||
old_volume, old_muted_state = self.volume_setting, self.muted
|
||||
self.volume_setting = min(self.volume_setting + self.step_size, MAX_VOLUME)
|
||||
self.muted = 0
|
||||
return (self.volume_setting, self.muted) != (old_volume, old_muted_state)
|
||||
|
||||
def _on_set_absolute_volume(self, volume_setting: int) -> bool:
|
||||
old_volume_setting = self.volume_setting
|
||||
self.volume_setting = volume_setting
|
||||
return old_volume_setting != self.volume_setting
|
||||
|
||||
def _on_unmute(self) -> bool:
|
||||
old_muted_state = self.muted
|
||||
self.muted = 0
|
||||
return self.muted != old_muted_state
|
||||
|
||||
def _on_mute(self) -> bool:
|
||||
old_muted_state = self.muted
|
||||
self.muted = 1
|
||||
return self.muted != old_muted_state
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Client
|
||||
# -----------------------------------------------------------------------------
|
||||
class VolumeControlServiceProxy(gatt_client.ProfileServiceProxy):
|
||||
SERVICE_CLASS = VolumeControlService
|
||||
|
||||
volume_control_point: gatt_client.CharacteristicProxy
|
||||
|
||||
def __init__(self, service_proxy: gatt_client.ServiceProxy) -> None:
|
||||
self.service_proxy = service_proxy
|
||||
|
||||
self.volume_state = gatt.PackedCharacteristicAdapter(
|
||||
service_proxy.get_characteristics_by_uuid(
|
||||
gatt.GATT_VOLUME_STATE_CHARACTERISTIC
|
||||
)[0],
|
||||
'BBB',
|
||||
)
|
||||
|
||||
self.volume_control_point = service_proxy.get_characteristics_by_uuid(
|
||||
gatt.GATT_VOLUME_CONTROL_POINT_CHARACTERISTIC
|
||||
)[0]
|
||||
|
||||
self.volume_flags = gatt.PackedCharacteristicAdapter(
|
||||
service_proxy.get_characteristics_by_uuid(
|
||||
gatt.GATT_VOLUME_FLAGS_CHARACTERISTIC
|
||||
)[0],
|
||||
'B',
|
||||
)
|
||||
292
bumble/rfcomm.py
292
bumble/rfcomm.py
@@ -19,12 +19,16 @@ from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import asyncio
|
||||
import dataclasses
|
||||
import enum
|
||||
from typing import Callable, Dict, List, Optional, Tuple, Union, TYPE_CHECKING
|
||||
from typing_extensions import Self
|
||||
|
||||
from pyee import EventEmitter
|
||||
|
||||
from . import core, l2cap
|
||||
from bumble import core
|
||||
from bumble import l2cap
|
||||
from bumble import sdp
|
||||
from .colors import color
|
||||
from .core import (
|
||||
UUID,
|
||||
@@ -34,15 +38,6 @@ from .core import (
|
||||
InvalidStateError,
|
||||
ProtocolError,
|
||||
)
|
||||
from .sdp import (
|
||||
SDP_SERVICE_RECORD_HANDLE_ATTRIBUTE_ID,
|
||||
SDP_BROWSE_GROUP_LIST_ATTRIBUTE_ID,
|
||||
SDP_SERVICE_CLASS_ID_LIST_ATTRIBUTE_ID,
|
||||
SDP_PROTOCOL_DESCRIPTOR_LIST_ATTRIBUTE_ID,
|
||||
SDP_PUBLIC_BROWSE_ROOT,
|
||||
DataElement,
|
||||
ServiceAttribute,
|
||||
)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from bumble.device import Device, Connection
|
||||
@@ -60,27 +55,18 @@ logger = logging.getLogger(__name__)
|
||||
|
||||
RFCOMM_PSM = 0x0003
|
||||
|
||||
class FrameType(enum.IntEnum):
|
||||
SABM = 0x2F # Control field [1,1,1,1,_,1,0,0] LSB-first
|
||||
UA = 0x63 # Control field [0,1,1,0,_,0,1,1] LSB-first
|
||||
DM = 0x0F # Control field [1,1,1,1,_,0,0,0] LSB-first
|
||||
DISC = 0x43 # Control field [0,1,0,_,0,0,1,1] LSB-first
|
||||
UIH = 0xEF # Control field [1,1,1,_,1,1,1,1] LSB-first
|
||||
UI = 0x03 # Control field [0,0,0,_,0,0,1,1] LSB-first
|
||||
|
||||
# Frame types
|
||||
RFCOMM_SABM_FRAME = 0x2F # Control field [1,1,1,1,_,1,0,0] LSB-first
|
||||
RFCOMM_UA_FRAME = 0x63 # Control field [0,1,1,0,_,0,1,1] LSB-first
|
||||
RFCOMM_DM_FRAME = 0x0F # Control field [1,1,1,1,_,0,0,0] LSB-first
|
||||
RFCOMM_DISC_FRAME = 0x43 # Control field [0,1,0,_,0,0,1,1] LSB-first
|
||||
RFCOMM_UIH_FRAME = 0xEF # Control field [1,1,1,_,1,1,1,1] LSB-first
|
||||
RFCOMM_UI_FRAME = 0x03 # Control field [0,0,0,_,0,0,1,1] LSB-first
|
||||
class MccType(enum.IntEnum):
|
||||
PN = 0x20
|
||||
MSC = 0x38
|
||||
|
||||
RFCOMM_FRAME_TYPE_NAMES = {
|
||||
RFCOMM_SABM_FRAME: 'SABM',
|
||||
RFCOMM_UA_FRAME: 'UA',
|
||||
RFCOMM_DM_FRAME: 'DM',
|
||||
RFCOMM_DISC_FRAME: 'DISC',
|
||||
RFCOMM_UIH_FRAME: 'UIH',
|
||||
RFCOMM_UI_FRAME: 'UI'
|
||||
}
|
||||
|
||||
# MCC Types
|
||||
RFCOMM_MCC_PN_TYPE = 0x20
|
||||
RFCOMM_MCC_MSC_TYPE = 0x38
|
||||
|
||||
# FCS CRC
|
||||
CRC_TABLE = bytes([
|
||||
@@ -118,7 +104,8 @@ CRC_TABLE = bytes([
|
||||
0XBA, 0X2B, 0X59, 0XC8, 0XBD, 0X2C, 0X5E, 0XCF
|
||||
])
|
||||
|
||||
RFCOMM_DEFAULT_WINDOW_SIZE = 16
|
||||
RFCOMM_DEFAULT_L2CAP_MTU = 2048
|
||||
RFCOMM_DEFAULT_WINDOW_SIZE = 7
|
||||
RFCOMM_DEFAULT_MAX_FRAME_SIZE = 2000
|
||||
|
||||
RFCOMM_DYNAMIC_CHANNEL_NUMBER_START = 1
|
||||
@@ -130,29 +117,33 @@ RFCOMM_DYNAMIC_CHANNEL_NUMBER_END = 30
|
||||
# -----------------------------------------------------------------------------
|
||||
def make_service_sdp_records(
|
||||
service_record_handle: int, channel: int, uuid: Optional[UUID] = None
|
||||
) -> List[ServiceAttribute]:
|
||||
) -> List[sdp.ServiceAttribute]:
|
||||
"""
|
||||
Create SDP records for an RFComm service given a channel number and an
|
||||
optional UUID. A Service Class Attribute is included only if the UUID is not None.
|
||||
"""
|
||||
records = [
|
||||
ServiceAttribute(
|
||||
SDP_SERVICE_RECORD_HANDLE_ATTRIBUTE_ID,
|
||||
DataElement.unsigned_integer_32(service_record_handle),
|
||||
sdp.ServiceAttribute(
|
||||
sdp.SDP_SERVICE_RECORD_HANDLE_ATTRIBUTE_ID,
|
||||
sdp.DataElement.unsigned_integer_32(service_record_handle),
|
||||
),
|
||||
ServiceAttribute(
|
||||
SDP_BROWSE_GROUP_LIST_ATTRIBUTE_ID,
|
||||
DataElement.sequence([DataElement.uuid(SDP_PUBLIC_BROWSE_ROOT)]),
|
||||
sdp.ServiceAttribute(
|
||||
sdp.SDP_BROWSE_GROUP_LIST_ATTRIBUTE_ID,
|
||||
sdp.DataElement.sequence(
|
||||
[sdp.DataElement.uuid(sdp.SDP_PUBLIC_BROWSE_ROOT)]
|
||||
),
|
||||
),
|
||||
ServiceAttribute(
|
||||
SDP_PROTOCOL_DESCRIPTOR_LIST_ATTRIBUTE_ID,
|
||||
DataElement.sequence(
|
||||
sdp.ServiceAttribute(
|
||||
sdp.SDP_PROTOCOL_DESCRIPTOR_LIST_ATTRIBUTE_ID,
|
||||
sdp.DataElement.sequence(
|
||||
[
|
||||
DataElement.sequence([DataElement.uuid(BT_L2CAP_PROTOCOL_ID)]),
|
||||
DataElement.sequence(
|
||||
sdp.DataElement.sequence(
|
||||
[sdp.DataElement.uuid(BT_L2CAP_PROTOCOL_ID)]
|
||||
),
|
||||
sdp.DataElement.sequence(
|
||||
[
|
||||
DataElement.uuid(BT_RFCOMM_PROTOCOL_ID),
|
||||
DataElement.unsigned_integer_8(channel),
|
||||
sdp.DataElement.uuid(BT_RFCOMM_PROTOCOL_ID),
|
||||
sdp.DataElement.unsigned_integer_8(channel),
|
||||
]
|
||||
),
|
||||
]
|
||||
@@ -162,15 +153,81 @@ def make_service_sdp_records(
|
||||
|
||||
if uuid:
|
||||
records.append(
|
||||
ServiceAttribute(
|
||||
SDP_SERVICE_CLASS_ID_LIST_ATTRIBUTE_ID,
|
||||
DataElement.sequence([DataElement.uuid(uuid)]),
|
||||
sdp.ServiceAttribute(
|
||||
sdp.SDP_SERVICE_CLASS_ID_LIST_ATTRIBUTE_ID,
|
||||
sdp.DataElement.sequence([sdp.DataElement.uuid(uuid)]),
|
||||
)
|
||||
)
|
||||
|
||||
return records
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
async def find_rfcomm_channels(connection: Connection) -> Dict[int, List[UUID]]:
|
||||
"""Searches all RFCOMM channels and their associated UUID from SDP service records.
|
||||
|
||||
Args:
|
||||
connection: ACL connection to make SDP search.
|
||||
|
||||
Returns:
|
||||
Dictionary mapping from channel number to service class UUID list.
|
||||
"""
|
||||
results = {}
|
||||
async with sdp.Client(connection) as sdp_client:
|
||||
search_result = await sdp_client.search_attributes(
|
||||
uuids=[core.BT_RFCOMM_PROTOCOL_ID],
|
||||
attribute_ids=[
|
||||
sdp.SDP_PROTOCOL_DESCRIPTOR_LIST_ATTRIBUTE_ID,
|
||||
sdp.SDP_SERVICE_CLASS_ID_LIST_ATTRIBUTE_ID,
|
||||
],
|
||||
)
|
||||
for attribute_lists in search_result:
|
||||
service_classes: List[UUID] = []
|
||||
channel: Optional[int] = None
|
||||
for attribute in attribute_lists:
|
||||
# The layout is [[L2CAP_PROTOCOL], [RFCOMM_PROTOCOL, RFCOMM_CHANNEL]].
|
||||
if attribute.id == sdp.SDP_PROTOCOL_DESCRIPTOR_LIST_ATTRIBUTE_ID:
|
||||
protocol_descriptor_list = attribute.value.value
|
||||
channel = protocol_descriptor_list[1].value[1].value
|
||||
elif attribute.id == sdp.SDP_SERVICE_CLASS_ID_LIST_ATTRIBUTE_ID:
|
||||
service_class_id_list = attribute.value.value
|
||||
service_classes = [
|
||||
service_class.value for service_class in service_class_id_list
|
||||
]
|
||||
if not service_classes or not channel:
|
||||
logger.warning(f"Bad result {attribute_lists}.")
|
||||
else:
|
||||
results[channel] = service_classes
|
||||
return results
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
async def find_rfcomm_channel_with_uuid(
|
||||
connection: Connection, uuid: str | UUID
|
||||
) -> Optional[int]:
|
||||
"""Searches an RFCOMM channel associated with given UUID from service records.
|
||||
|
||||
Args:
|
||||
connection: ACL connection to make SDP search.
|
||||
uuid: UUID of service record to search for.
|
||||
|
||||
Returns:
|
||||
RFCOMM channel number if found, otherwise None.
|
||||
"""
|
||||
if isinstance(uuid, str):
|
||||
uuid = UUID(uuid)
|
||||
return next(
|
||||
(
|
||||
channel
|
||||
for channel, class_id_list in (
|
||||
await find_rfcomm_channels(connection)
|
||||
).items()
|
||||
if uuid in class_id_list
|
||||
),
|
||||
None,
|
||||
)
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
def compute_fcs(buffer: bytes) -> int:
|
||||
result = 0xFF
|
||||
@@ -183,7 +240,7 @@ def compute_fcs(buffer: bytes) -> int:
|
||||
class RFCOMM_Frame:
|
||||
def __init__(
|
||||
self,
|
||||
frame_type: int,
|
||||
frame_type: FrameType,
|
||||
c_r: int,
|
||||
dlci: int,
|
||||
p_f: int,
|
||||
@@ -206,14 +263,11 @@ class RFCOMM_Frame:
|
||||
self.length = bytes([(length << 1) | 1])
|
||||
self.address = (dlci << 2) | (c_r << 1) | 1
|
||||
self.control = frame_type | (p_f << 4)
|
||||
if frame_type == RFCOMM_UIH_FRAME:
|
||||
if frame_type == FrameType.UIH:
|
||||
self.fcs = compute_fcs(bytes([self.address, self.control]))
|
||||
else:
|
||||
self.fcs = compute_fcs(bytes([self.address, self.control]) + self.length)
|
||||
|
||||
def type_name(self) -> str:
|
||||
return RFCOMM_FRAME_TYPE_NAMES[self.type]
|
||||
|
||||
@staticmethod
|
||||
def parse_mcc(data) -> Tuple[int, bool, bytes]:
|
||||
mcc_type = data[0] >> 2
|
||||
@@ -237,24 +291,24 @@ class RFCOMM_Frame:
|
||||
|
||||
@staticmethod
|
||||
def sabm(c_r: int, dlci: int):
|
||||
return RFCOMM_Frame(RFCOMM_SABM_FRAME, c_r, dlci, 1)
|
||||
return RFCOMM_Frame(FrameType.SABM, c_r, dlci, 1)
|
||||
|
||||
@staticmethod
|
||||
def ua(c_r: int, dlci: int):
|
||||
return RFCOMM_Frame(RFCOMM_UA_FRAME, c_r, dlci, 1)
|
||||
return RFCOMM_Frame(FrameType.UA, c_r, dlci, 1)
|
||||
|
||||
@staticmethod
|
||||
def dm(c_r: int, dlci: int):
|
||||
return RFCOMM_Frame(RFCOMM_DM_FRAME, c_r, dlci, 1)
|
||||
return RFCOMM_Frame(FrameType.DM, c_r, dlci, 1)
|
||||
|
||||
@staticmethod
|
||||
def disc(c_r: int, dlci: int):
|
||||
return RFCOMM_Frame(RFCOMM_DISC_FRAME, c_r, dlci, 1)
|
||||
return RFCOMM_Frame(FrameType.DISC, c_r, dlci, 1)
|
||||
|
||||
@staticmethod
|
||||
def uih(c_r: int, dlci: int, information: bytes, p_f: int = 0):
|
||||
return RFCOMM_Frame(
|
||||
RFCOMM_UIH_FRAME, c_r, dlci, p_f, information, with_credits=(p_f == 1)
|
||||
FrameType.UIH, c_r, dlci, p_f, information, with_credits=(p_f == 1)
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
@@ -262,7 +316,7 @@ class RFCOMM_Frame:
|
||||
# Extract fields
|
||||
dlci = (data[0] >> 2) & 0x3F
|
||||
c_r = (data[0] >> 1) & 0x01
|
||||
frame_type = data[1] & 0xEF
|
||||
frame_type = FrameType(data[1] & 0xEF)
|
||||
p_f = (data[1] >> 4) & 0x01
|
||||
length = data[2]
|
||||
if length & 0x01:
|
||||
@@ -291,7 +345,7 @@ class RFCOMM_Frame:
|
||||
|
||||
def __str__(self) -> str:
|
||||
return (
|
||||
f'{color(self.type_name(), "yellow")}'
|
||||
f'{color(self.type.name, "yellow")}'
|
||||
f'(c/r={self.c_r},'
|
||||
f'dlci={self.dlci},'
|
||||
f'p/f={self.p_f},'
|
||||
@@ -301,6 +355,7 @@ class RFCOMM_Frame:
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
@dataclasses.dataclass
|
||||
class RFCOMM_MCC_PN:
|
||||
dlci: int
|
||||
cl: int
|
||||
@@ -310,23 +365,11 @@ class RFCOMM_MCC_PN:
|
||||
max_retransmissions: int
|
||||
window_size: int
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
dlci: int,
|
||||
cl: int,
|
||||
priority: int,
|
||||
ack_timer: int,
|
||||
max_frame_size: int,
|
||||
max_retransmissions: int,
|
||||
window_size: int,
|
||||
) -> None:
|
||||
self.dlci = dlci
|
||||
self.cl = cl
|
||||
self.priority = priority
|
||||
self.ack_timer = ack_timer
|
||||
self.max_frame_size = max_frame_size
|
||||
self.max_retransmissions = max_retransmissions
|
||||
self.window_size = window_size
|
||||
def __post_init__(self) -> None:
|
||||
if self.window_size < 1 or self.window_size > 7:
|
||||
logger.warning(
|
||||
f'Error Recovery Window size {self.window_size} is out of range [1, 7].'
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def from_bytes(data: bytes) -> RFCOMM_MCC_PN:
|
||||
@@ -337,7 +380,7 @@ class RFCOMM_MCC_PN:
|
||||
ack_timer=data[3],
|
||||
max_frame_size=data[4] | data[5] << 8,
|
||||
max_retransmissions=data[6],
|
||||
window_size=data[7],
|
||||
window_size=data[7] & 0x07,
|
||||
)
|
||||
|
||||
def __bytes__(self) -> bytes:
|
||||
@@ -350,23 +393,14 @@ class RFCOMM_MCC_PN:
|
||||
self.max_frame_size & 0xFF,
|
||||
(self.max_frame_size >> 8) & 0xFF,
|
||||
self.max_retransmissions & 0xFF,
|
||||
self.window_size & 0xFF,
|
||||
# Only 3 bits are meaningful.
|
||||
self.window_size & 0x07,
|
||||
]
|
||||
)
|
||||
|
||||
def __str__(self) -> str:
|
||||
return (
|
||||
f'PN(dlci={self.dlci},'
|
||||
f'cl={self.cl},'
|
||||
f'priority={self.priority},'
|
||||
f'ack_timer={self.ack_timer},'
|
||||
f'max_frame_size={self.max_frame_size},'
|
||||
f'max_retransmissions={self.max_retransmissions},'
|
||||
f'window_size={self.window_size})'
|
||||
)
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
@dataclasses.dataclass
|
||||
class RFCOMM_MCC_MSC:
|
||||
dlci: int
|
||||
fc: int
|
||||
@@ -375,16 +409,6 @@ class RFCOMM_MCC_MSC:
|
||||
ic: int
|
||||
dv: int
|
||||
|
||||
def __init__(
|
||||
self, dlci: int, fc: int, rtc: int, rtr: int, ic: int, dv: int
|
||||
) -> None:
|
||||
self.dlci = dlci
|
||||
self.fc = fc
|
||||
self.rtc = rtc
|
||||
self.rtr = rtr
|
||||
self.ic = ic
|
||||
self.dv = dv
|
||||
|
||||
@staticmethod
|
||||
def from_bytes(data: bytes) -> RFCOMM_MCC_MSC:
|
||||
return RFCOMM_MCC_MSC(
|
||||
@@ -409,16 +433,6 @@ class RFCOMM_MCC_MSC:
|
||||
]
|
||||
)
|
||||
|
||||
def __str__(self) -> str:
|
||||
return (
|
||||
f'MSC(dlci={self.dlci},'
|
||||
f'fc={self.fc},'
|
||||
f'rtc={self.rtc},'
|
||||
f'rtr={self.rtr},'
|
||||
f'ic={self.ic},'
|
||||
f'dv={self.dv})'
|
||||
)
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
class DLC(EventEmitter):
|
||||
@@ -460,7 +474,7 @@ class DLC(EventEmitter):
|
||||
# Compute the MTU
|
||||
max_overhead = 4 + 1 # header with 2-byte length + fcs
|
||||
self.mtu = min(
|
||||
max_frame_size, self.multiplexer.l2cap_channel.mtu - max_overhead
|
||||
max_frame_size, self.multiplexer.l2cap_channel.peer_mtu - max_overhead
|
||||
)
|
||||
|
||||
def change_state(self, new_state: State) -> None:
|
||||
@@ -471,7 +485,7 @@ class DLC(EventEmitter):
|
||||
self.multiplexer.send_frame(frame)
|
||||
|
||||
def on_frame(self, frame: RFCOMM_Frame) -> None:
|
||||
handler = getattr(self, f'on_{frame.type_name()}_frame'.lower())
|
||||
handler = getattr(self, f'on_{frame.type.name}_frame'.lower())
|
||||
handler(frame)
|
||||
|
||||
def on_sabm_frame(self, _frame: RFCOMM_Frame) -> None:
|
||||
@@ -485,9 +499,7 @@ class DLC(EventEmitter):
|
||||
|
||||
# Exchange the modem status with the peer
|
||||
msc = RFCOMM_MCC_MSC(dlci=self.dlci, fc=0, rtc=1, rtr=1, ic=0, dv=1)
|
||||
mcc = RFCOMM_Frame.make_mcc(
|
||||
mcc_type=RFCOMM_MCC_MSC_TYPE, c_r=1, data=bytes(msc)
|
||||
)
|
||||
mcc = RFCOMM_Frame.make_mcc(mcc_type=MccType.MSC, c_r=1, data=bytes(msc))
|
||||
logger.debug(f'>>> MCC MSC Command: {msc}')
|
||||
self.send_frame(RFCOMM_Frame.uih(c_r=self.c_r, dlci=0, information=mcc))
|
||||
|
||||
@@ -503,9 +515,7 @@ class DLC(EventEmitter):
|
||||
|
||||
# Exchange the modem status with the peer
|
||||
msc = RFCOMM_MCC_MSC(dlci=self.dlci, fc=0, rtc=1, rtr=1, ic=0, dv=1)
|
||||
mcc = RFCOMM_Frame.make_mcc(
|
||||
mcc_type=RFCOMM_MCC_MSC_TYPE, c_r=1, data=bytes(msc)
|
||||
)
|
||||
mcc = RFCOMM_Frame.make_mcc(mcc_type=MccType.MSC, c_r=1, data=bytes(msc))
|
||||
logger.debug(f'>>> MCC MSC Command: {msc}')
|
||||
self.send_frame(RFCOMM_Frame.uih(c_r=self.c_r, dlci=0, information=mcc))
|
||||
|
||||
@@ -559,9 +569,7 @@ class DLC(EventEmitter):
|
||||
# Command
|
||||
logger.debug(f'<<< MCC MSC Command: {msc}')
|
||||
msc = RFCOMM_MCC_MSC(dlci=self.dlci, fc=0, rtc=1, rtr=1, ic=0, dv=1)
|
||||
mcc = RFCOMM_Frame.make_mcc(
|
||||
mcc_type=RFCOMM_MCC_MSC_TYPE, c_r=0, data=bytes(msc)
|
||||
)
|
||||
mcc = RFCOMM_Frame.make_mcc(mcc_type=MccType.MSC, c_r=0, data=bytes(msc))
|
||||
logger.debug(f'>>> MCC MSC Response: {msc}')
|
||||
self.send_frame(RFCOMM_Frame.uih(c_r=self.c_r, dlci=0, information=mcc))
|
||||
else:
|
||||
@@ -589,7 +597,7 @@ class DLC(EventEmitter):
|
||||
max_retransmissions=0,
|
||||
window_size=self.window_size,
|
||||
)
|
||||
mcc = RFCOMM_Frame.make_mcc(mcc_type=RFCOMM_MCC_PN_TYPE, c_r=0, data=bytes(pn))
|
||||
mcc = RFCOMM_Frame.make_mcc(mcc_type=MccType.PN, c_r=0, data=bytes(pn))
|
||||
logger.debug(f'>>> PN Response: {pn}')
|
||||
self.send_frame(RFCOMM_Frame.uih(c_r=self.c_r, dlci=0, information=mcc))
|
||||
self.change_state(DLC.State.CONNECTING)
|
||||
@@ -711,7 +719,7 @@ class Multiplexer(EventEmitter):
|
||||
if frame.dlci == 0:
|
||||
self.on_frame(frame)
|
||||
else:
|
||||
if frame.type == RFCOMM_DM_FRAME:
|
||||
if frame.type == FrameType.DM:
|
||||
# DM responses are for a DLCI, but since we only create the dlc when we
|
||||
# receive a PN response (because we need the parameters), we handle DM
|
||||
# frames at the Multiplexer level
|
||||
@@ -724,7 +732,7 @@ class Multiplexer(EventEmitter):
|
||||
dlc.on_frame(frame)
|
||||
|
||||
def on_frame(self, frame: RFCOMM_Frame) -> None:
|
||||
handler = getattr(self, f'on_{frame.type_name()}_frame'.lower())
|
||||
handler = getattr(self, f'on_{frame.type.name}_frame'.lower())
|
||||
handler(frame)
|
||||
|
||||
def on_sabm_frame(self, _frame: RFCOMM_Frame) -> None:
|
||||
@@ -772,10 +780,10 @@ class Multiplexer(EventEmitter):
|
||||
def on_uih_frame(self, frame: RFCOMM_Frame) -> None:
|
||||
(mcc_type, c_r, value) = RFCOMM_Frame.parse_mcc(frame.information)
|
||||
|
||||
if mcc_type == RFCOMM_MCC_PN_TYPE:
|
||||
if mcc_type == MccType.PN:
|
||||
pn = RFCOMM_MCC_PN.from_bytes(value)
|
||||
self.on_mcc_pn(c_r, pn)
|
||||
elif mcc_type == RFCOMM_MCC_MSC_TYPE:
|
||||
elif mcc_type == MccType.MSC:
|
||||
mcs = RFCOMM_MCC_MSC.from_bytes(value)
|
||||
self.on_mcc_msc(c_r, mcs)
|
||||
|
||||
@@ -871,7 +879,7 @@ class Multiplexer(EventEmitter):
|
||||
max_retransmissions=0,
|
||||
window_size=window_size,
|
||||
)
|
||||
mcc = RFCOMM_Frame.make_mcc(mcc_type=RFCOMM_MCC_PN_TYPE, c_r=1, data=bytes(pn))
|
||||
mcc = RFCOMM_Frame.make_mcc(mcc_type=MccType.PN, c_r=1, data=bytes(pn))
|
||||
logger.debug(f'>>> Sending MCC: {pn}')
|
||||
self.open_result = asyncio.get_running_loop().create_future()
|
||||
self.change_state(Multiplexer.State.OPENING)
|
||||
@@ -901,8 +909,11 @@ class Client:
|
||||
multiplexer: Optional[Multiplexer]
|
||||
l2cap_channel: Optional[l2cap.ClassicChannel]
|
||||
|
||||
def __init__(self, connection: Connection) -> None:
|
||||
def __init__(
|
||||
self, connection: Connection, l2cap_mtu: int = RFCOMM_DEFAULT_L2CAP_MTU
|
||||
) -> None:
|
||||
self.connection = connection
|
||||
self.l2cap_mtu = l2cap_mtu
|
||||
self.l2cap_channel = None
|
||||
self.multiplexer = None
|
||||
|
||||
@@ -910,7 +921,7 @@ class Client:
|
||||
# Create a new L2CAP connection
|
||||
try:
|
||||
self.l2cap_channel = await self.connection.create_l2cap_channel(
|
||||
spec=l2cap.ClassicChannelSpec(RFCOMM_PSM)
|
||||
spec=l2cap.ClassicChannelSpec(psm=RFCOMM_PSM, mtu=self.l2cap_mtu)
|
||||
)
|
||||
except ProtocolError as error:
|
||||
logger.warning(f'L2CAP connection failed: {error}')
|
||||
@@ -933,22 +944,33 @@ class Client:
|
||||
self.multiplexer = None
|
||||
|
||||
# Close the L2CAP channel
|
||||
# TODO
|
||||
if self.l2cap_channel:
|
||||
await self.l2cap_channel.disconnect()
|
||||
self.l2cap_channel = None
|
||||
|
||||
async def __aenter__(self) -> Multiplexer:
|
||||
return await self.start()
|
||||
|
||||
async def __aexit__(self, *args) -> None:
|
||||
await self.shutdown()
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
class Server(EventEmitter):
|
||||
acceptors: Dict[int, Callable[[DLC], None]]
|
||||
|
||||
def __init__(self, device: Device) -> None:
|
||||
def __init__(
|
||||
self, device: Device, l2cap_mtu: int = RFCOMM_DEFAULT_L2CAP_MTU
|
||||
) -> None:
|
||||
super().__init__()
|
||||
self.device = device
|
||||
self.multiplexer = None
|
||||
self.acceptors = {}
|
||||
|
||||
# Register ourselves with the L2CAP channel manager
|
||||
device.create_l2cap_server(
|
||||
spec=l2cap.ClassicChannelSpec(psm=RFCOMM_PSM), handler=self.on_connection
|
||||
self.l2cap_server = device.create_l2cap_server(
|
||||
spec=l2cap.ClassicChannelSpec(psm=RFCOMM_PSM, mtu=l2cap_mtu),
|
||||
handler=self.on_connection,
|
||||
)
|
||||
|
||||
def listen(self, acceptor: Callable[[DLC], None], channel: int = 0) -> int:
|
||||
@@ -998,3 +1020,9 @@ class Server(EventEmitter):
|
||||
acceptor = self.acceptors.get(dlc.dlci >> 1)
|
||||
if acceptor:
|
||||
acceptor(dlc)
|
||||
|
||||
def __enter__(self) -> Self:
|
||||
return self
|
||||
|
||||
def __exit__(self, *args) -> None:
|
||||
self.l2cap_server.close()
|
||||
|
||||
@@ -19,6 +19,7 @@ from __future__ import annotations
|
||||
import logging
|
||||
import struct
|
||||
from typing import Dict, List, Type, Optional, Tuple, Union, NewType, TYPE_CHECKING
|
||||
from typing_extensions import Self
|
||||
|
||||
from . import core, l2cap
|
||||
from .colors import color
|
||||
@@ -920,6 +921,13 @@ class Client:
|
||||
|
||||
return ServiceAttribute.list_from_data_elements(attribute_list_sequence.value)
|
||||
|
||||
async def __aenter__(self) -> Self:
|
||||
await self.connect()
|
||||
return self
|
||||
|
||||
async def __aexit__(self, *args) -> None:
|
||||
await self.disconnect()
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
class Server:
|
||||
|
||||
@@ -2,5 +2,6 @@
|
||||
"name": "Bumble-LEA",
|
||||
"keystore": "JsonKeyStore",
|
||||
"address": "F0:F1:F2:F3:F4:FA",
|
||||
"class_of_device": 2376708,
|
||||
"advertising_interval": 100
|
||||
}
|
||||
|
||||
9
examples/leaudio_with_classic.json
Normal file
9
examples/leaudio_with_classic.json
Normal file
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"name": "Bumble-LEA",
|
||||
"keystore": "JsonKeyStore",
|
||||
"address": "F0:F1:F2:F3:F4:FA",
|
||||
"classic_enabled": true,
|
||||
"cis_enabled": true,
|
||||
"class_of_device": 2376708,
|
||||
"advertising_interval": 100
|
||||
}
|
||||
@@ -74,7 +74,7 @@ def codec_capabilities():
|
||||
# -----------------------------------------------------------------------------
|
||||
def on_avdtp_connection(read_function, protocol):
|
||||
packet_source = SbcPacketSource(
|
||||
read_function, protocol.l2cap_channel.mtu, codec_capabilities()
|
||||
read_function, protocol.l2cap_channel.peer_mtu, codec_capabilities()
|
||||
)
|
||||
packet_pump = MediaPacketPump(packet_source.packets)
|
||||
protocol.add_source(packet_source.codec_capabilities, packet_pump)
|
||||
@@ -98,7 +98,7 @@ async def stream_packets(read_function, protocol):
|
||||
|
||||
# Stream the packets
|
||||
packet_source = SbcPacketSource(
|
||||
read_function, protocol.l2cap_channel.mtu, codec_capabilities()
|
||||
read_function, protocol.l2cap_channel.peer_mtu, codec_capabilities()
|
||||
)
|
||||
packet_pump = MediaPacketPump(packet_source.packets)
|
||||
source = protocol.add_source(packet_source.codec_capabilities, packet_pump)
|
||||
|
||||
@@ -21,11 +21,13 @@ import os
|
||||
import logging
|
||||
import json
|
||||
import websockets
|
||||
import functools
|
||||
from typing import Optional
|
||||
|
||||
from bumble.device import Device
|
||||
from bumble import rfcomm
|
||||
from bumble import hci
|
||||
from bumble.device import Device, Connection
|
||||
from bumble.transport import open_transport_or_link
|
||||
from bumble.rfcomm import Server as RfcommServer
|
||||
from bumble import hfp
|
||||
from bumble.hfp import HfProtocol
|
||||
|
||||
@@ -57,12 +59,44 @@ class UiServer:
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
def on_dlc(dlc, configuration: hfp.Configuration):
|
||||
def on_dlc(dlc: rfcomm.DLC, configuration: hfp.Configuration):
|
||||
print('*** DLC connected', dlc)
|
||||
protocol = HfProtocol(dlc, configuration)
|
||||
UiServer.protocol = protocol
|
||||
asyncio.create_task(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.SCO_LINK_TYPE:
|
||||
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
|
||||
]
|
||||
connection.abort_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=protocol)
|
||||
dlc.multiplexer.l2cap_channel.connection.device.on('sco_request', handler)
|
||||
dlc.multiplexer.l2cap_channel.once(
|
||||
'close',
|
||||
lambda: dlc.multiplexer.l2cap_channel.connection.device.remove_listener(
|
||||
'sco_request', handler
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
async def main():
|
||||
@@ -101,7 +135,7 @@ async def main():
|
||||
device.classic_enabled = True
|
||||
|
||||
# Create and register a server
|
||||
rfcomm_server = RfcommServer(device)
|
||||
rfcomm_server = rfcomm.Server(device)
|
||||
|
||||
# Listen for incoming DLC connections
|
||||
channel_number = rfcomm_server.listen(lambda dlc: on_dlc(dlc, configuration))
|
||||
|
||||
@@ -98,14 +98,14 @@ async def main() -> None:
|
||||
coding_format=CodingFormat(CodecID.LC3),
|
||||
codec_specific_capabilities=CodecSpecificCapabilities(
|
||||
supported_sampling_frequencies=(
|
||||
SupportedSamplingFrequency.FREQ_24000
|
||||
SupportedSamplingFrequency.FREQ_48000
|
||||
),
|
||||
supported_frame_durations=(
|
||||
SupportedFrameDuration.DURATION_10000_US_SUPPORTED
|
||||
),
|
||||
supported_audio_channel_counts=[1],
|
||||
min_octets_per_codec_frame=60,
|
||||
max_octets_per_codec_frame=60,
|
||||
min_octets_per_codec_frame=120,
|
||||
max_octets_per_codec_frame=120,
|
||||
supported_max_codec_frames_per_sdu=1,
|
||||
),
|
||||
),
|
||||
@@ -158,7 +158,7 @@ async def main() -> None:
|
||||
+ struct.pack(
|
||||
'<HHHHHHI',
|
||||
18, # Header length.
|
||||
24000 // 100, # Sampling Rate(/100Hz).
|
||||
48000 // 100, # Sampling Rate(/100Hz).
|
||||
0, # Bitrate(unused).
|
||||
1, # Channels.
|
||||
10000 // 10, # Frame duration(/10us).
|
||||
|
||||
191
examples/run_vcp_renderer.py
Normal file
191
examples/run_vcp_renderer.py
Normal file
@@ -0,0 +1,191 @@
|
||||
# Copyright 2021-2024 Google LLC
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# https://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Imports
|
||||
# -----------------------------------------------------------------------------
|
||||
import asyncio
|
||||
import logging
|
||||
import sys
|
||||
import os
|
||||
import secrets
|
||||
import websockets
|
||||
import json
|
||||
|
||||
from bumble.core import AdvertisingData
|
||||
from bumble.device import Device, AdvertisingParameters, AdvertisingEventProperties
|
||||
from bumble.hci import (
|
||||
CodecID,
|
||||
CodingFormat,
|
||||
OwnAddressType,
|
||||
)
|
||||
from bumble.profiles.bap import (
|
||||
CodecSpecificCapabilities,
|
||||
ContextType,
|
||||
AudioLocation,
|
||||
SupportedSamplingFrequency,
|
||||
SupportedFrameDuration,
|
||||
PacRecord,
|
||||
PublishedAudioCapabilitiesService,
|
||||
AudioStreamControlService,
|
||||
)
|
||||
from bumble.profiles.cap import CommonAudioServiceService
|
||||
from bumble.profiles.csip import CoordinatedSetIdentificationService, SirkType
|
||||
from bumble.profiles.vcp import VolumeControlService
|
||||
|
||||
from bumble.transport import open_transport_or_link
|
||||
|
||||
from typing import Optional
|
||||
|
||||
|
||||
def dumps_volume_state(volume_setting: int, muted: int, change_counter: int) -> str:
|
||||
return json.dumps(
|
||||
{
|
||||
'volume_setting': volume_setting,
|
||||
'muted': muted,
|
||||
'change_counter': change_counter,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
async def main() -> None:
|
||||
if len(sys.argv) < 3:
|
||||
print('Usage: run_vcp_renderer.py <config-file>' '<transport-spec-for-device>')
|
||||
return
|
||||
|
||||
print('<<< connecting to HCI...')
|
||||
async with await open_transport_or_link(sys.argv[2]) as hci_transport:
|
||||
print('<<< connected')
|
||||
|
||||
device = Device.from_config_file_with_hci(
|
||||
sys.argv[1], hci_transport.source, hci_transport.sink
|
||||
)
|
||||
|
||||
await device.power_on()
|
||||
|
||||
# Add "placeholder" services to enable Android LEA features.
|
||||
csis = CoordinatedSetIdentificationService(
|
||||
set_identity_resolving_key=secrets.token_bytes(16),
|
||||
set_identity_resolving_key_type=SirkType.PLAINTEXT,
|
||||
)
|
||||
device.add_service(CommonAudioServiceService(csis))
|
||||
device.add_service(
|
||||
PublishedAudioCapabilitiesService(
|
||||
supported_source_context=ContextType.PROHIBITED,
|
||||
available_source_context=ContextType.PROHIBITED,
|
||||
supported_sink_context=ContextType.MEDIA,
|
||||
available_sink_context=ContextType.MEDIA,
|
||||
sink_audio_locations=(
|
||||
AudioLocation.FRONT_LEFT | AudioLocation.FRONT_RIGHT
|
||||
),
|
||||
sink_pac=[
|
||||
# Codec Capability Setting 48_4
|
||||
PacRecord(
|
||||
coding_format=CodingFormat(CodecID.LC3),
|
||||
codec_specific_capabilities=CodecSpecificCapabilities(
|
||||
supported_sampling_frequencies=(
|
||||
SupportedSamplingFrequency.FREQ_48000
|
||||
),
|
||||
supported_frame_durations=(
|
||||
SupportedFrameDuration.DURATION_10000_US_SUPPORTED
|
||||
),
|
||||
supported_audio_channel_counts=[1],
|
||||
min_octets_per_codec_frame=120,
|
||||
max_octets_per_codec_frame=120,
|
||||
supported_max_codec_frames_per_sdu=1,
|
||||
),
|
||||
),
|
||||
],
|
||||
)
|
||||
)
|
||||
device.add_service(AudioStreamControlService(device, sink_ase_id=[1, 2]))
|
||||
|
||||
vcs = VolumeControlService()
|
||||
device.add_service(vcs)
|
||||
|
||||
ws: Optional[websockets.WebSocketServerProtocol] = None
|
||||
|
||||
def on_volume_state(volume_setting: int, muted: int, change_counter: int):
|
||||
if ws:
|
||||
asyncio.create_task(
|
||||
ws.send(dumps_volume_state(volume_setting, muted, change_counter))
|
||||
)
|
||||
|
||||
vcs.on('volume_state', on_volume_state)
|
||||
|
||||
advertising_data = (
|
||||
bytes(
|
||||
AdvertisingData(
|
||||
[
|
||||
(
|
||||
AdvertisingData.COMPLETE_LOCAL_NAME,
|
||||
bytes('Bumble LE Audio', 'utf-8'),
|
||||
),
|
||||
(
|
||||
AdvertisingData.FLAGS,
|
||||
bytes(
|
||||
[
|
||||
AdvertisingData.LE_GENERAL_DISCOVERABLE_MODE_FLAG
|
||||
| AdvertisingData.BR_EDR_HOST_FLAG
|
||||
| AdvertisingData.BR_EDR_CONTROLLER_FLAG
|
||||
]
|
||||
),
|
||||
),
|
||||
(
|
||||
AdvertisingData.INCOMPLETE_LIST_OF_16_BIT_SERVICE_CLASS_UUIDS,
|
||||
bytes(PublishedAudioCapabilitiesService.UUID),
|
||||
),
|
||||
]
|
||||
)
|
||||
)
|
||||
+ csis.get_advertising_data()
|
||||
)
|
||||
|
||||
await device.create_advertising_set(
|
||||
advertising_parameters=AdvertisingParameters(
|
||||
advertising_event_properties=AdvertisingEventProperties(),
|
||||
own_address_type=OwnAddressType.PUBLIC,
|
||||
),
|
||||
advertising_data=advertising_data,
|
||||
)
|
||||
|
||||
async def serve(websocket: websockets.WebSocketServerProtocol, _path):
|
||||
nonlocal ws
|
||||
await websocket.send(
|
||||
dumps_volume_state(vcs.volume_setting, vcs.muted, vcs.change_counter)
|
||||
)
|
||||
ws = websocket
|
||||
async for message in websocket:
|
||||
volume_state = json.loads(message)
|
||||
vcs.volume_state_bytes = bytes(
|
||||
[
|
||||
volume_state['volume_setting'],
|
||||
volume_state['muted'],
|
||||
volume_state['change_counter'],
|
||||
]
|
||||
)
|
||||
await device.notify_subscribers(
|
||||
vcs.volume_state, vcs.volume_state_bytes
|
||||
)
|
||||
ws = None
|
||||
|
||||
await websockets.serve(serve, 'localhost', 8989)
|
||||
|
||||
await hci_transport.source.terminated
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
logging.basicConfig(level=os.environ.get('BUMBLE_LOGLEVEL', 'DEBUG').upper())
|
||||
asyncio.run(main())
|
||||
103
examples/vcp_renderer.html
Normal file
103
examples/vcp_renderer.html
Normal file
@@ -0,0 +1,103 @@
|
||||
<html data-bs-theme="dark">
|
||||
|
||||
<head>
|
||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/css/bootstrap.min.css" rel="stylesheet"
|
||||
integrity="sha384-T3c6CoIi6uLrA9TneNEoa7RxnatzjcDSCmG1MXxSR1GAsXEV/Dwwykc2MPK8M2HN" crossorigin="anonymous">
|
||||
|
||||
</head>
|
||||
|
||||
<body>
|
||||
|
||||
<div class="container">
|
||||
|
||||
<label for="server-port" class="form-label">Server Port</label>
|
||||
<div class="input-group mb-3">
|
||||
<input type="text" class="form-control" aria-label="Port Number" value="8989" id="port">
|
||||
<button class="btn btn-primary" type="button" onclick="connect()">Connect</button>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<div class="col">
|
||||
<label for="volume_setting" class="form-label">Volume Setting</label>
|
||||
<input type="range" class="form-range" min="0" max="255" id="volume_setting">
|
||||
</div>
|
||||
<div class="col">
|
||||
<label for="change_counter" class="form-label">Change Counter</label>
|
||||
<input type="range" class="form-range" min="0" max="255" id="change_counter">
|
||||
</div>
|
||||
<div class="col">
|
||||
<div class="form-check form-switch">
|
||||
<input class="form-check-input" type="checkbox" role="switch" id="muted">
|
||||
<label class="form-check-label" for="muted">Muted</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button class="btn btn-primary" type="button" onclick="update_state()">Notify New Volume State</button>
|
||||
|
||||
|
||||
<hr>
|
||||
<div id="socketStateContainer" class="bg-body-tertiary p-3 rounded-2">
|
||||
<h3>Log</h3>
|
||||
<code id="socketState">
|
||||
</code>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
let portInput = document.getElementById("port")
|
||||
let volumeSetting = document.getElementById("volume_setting")
|
||||
let muted = document.getElementById("muted")
|
||||
let changeCounter = document.getElementById("change_counter")
|
||||
let socket = null
|
||||
|
||||
function connect() {
|
||||
if (socket != null) {
|
||||
return
|
||||
}
|
||||
socket = new WebSocket(`ws://localhost:${portInput.value}`);
|
||||
socket.onopen = _ => {
|
||||
socketState.innerText += 'OPEN\n'
|
||||
}
|
||||
socket.onclose = _ => {
|
||||
socketState.innerText += 'CLOSED\n'
|
||||
socket = null
|
||||
}
|
||||
socket.onerror = (error) => {
|
||||
socketState.innerText += 'ERROR\n'
|
||||
console.log(`ERROR: ${error}`)
|
||||
}
|
||||
socket.onmessage = (event) => {
|
||||
socketState.innerText += `<- ${event.data}\n`
|
||||
let volume_state = JSON.parse(event.data)
|
||||
volumeSetting.value = volume_state.volume_setting
|
||||
changeCounter.value = volume_state.change_counter
|
||||
muted.checked = volume_state.muted ? true : false
|
||||
}
|
||||
}
|
||||
|
||||
function send(message) {
|
||||
if (socket && socket.readyState == WebSocket.OPEN) {
|
||||
let jsonMessage = JSON.stringify(message)
|
||||
socketState.innerText += `-> ${jsonMessage}\n`
|
||||
socket.send(jsonMessage)
|
||||
} else {
|
||||
socketState.innerText += 'NOT CONNECTED\n'
|
||||
}
|
||||
}
|
||||
|
||||
function update_state() {
|
||||
send({
|
||||
volume_setting: parseInt(volumeSetting.value),
|
||||
change_counter: parseInt(changeCounter.value),
|
||||
muted: muted.checked ? 1 : 0
|
||||
})
|
||||
}
|
||||
</script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/js/bootstrap.bundle.min.js"
|
||||
integrity="sha384-C6RzsynM9kWDrMNeT87bh95OGNyZPhcTNXj1NW7RuBCsyN/o0jlpcV8Qyq46cDfL"
|
||||
crossorigin="anonymous"></script>
|
||||
|
||||
</body>
|
||||
|
||||
</html>
|
||||
@@ -52,7 +52,7 @@ install_requires =
|
||||
pyserial-asyncio >= 0.5; platform_system!='Emscripten'
|
||||
pyserial >= 3.5; platform_system!='Emscripten'
|
||||
pyusb >= 1.2; platform_system!='Emscripten'
|
||||
websockets >= 8.1; platform_system!='Emscripten'
|
||||
websockets >= 12.0; platform_system!='Emscripten'
|
||||
|
||||
[options.entry_points]
|
||||
console_scripts =
|
||||
|
||||
62
tests/host_test.py
Normal file
62
tests/host_test.py
Normal file
@@ -0,0 +1,62 @@
|
||||
# Copyright 2021-2024 Google LLC
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# https://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Imports
|
||||
# -----------------------------------------------------------------------------
|
||||
import logging
|
||||
import pytest
|
||||
|
||||
from bumble.controller import Controller
|
||||
from bumble.host import Host
|
||||
from bumble.transport import AsyncPipeSink
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Logging
|
||||
# -----------------------------------------------------------------------------
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize(
|
||||
'supported_commands, lmp_features',
|
||||
[
|
||||
(
|
||||
# Default commands
|
||||
'2000800000c000000000e4000000a822000000000000040000f7ffff7f000000'
|
||||
'30f0f9ff01008004000000000000000000000000000000000000000000000000',
|
||||
# Only LE LMP feature
|
||||
'0000000060000000',
|
||||
),
|
||||
(
|
||||
# All commands
|
||||
'ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff'
|
||||
'ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff',
|
||||
# 3 pages of LMP features
|
||||
'000102030405060708090A0B0C0D0E0F011112131415161718191A1B1C1D1E1F',
|
||||
),
|
||||
],
|
||||
)
|
||||
async def test_reset(supported_commands: str, lmp_features: str):
|
||||
controller = Controller('C')
|
||||
controller.supported_commands = bytes.fromhex(supported_commands)
|
||||
controller.lmp_features = bytes.fromhex(lmp_features)
|
||||
host = Host(controller, AsyncPipeSink(controller))
|
||||
|
||||
await host.reset()
|
||||
|
||||
assert host.local_lmp_features == int.from_bytes(
|
||||
bytes.fromhex(lmp_features), 'little'
|
||||
)
|
||||
@@ -227,12 +227,34 @@ async def test_bidirectional_transfer():
|
||||
assert server_received_bytes == message_bytes
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
@pytest.mark.asyncio
|
||||
async def test_mtu():
|
||||
devices = TwoDevices()
|
||||
await devices.setup_connection()
|
||||
|
||||
def on_channel_open(channel):
|
||||
assert channel.peer_mtu == 456
|
||||
|
||||
def on_channel(channel):
|
||||
channel.on('open', lambda: on_channel_open(channel))
|
||||
|
||||
server = devices.devices[1].create_l2cap_server(
|
||||
spec=ClassicChannelSpec(mtu=345), handler=on_channel
|
||||
)
|
||||
client_channel = await devices.connections[0].create_l2cap_channel(
|
||||
spec=ClassicChannelSpec(server.psm, mtu=456)
|
||||
)
|
||||
assert client_channel.peer_mtu == 345
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
async def run():
|
||||
test_helpers()
|
||||
await test_basic_connection()
|
||||
await test_transfer()
|
||||
await test_bidirectional_transfer()
|
||||
await test_mtu()
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
@@ -15,7 +15,22 @@
|
||||
# -----------------------------------------------------------------------------
|
||||
# Imports
|
||||
# -----------------------------------------------------------------------------
|
||||
from bumble.rfcomm import RFCOMM_Frame
|
||||
import asyncio
|
||||
import pytest
|
||||
from typing import List
|
||||
|
||||
from . import test_utils
|
||||
from bumble import core
|
||||
from bumble.rfcomm import (
|
||||
RFCOMM_Frame,
|
||||
Server,
|
||||
Client,
|
||||
DLC,
|
||||
make_service_sdp_records,
|
||||
find_rfcomm_channels,
|
||||
find_rfcomm_channel_with_uuid,
|
||||
RFCOMM_PSM,
|
||||
)
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
@@ -43,6 +58,69 @@ def test_frames():
|
||||
basic_frame_check(frame)
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
@pytest.mark.asyncio
|
||||
async def test_basic_connection() -> None:
|
||||
devices = test_utils.TwoDevices()
|
||||
await devices.setup_connection()
|
||||
|
||||
accept_future: asyncio.Future[DLC] = asyncio.get_running_loop().create_future()
|
||||
channel = Server(devices[0]).listen(acceptor=accept_future.set_result)
|
||||
|
||||
assert devices.connections[1]
|
||||
multiplexer = await Client(devices.connections[1]).start()
|
||||
dlcs = await asyncio.gather(accept_future, multiplexer.open_dlc(channel))
|
||||
|
||||
queues: List[asyncio.Queue] = [asyncio.Queue(), asyncio.Queue()]
|
||||
for dlc, queue in zip(dlcs, queues):
|
||||
dlc.sink = queue.put_nowait
|
||||
|
||||
dlcs[0].write(b'The quick brown fox jumps over the lazy dog')
|
||||
assert await queues[1].get() == b'The quick brown fox jumps over the lazy dog'
|
||||
|
||||
dlcs[1].write(b'Lorem ipsum dolor sit amet')
|
||||
assert await queues[0].get() == b'Lorem ipsum dolor sit amet'
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
@pytest.mark.asyncio
|
||||
async def test_service_record():
|
||||
HANDLE = 2
|
||||
CHANNEL = 1
|
||||
SERVICE_UUID = core.UUID('00000000-0000-0000-0000-000000000001')
|
||||
|
||||
devices = test_utils.TwoDevices()
|
||||
await devices.setup_connection()
|
||||
|
||||
devices[0].sdp_service_records[HANDLE] = make_service_sdp_records(
|
||||
HANDLE, CHANNEL, SERVICE_UUID
|
||||
)
|
||||
|
||||
assert SERVICE_UUID in (await find_rfcomm_channels(devices.connections[1]))[CHANNEL]
|
||||
assert (
|
||||
await find_rfcomm_channel_with_uuid(devices.connections[1], SERVICE_UUID)
|
||||
== CHANNEL
|
||||
)
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
@pytest.mark.asyncio
|
||||
async def test_context():
|
||||
devices = test_utils.TwoDevices()
|
||||
await devices.setup_connection()
|
||||
|
||||
server = Server(devices[0])
|
||||
with server:
|
||||
assert server.l2cap_server is not None
|
||||
|
||||
client = Client(devices.connections[1])
|
||||
async with client:
|
||||
assert client.l2cap_channel is not None
|
||||
|
||||
assert client.l2cap_channel is None
|
||||
assert RFCOMM_PSM not in devices[0].l2cap_channel_manager.servers
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
if __name__ == '__main__':
|
||||
test_frames()
|
||||
|
||||
@@ -38,6 +38,7 @@ from .test_utils import TwoDevices
|
||||
# pylint: disable=invalid-name
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
def basic_check(x: DataElement) -> None:
|
||||
serialized = bytes(x)
|
||||
@@ -269,6 +270,20 @@ async def test_service_search_attribute():
|
||||
assert expect.value == actual.value
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
@pytest.mark.asyncio
|
||||
async def test_client_async_context():
|
||||
devices = TwoDevices()
|
||||
await devices.setup_connection()
|
||||
|
||||
client = Client(devices.connections[1])
|
||||
|
||||
async with client:
|
||||
assert client.channel is not None
|
||||
|
||||
assert client.channel is None
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
async def run():
|
||||
test_data_elements()
|
||||
|
||||
120
tests/vcp_test.py
Normal file
120
tests/vcp_test.py
Normal file
@@ -0,0 +1,120 @@
|
||||
# Copyright 2021-2023 Google LLC
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# https://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Imports
|
||||
# -----------------------------------------------------------------------------
|
||||
import pytest
|
||||
import pytest_asyncio
|
||||
import logging
|
||||
|
||||
from bumble import device
|
||||
from bumble.profiles import vcp
|
||||
from .test_utils import TwoDevices
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Logging
|
||||
# -----------------------------------------------------------------------------
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
@pytest_asyncio.fixture
|
||||
async def vcp_client():
|
||||
devices = TwoDevices()
|
||||
devices[0].add_service(
|
||||
vcp.VolumeControlService(volume_setting=32, muted=1, volume_flags=1)
|
||||
)
|
||||
|
||||
await devices.setup_connection()
|
||||
|
||||
# Mock encryption.
|
||||
devices.connections[0].encryption = 1
|
||||
devices.connections[1].encryption = 1
|
||||
|
||||
peer = device.Peer(devices.connections[1])
|
||||
vcp_client = await peer.discover_service_and_create_proxy(
|
||||
vcp.VolumeControlServiceProxy
|
||||
)
|
||||
yield vcp_client
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
@pytest.mark.asyncio
|
||||
async def test_init_service(vcp_client: vcp.VolumeControlServiceProxy):
|
||||
assert (await vcp_client.volume_flags.read_value()) == 1
|
||||
assert (await vcp_client.volume_state.read_value()) == (32, 1, 0)
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
@pytest.mark.asyncio
|
||||
async def test_relative_volume_down(vcp_client: vcp.VolumeControlServiceProxy):
|
||||
await vcp_client.volume_control_point.write_value(
|
||||
bytes([vcp.VolumeControlPointOpcode.RELATIVE_VOLUME_DOWN, 0])
|
||||
)
|
||||
assert (await vcp_client.volume_state.read_value()) == (16, 1, 1)
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
@pytest.mark.asyncio
|
||||
async def test_relative_volume_up(vcp_client: vcp.VolumeControlServiceProxy):
|
||||
await vcp_client.volume_control_point.write_value(
|
||||
bytes([vcp.VolumeControlPointOpcode.RELATIVE_VOLUME_UP, 0])
|
||||
)
|
||||
assert (await vcp_client.volume_state.read_value()) == (48, 1, 1)
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
@pytest.mark.asyncio
|
||||
async def test_unmute_relative_volume_down(vcp_client: vcp.VolumeControlServiceProxy):
|
||||
await vcp_client.volume_control_point.write_value(
|
||||
bytes([vcp.VolumeControlPointOpcode.UNMUTE_RELATIVE_VOLUME_DOWN, 0])
|
||||
)
|
||||
assert (await vcp_client.volume_state.read_value()) == (16, 0, 1)
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
@pytest.mark.asyncio
|
||||
async def test_unmute_relative_volume_up(vcp_client: vcp.VolumeControlServiceProxy):
|
||||
await vcp_client.volume_control_point.write_value(
|
||||
bytes([vcp.VolumeControlPointOpcode.UNMUTE_RELATIVE_VOLUME_UP, 0])
|
||||
)
|
||||
assert (await vcp_client.volume_state.read_value()) == (48, 0, 1)
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
@pytest.mark.asyncio
|
||||
async def test_set_absolute_volume(vcp_client: vcp.VolumeControlServiceProxy):
|
||||
await vcp_client.volume_control_point.write_value(
|
||||
bytes([vcp.VolumeControlPointOpcode.SET_ABSOLUTE_VOLUME, 0, 255])
|
||||
)
|
||||
assert (await vcp_client.volume_state.read_value()) == (255, 1, 1)
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
@pytest.mark.asyncio
|
||||
async def test_mute(vcp_client: vcp.VolumeControlServiceProxy):
|
||||
await vcp_client.volume_control_point.write_value(
|
||||
bytes([vcp.VolumeControlPointOpcode.MUTE, 0])
|
||||
)
|
||||
assert (await vcp_client.volume_state.read_value()) == (32, 1, 0)
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
@pytest.mark.asyncio
|
||||
async def test_unmute(vcp_client: vcp.VolumeControlServiceProxy):
|
||||
await vcp_client.volume_control_point.write_value(
|
||||
bytes([vcp.VolumeControlPointOpcode.UNMUTE, 0])
|
||||
)
|
||||
assert (await vcp_client.volume_state.read_value()) == (32, 0, 1)
|
||||
Reference in New Issue
Block a user