Compare commits

...

13 Commits

Author SHA1 Message Date
Gilles Boccon-Gibod
12af7a526c fix incorrect var reference 2024-05-12 11:59:05 -07:00
zxzxwu
8781943646 Merge pull request #483 from zxzxwu/rfc
RFCOMM: Handle packets received before DLC sink set
2024-05-10 16:34:57 +08:00
Gilles Boccon-Gibod
7fbfdb634c Merge pull request #481 from google/gbg/command-status-fix
allow checking results for HCI_Command_Status_Event
2024-05-09 19:50:10 -07:00
Josh Wu
9682077f6b RFCOMM: Avoid receive packets before DLC sink set 2024-05-09 17:57:13 +08:00
Gilles Boccon-Gibod
22eb405fde Merge pull request #482 from servusdei2018/main
bumble.js(PacketSink): Implement asynchronous packet processing
2024-05-08 20:16:04 -07:00
zxzxwu
593c61973f Merge pull request #480 from zxzxwu/hfp-ag
HFP: Add AG example and fix errors
2024-05-07 17:50:01 +08:00
Josh Wu
ccff32102f HFP: Add example and fix AG errors 2024-05-07 00:36:52 +08:00
Nate
851d62c6c9 bumble.js(PacketSink): Implement asynchronous packet processing 2024-05-05 15:03:22 -04:00
Gilles Boccon-Gibod
090158820f allow checking results for HCI_Command_Status_Event 2024-05-04 12:17:05 -07:00
zxzxwu
26e6650038 Merge pull request #477 from zxzxwu/hfp-ag
Fix HFP query call status
2024-05-02 01:17:17 +08:00
Josh Wu
c48568aabe Fix HFP query call status 2024-04-30 03:13:38 +00:00
zxzxwu
1b33c9eb74 Merge pull request #475 from zxzxwu/hfp-ag
Add more HFP command suppport
2024-04-26 12:01:20 +08:00
zxzxwu
6633228975 Add more HFP command suppport
* Support all Call Hold Operation
* Support CLI Presentation
* Support Voice Recognition
* Support RING and Volume Changes
* [AG] Support Enhanced Call Status
* Minor fixes
2024-04-24 15:29:48 +00:00
11 changed files with 1106 additions and 94 deletions

View File

@@ -2184,7 +2184,7 @@ class Device(CompositeEventEmitter):
# controller.
await self.send_command(
HCI_LE_Remove_Advertising_Set_Command(
advertising_handle=advertising_data
advertising_handle=advertising_handle
),
check_result=False,
)

View File

@@ -50,7 +50,7 @@ from bumble.core import (
ProtocolError,
BT_GENERIC_AUDIO_SERVICE,
BT_HANDSFREE_SERVICE,
BT_HEADSET_AUDIO_GATEWAY_SERVICE,
BT_HANDSFREE_AUDIO_GATEWAY_SERVICE,
BT_L2CAP_PROTOCOL_ID,
BT_RFCOMM_PROTOCOL_ID,
)
@@ -204,17 +204,22 @@ class HfIndicator(enum.IntEnum):
BATTERY_LEVEL = 0x02 # Battery level feature
class CallHoldOperation(enum.IntEnum):
class CallHoldOperation(enum.Enum):
"""
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
RELEASE_ALL_HELD_CALLS = "0" # Release all held calls
RELEASE_ALL_ACTIVE_CALLS = "1" # Release all active calls, accept other
RELEASE_SPECIFIC_CALL = "1x" # Release a specific call X
HOLD_ALL_ACTIVE_CALLS = "2" # Place all active calls on hold, accept other
HOLD_ALL_CALLS_EXCEPT = "2x" # Place all active calls except call X
ADD_HELD_CALL = "3" # Adds a held call to conversation
CONNECT_TWO_CALLS = (
"4" # Connects the two calls and disconnects the subscriber from both calls
)
class ResponseHoldStatus(enum.IntEnum):
@@ -335,10 +340,82 @@ class CallInfo:
status: CallInfoStatus
mode: CallInfoMode
multi_party: CallInfoMultiParty
number: Optional[int] = None
number: Optional[str] = None
type: Optional[int] = None
@dataclasses.dataclass
class CallLineIdentification:
"""
Calling Line Identification notification.
TS 127 007 - V6.8.0, 7.6 Calling line identification presentation +CLIP, but only
number, type and alpha are meaningful in HFP.
Attributes:
number: String type phone number of format specified by `type`.
type: Type of address octet in integer format (refer TS 24.008 [8] subclause
10.5.4.7).
subaddr: String type subaddress of format specified by `satype`.
satype: Type of subaddress octet in integer format (refer TS 24.008 [8]
subclause 10.5.4.8).
alpha: Optional string type alphanumeric representation of number corresponding
to the entry found in phonebook; used character set should be the one selected
with command Select TE Character Set +CSCS.
cli_validity: 0 CLI valid, 1 CLI has been withheld by the originator, 2 CLI is
not available due to interworking problems or limitations of originating
network.
"""
number: str
type: int
subaddr: Optional[str] = None
satype: Optional[int] = None
alpha: Optional[str] = None
cli_validity: Optional[int] = None
@classmethod
def parse_from(cls: Type[Self], parameters: List[bytes]) -> Self:
return cls(
number=parameters[0].decode(),
type=int(parameters[1]),
subaddr=parameters[2].decode() if len(parameters) >= 3 else None,
satype=(
int(parameters[3]) if len(parameters) >= 4 and parameters[3] else None
),
alpha=parameters[4].decode() if len(parameters) >= 5 else None,
cli_validity=(
int(parameters[5]) if len(parameters) >= 6 and parameters[5] else None
),
)
def to_clip_string(self) -> str:
return ','.join(
str(arg) if arg else ''
for arg in [
self.number,
self.type,
self.subaddr,
self.satype,
self.alpha,
self.cli_validity,
]
)
class VoiceRecognitionState(enum.IntEnum):
"""
vrec values provided in AT+BVRA command.
Hands-Free Profile v1.8, 4.34.2, AT Capabilities Re-Used from GSM 07.07 and 3GPP 27.007.
"""
DISABLE = 0
ENABLE = 1
# (Enhanced Voice Recognition Status only) HF is ready to accept audio.
ENHANCED_READY = 2
class CmeError(enum.IntEnum):
"""
CME ERROR codes (partial listed).
@@ -359,7 +436,7 @@ class CmeError(enum.IntEnum):
# -----------------------------------------------------------------------------
# Response codes.
RESPONSE_CODES = [
RESPONSE_CODES = {
"+APLSIRI",
"+BAC",
"+BCC",
@@ -390,10 +467,10 @@ RESPONSE_CODES = [
"+XAPL",
"A",
"D",
]
}
# Unsolicited responses and statuses.
UNSOLICITED_CODES = [
UNSOLICITED_CODES = {
"+APLSIRI",
"+BCS",
"+BIND",
@@ -411,10 +488,10 @@ UNSOLICITED_CODES = [
"NO ANSWER",
"NO CARRIER",
"RING",
]
}
# Status codes
STATUS_CODES = [
STATUS_CODES = {
"+CME ERROR",
"BLACKLISTED",
"BUSY",
@@ -423,7 +500,7 @@ STATUS_CODES = [
"NO ANSWER",
"NO CARRIER",
"OK",
]
}
@dataclasses.dataclass
@@ -626,10 +703,25 @@ class HfProtocol(pyee.EventEmitter):
ag_indicator: When AG update their indicators, notify the new state.
Args:
ag_indicator: AgIndicator
speaker_volume: Emitted when AG update speaker volume autonomously.
Args:
volume: Int
microphone_volume: Emitted when AG update microphone volume autonomously.
Args:
volume: Int
microphone_volume: Emitted when AG sends a ringtone request.
Args:
None
cli_notification: Emitted when notify the call metadata on line.
Args:
cli_notification: CallLineIdentification
voice_recognition: Emitted when AG starts voice recognition autonomously.
Args:
vrec: VoiceRecognitionState
"""
class HfLoopTermination(HfpProtocolError): ...
"""Termination signal for run() loop."""
class HfLoopTermination(HfpProtocolError):
"""Termination signal for run() loop."""
supported_hf_features: int
supported_audio_codecs: List[AudioCodec]
@@ -651,7 +743,11 @@ class HfProtocol(pyee.EventEmitter):
read_buffer: bytearray
active_codec: AudioCodec
def __init__(self, dlc: rfcomm.DLC, configuration: HfConfiguration) -> None:
def __init__(
self,
dlc: rfcomm.DLC,
configuration: HfConfiguration,
) -> None:
super().__init__()
# Configure internal state.
@@ -841,7 +937,7 @@ class HfProtocol(pyee.EventEmitter):
if self.supports_hf_feature(
HfFeature.THREE_WAY_CALLING
) and self.supports_ag_feature(HfFeature.THREE_WAY_CALLING):
) and self.supports_ag_feature(AgFeature.THREE_WAY_CALLING):
# After the HF has enabled the “Indicators status update” function in
# the AG, and if the “Call waiting and 3-way calling” bit was set in the
# supported features bitmap by both the HF and the AG, the HF shall
@@ -854,9 +950,8 @@ class HfProtocol(pyee.EventEmitter):
)
self.supported_ag_call_hold_operations = [
CallHoldOperation(int(operation))
CallHoldOperation(operation.decode())
for operation in response.parameters[0]
if not b'x' in operation
]
# 4.2.1.4 HF Indicators
@@ -986,8 +1081,9 @@ class HfProtocol(pyee.EventEmitter):
mode=CallInfoMode(int(response.parameters[3])),
multi_party=CallInfoMultiParty(int(response.parameters[4])),
)
if len(response.parameters) >= 6:
call_info.number = response.parameters[5].decode()
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
@@ -1010,6 +1106,21 @@ class HfProtocol(pyee.EventEmitter):
await self.update_ag_indicator(
int(result.parameters[0]), int(result.parameters[1])
)
elif result.code == "+VGS":
self.emit('speaker_volume', int(result.parameters[0]))
elif result.code == "+VGM":
self.emit('microphone_volume', int(result.parameters[0]))
elif result.code == "RING":
self.emit('ring')
elif result.code == "+CLIP":
self.emit(
'cli_notification', CallLineIdentification.parse_from(result.parameters)
)
elif result.code == "+BVRA":
# TODO: Support Enhanced Voice Recognition.
self.emit(
'voice_recognition', VoiceRecognitionState(int(result.parameters[0]))
)
else:
logging.info(f"unhandled unsolicited response {result.code}")
@@ -1045,11 +1156,24 @@ class AgProtocol(pyee.EventEmitter):
active_codec: AudioCodec
hf_indicator: When HF update their indicators, notify the new state.
Args:
hf_indicator: HfIndicator
hf_indicator: HfIndicatorState
codec_connection_request: Emit when HF sends AT+BCC to request codec connection.
answer: Emit when HF sends ATA to answer phone call.
hang_up: Emit when HF sends AT+CHUP to hang up phone call.
dial: Emit when HF sends ATD to dial phone call.
voice_recognition: Emit when HF requests voice recognition state.
Args:
vrec: VoiceRecognitionState
call_hold: Emit when HF requests call hold operation.
Args:
operation: CallHoldOperation
call_index: Optional[int]
speaker_volume: Emitted when AG update speaker volume autonomously.
Args:
volume: Int
microphone_volume: Emitted when AG update microphone volume autonomously.
Args:
volume: Int
"""
supported_hf_features: int
@@ -1066,10 +1190,13 @@ class AgProtocol(pyee.EventEmitter):
read_buffer: bytearray
active_codec: AudioCodec
calls: List[CallInfo]
indicator_report_enabled: bool
inband_ringtone_enabled: bool
cme_error_enabled: bool
cli_notification_enabled: bool
call_waiting_enabled: bool
_remained_slc_setup_features: Set[HfFeature]
def __init__(self, dlc: rfcomm.DLC, configuration: AgConfiguration) -> None:
@@ -1079,6 +1206,7 @@ class AgProtocol(pyee.EventEmitter):
self.dlc = dlc
self.read_buffer = bytearray()
self.active_codec = AudioCodec.CVSD
self.calls = []
# Build local features.
self.supported_ag_features = sum(configuration.supported_ag_features)
@@ -1095,6 +1223,8 @@ class AgProtocol(pyee.EventEmitter):
self.supported_audio_codecs = []
self.indicator_report_enabled = False
self.cme_error_enabled = False
self.cli_notification_enabled = False
self.call_waiting_enabled = False
self.hf_indicators = collections.OrderedDict()
@@ -1168,6 +1298,21 @@ class AgProtocol(pyee.EventEmitter):
self.inband_ringtone_enabled = enabled
self.send_response(f'+BSIR: {1 if enabled else 0}')
def set_speaker_volume(self, level: int) -> None:
"""Reports speaker volume."""
self.send_response(f'+VGS: {level}')
def set_microphone_volume(self, level: int) -> None:
"""Reports microphone volume."""
self.send_response(f'+VGM: {level}')
def send_ring(self) -> None:
"""Sends RING command to trigger ringtone on HF."""
self.send_response('RING')
def update_ag_indicator(self, indicator: AgIndicator, value: int) -> None:
"""Updates AG indicator.
@@ -1212,6 +1357,14 @@ class AgProtocol(pyee.EventEmitter):
if (new_codec := await at_bcs_future) != codec:
raise HfpProtocolError(f'Expect codec: {codec}, but get {new_codec}')
def send_cli_notification(self, cli: CallLineIdentification) -> None:
"""Sends +CLIP CLI notification."""
if not self.cli_notification_enabled:
logger.warning('Try to send CLIP while CLI notification is not enabled')
self.send_response(f'+CLIP: {cli.to_clip_string()}')
def _check_remained_slc_commands(self) -> None:
if not self._remained_slc_setup_features:
self.emit('slc_complete')
@@ -1240,6 +1393,54 @@ class AgProtocol(pyee.EventEmitter):
self.send_ok()
self.emit('codec_negotiation', self.active_codec)
def _on_bvra(self, vrec: bytes) -> None:
self.send_ok()
self.emit('voice_recognition', VoiceRecognitionState(int(vrec)))
def _on_chld(self, operation_code: bytes) -> None:
call_index: Optional[int] = None
if len(operation_code) > 1:
call_index = int(operation_code[1:])
operation_code = operation_code[:1] + b'x'
try:
operation = CallHoldOperation(operation_code.decode())
except:
logger.error(f'Invalid operation: {operation_code.decode()}')
self.send_cme_error(CmeError.OPERATION_NOT_SUPPORTED)
return
if operation not in self.supported_ag_call_hold_operations:
logger.error(f'Unsupported operation: {operation_code.decode()}')
self.send_cme_error(CmeError.OPERATION_NOT_SUPPORTED)
if call_index is not None and not any(
call.index == call_index for call in self.calls
):
logger.error(f'No matching call {call_index}')
self.send_cme_error(CmeError.INVALID_INDEX)
# Real three-way calls have more complicated situations, but this is not a popular issue - let users to handle the remaining :)
self.send_ok()
self.emit('call_hold', operation, call_index)
def _on_chld_test(self) -> None:
if not self.supports_ag_feature(AgFeature.THREE_WAY_CALLING):
self.send_error()
return
self.send_response(
'+CHLD: ({})'.format(
','.join(
operation.value
for operation in self.supported_ag_call_hold_operations
)
)
)
self.send_ok()
self._remained_slc_setup_features.remove(HfFeature.THREE_WAY_CALLING)
self._check_remained_slc_commands()
def _on_cind_test(self) -> None:
if not self.ag_indicators:
self.send_cme_error(CmeError.NOT_FOUND)
@@ -1271,7 +1472,12 @@ class AgProtocol(pyee.EventEmitter):
display: Optional[bytes] = None,
indicator: bytes = b'',
) -> None:
if int(mode) != 3 or keypad or display or int(indicator) not in (0, 1):
if (
int(mode) != 3
or (keypad and int(keypad))
or (display and int(display))
or int(indicator) not in (0, 1)
):
logger.error(
f'Unexpected values: mode={mode!r}, keypad={keypad!r}, '
f'display={display!r}, indicator={indicator!r}'
@@ -1285,6 +1491,10 @@ class AgProtocol(pyee.EventEmitter):
self.cme_error_enabled = bool(int(enabled))
self.send_ok()
def _on_ccwa(self, enabled: bytes) -> None:
self.call_waiting_enabled = bool(int(enabled))
self.send_ok()
def _on_bind(self, *args) -> None:
if not self.supports_ag_feature(AgFeature.HF_INDICATORS):
self.send_error()
@@ -1364,6 +1574,36 @@ class AgProtocol(pyee.EventEmitter):
self.emit('hang_up')
self.send_ok()
def _on_clcc(self) -> None:
for call in self.calls:
number_text = f',\"{call.number}\"' if call.number is not None else ''
type_text = f',{call.type}' if call.type is not None else ''
response = (
f'+CLCC: {call.index}'
f',{call.direction.value}'
f',{call.status.value}'
f',{call.mode.value}'
f',{call.multi_party.value}'
f'{number_text}'
f'{type_text}'
)
self.send_response(response)
self.send_ok()
def _on_clip(self, enabled: bytes) -> None:
if not self.supports_hf_feature(HfFeature.CLI_PRESENTATION_CAPABILITY):
logger.error('Remote doesn not support CLI but sends AT+CLIP')
self.cli_notification_enabled = True if enabled == b'1' else False
self.send_ok()
def _on_vgs(self, level: bytes) -> None:
self.emit('speaker_volume', int(level))
self.send_ok()
def _on_vgm(self, level: bytes) -> None:
self.emit('microphone_volume', int(level))
self.send_ok()
# -----------------------------------------------------------------------------
# Normative SDP definitions
@@ -1546,7 +1786,7 @@ def make_ag_sdp_records(
sdp.SDP_SERVICE_CLASS_ID_LIST_ATTRIBUTE_ID,
sdp.DataElement.sequence(
[
sdp.DataElement.uuid(BT_HEADSET_AUDIO_GATEWAY_SERVICE),
sdp.DataElement.uuid(BT_HANDSFREE_AUDIO_GATEWAY_SERVICE),
sdp.DataElement.uuid(BT_GENERIC_AUDIO_SERVICE),
]
),
@@ -1573,7 +1813,7 @@ def make_ag_sdp_records(
[
sdp.DataElement.sequence(
[
sdp.DataElement.uuid(BT_HEADSET_AUDIO_GATEWAY_SERVICE),
sdp.DataElement.uuid(BT_HANDSFREE_AUDIO_GATEWAY_SERVICE),
sdp.DataElement.unsigned_integer_16(version),
]
)
@@ -1596,7 +1836,7 @@ async def find_hf_sdp_record(
connection: ACL connection to make SDP search.
Returns:
Dictionary mapping from channel number to service class UUID list.
Tuple of (<RFCOMM channel>, <Profile Version>, <HF SDP features>)
"""
async with sdp.Client(connection) as sdp_client:
search_result = await sdp_client.search_attributes(
@@ -1605,6 +1845,7 @@ async def find_hf_sdp_record(
sdp.SDP_PROTOCOL_DESCRIPTOR_LIST_ATTRIBUTE_ID,
sdp.SDP_BLUETOOTH_PROFILE_DESCRIPTOR_LIST_ATTRIBUTE_ID,
sdp.SDP_SUPPORTED_FEATURES_ATTRIBUTE_ID,
sdp.SDP_SERVICE_CLASS_ID_LIST_ATTRIBUTE_ID,
],
)
for attribute_lists in search_result:
@@ -1624,10 +1865,17 @@ async def find_hf_sdp_record(
version = ProfileVersion(profile_descriptor_list[0].value[1].value)
elif attribute.id == sdp.SDP_SUPPORTED_FEATURES_ATTRIBUTE_ID:
features = HfSdpFeature(attribute.value.value)
if not channel or not version or features is None:
logger.warning(f"Bad result {attribute_lists}.")
return None
return (channel, version, features)
elif attribute.id == sdp.SDP_SERVICE_CLASS_ID_LIST_ATTRIBUTE_ID:
class_id_list = attribute.value.value
uuid = class_id_list[0].value
# AG record may also contain HF UUID in its profile descriptor list.
# If found, skip this record.
if uuid == BT_HANDSFREE_AUDIO_GATEWAY_SERVICE:
channel, version, features = (None, None, None)
break
if channel is not None and version is not None and features is not None:
return (channel, version, features)
return None
@@ -1640,11 +1888,11 @@ async def find_ag_sdp_record(
connection: ACL connection to make SDP search.
Returns:
Dictionary mapping from channel number to service class UUID list.
Tuple of (<RFCOMM channel>, <Profile Version>, <AG SDP features>)
"""
async with sdp.Client(connection) as sdp_client:
search_result = await sdp_client.search_attributes(
uuids=[BT_HEADSET_AUDIO_GATEWAY_SERVICE],
uuids=[BT_HANDSFREE_AUDIO_GATEWAY_SERVICE],
attribute_ids=[
sdp.SDP_PROTOCOL_DESCRIPTOR_LIST_ATTRIBUTE_ID,
sdp.SDP_BLUETOOTH_PROFILE_DESCRIPTOR_LIST_ATTRIBUTE_ID,

View File

@@ -530,7 +530,9 @@ class Host(AbortableEventEmitter):
# Check the return parameters if required
if check_result:
if isinstance(response.return_parameters, int):
if isinstance(response, hci.HCI_Command_Status_Event):
status = response.status
elif isinstance(response.return_parameters, int):
status = response.return_parameters
elif isinstance(response.return_parameters, bytes):
# return parameters first field is a one byte status code

View File

@@ -19,6 +19,7 @@ from __future__ import annotations
import logging
import asyncio
import collections
import dataclasses
import enum
from typing import Callable, Dict, List, Optional, Tuple, Union, TYPE_CHECKING
@@ -54,6 +55,7 @@ logger = logging.getLogger(__name__)
# fmt: off
RFCOMM_PSM = 0x0003
DEFAULT_RX_QUEUE_SIZE = 32
class FrameType(enum.IntEnum):
SABM = 0x2F # Control field [1,1,1,1,_,1,0,0] LSB-first
@@ -445,7 +447,8 @@ class DLC(EventEmitter):
RESET = 0x05
connection_result: Optional[asyncio.Future]
sink: Optional[Callable[[bytes], None]]
_sink: Optional[Callable[[bytes], None]]
_enqueued_rx_packets: collections.deque[bytes]
def __init__(
self,
@@ -466,10 +469,12 @@ class DLC(EventEmitter):
self.state = DLC.State.INIT
self.role = multiplexer.role
self.c_r = 1 if self.role == Multiplexer.Role.INITIATOR else 0
self.sink = None
self.connection_result = None
self.drained = asyncio.Event()
self.drained.set()
# Queued packets when sink is not set.
self._enqueued_rx_packets = collections.deque(maxlen=DEFAULT_RX_QUEUE_SIZE)
self._sink = None
# Compute the MTU
max_overhead = 4 + 1 # header with 2-byte length + fcs
@@ -477,6 +482,19 @@ class DLC(EventEmitter):
max_frame_size, self.multiplexer.l2cap_channel.peer_mtu - max_overhead
)
@property
def sink(self) -> Optional[Callable[[bytes], None]]:
return self._sink
@sink.setter
def sink(self, sink: Optional[Callable[[bytes], None]]) -> None:
self._sink = sink
# Dump queued packets to sink
if sink:
for packet in self._enqueued_rx_packets:
sink(packet) # pylint: disable=not-callable
self._enqueued_rx_packets.clear()
def change_state(self, new_state: State) -> None:
logger.debug(f'{self} state change -> {color(new_state.name, "magenta")}')
self.state = new_state
@@ -549,8 +567,15 @@ class DLC(EventEmitter):
f'rx_credits={self.rx_credits}: {data.hex()}'
)
if data:
if self.sink:
self.sink(data) # pylint: disable=not-callable
if self._sink:
self._sink(data) # pylint: disable=not-callable
else:
self._enqueued_rx_packets.append(data)
if (
self._enqueued_rx_packets.maxlen
and len(self._enqueued_rx_packets) >= self._enqueued_rx_packets.maxlen
):
logger.warning(f'DLC [{self.dlci}] received packet queue is full')
# Update the credits
if self.rx_credits > 0:

350
examples/hfp_gateway.html Normal file
View File

@@ -0,0 +1,350 @@
<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">
<script src="https://unpkg.com/pcm-player"></script>
</head>
<body>
<nav class="navbar navbar-dark bg-primary">
<div class="container">
<span class="navbar-brand mb-0 h1">Bumble HFP Audio Gateway</span>
</div>
</nav>
<br>
<div class="container">
<label class="form-label">Send AT Response</label>
<div class="input-group mb-3">
<input type="text" class="form-control" placeholder="AT Response" aria-label="AT response" id="at_response">
<button class="btn btn-primary" type="button"
onclick="send_at_response(document.getElementById('at_response').value)">Send</button>
</div>
<div class="row">
<div class="col-3">
<label class="form-label">Speaker Volume</label>
<div class="input-group mb-3 col-auto">
<input type="text" class="form-control" placeholder="0 - 15" aria-label="Speaker Volume"
id="speaker_volume">
<button class="btn btn-primary" type="button"
onclick="send_at_response(`+VGS: ${document.getElementById('speaker_volume').value}`)">Set</button>
</div>
</div>
<div class="col-3">
<label class="form-label">Mic Volume</label>
<div class="input-group mb-3 col-auto">
<input type="text" class="form-control" placeholder="0 - 15" aria-label="Mic Volume"
id="mic_volume">
<button class="btn btn-primary" type="button"
onclick="send_at_response(`+VGM: ${document.getElementById('mic_volume').value}`)">Set</button>
</div>
</div>
<div class="col-3">
<label class="form-label">Browser Gain</label>
<input type="range" class="form-range" id="browser-gain" min="0" max="2" value="1" step="0.1" onchange="setGain()">
</div>
</div>
<div class="row">
<div class="col-auto">
<div class="input-group mb-3">
<span class="input-group-text">Codec</span>
<select class="form-select" id="codec">
<option selected value="1">CVSD</option>
<option value="2">MSBC</option>
</select>
</div>
</div>
<div class="col-auto">
<button class="btn btn-primary" onclick="negotiate_codec()">Negotiate Codec</button>
</div>
<div class="col-auto">
<button class="btn btn-primary" onclick="connect_sco()">Connect SCO</button>
</div>
<div class="col-auto">
<button class="btn btn-primary" onclick="disconnect_sco()">Disconnect SCO</button>
</div>
<div class="col-auto">
<button class="btn btn-danger" onclick="connectAudio()">Connect Audio</button>
</div>
</div>
<hr>
<div class="row">
<h4>AG Indicators</h2>
<div class="col-3">
<label class="form-label">call</label>
<div class="input-group mb-3 col-auto">
<select class="form-select" id="call">
<option selected value="0">Inactive</option>
<option value="1">Active</option>
</select>
<button class="btn btn-primary" type="button" onclick="update_ag_indicator('call')">Set</button>
</div>
</div>
<div class="col-3">
<label class="form-label">callsetup</label>
<div class="input-group mb-3 col-auto">
<select class="form-select" id="callsetup">
<option selected value="0">Idle</option>
<option value="1">Incoming</option>
<option value="2">Outgoing</option>
<option value="3">Remote Alerted</option>
</select>
<button class="btn btn-primary" type="button"
onclick="update_ag_indicator('callsetup')">Set</button>
</div>
</div>
<div class="col-3">
<label class="form-label">callheld</label>
<div class="input-group mb-3 col-auto">
<select class="form-select" id="callsetup">
<option selected value="0">0</option>
<option value="1">1</option>
<option value="2">2</option>
</select>
<button class="btn btn-primary" type="button"
onclick="update_ag_indicator('callheld')">Set</button>
</div>
</div>
<div class="col-3">
<label class="form-label">signal</label>
<div class="input-group mb-3 col-auto">
<select class="form-select" id="signal">
<option selected value="0">0</option>
<option value="1">1</option>
<option value="2">2</option>
<option value="3">3</option>
<option value="4">4</option>
<option value="5">5</option>
</select>
<button class="btn btn-primary" type="button"
onclick="update_ag_indicator('signal')">Set</button>
</div>
</div>
<div class="col-3">
<label class="form-label">roam</label>
<div class="input-group mb-3 col-auto">
<select class="form-select" id="roam">
<option selected value="0">0</option>
<option value="1">1</option>
</select>
<button class="btn btn-primary" type="button" onclick="update_ag_indicator('roam')">Set</button>
</div>
</div>
<div class="col-3">
<label class="form-label">battchg</label>
<div class="input-group mb-3 col-auto">
<select class="form-select" id="battchg">
<option selected value="0">0</option>
<option value="1">1</option>
<option value="2">2</option>
<option value="3">3</option>
<option value="4">4</option>
<option value="5">5</option>
</select>
<button class="btn btn-primary" type="button"
onclick="update_ag_indicator('battchg')">Set</button>
</div>
</div>
<div class="col-3">
<label class="form-label">service</label>
<div class="input-group mb-3 col-auto">
<select class="form-select" id="service">
<option selected value="0">0</option>
<option value="1">1</option>
</select>
<button class="btn btn-primary" type="button"
onclick="update_ag_indicator('service')">Set</button>
</div>
</div>
</div>
<hr>
<button class="btn btn-primary" onclick="send_at_response('+BVRA: 1')">Start Voice Assistant</button>
<button class="btn btn-primary" onclick="send_at_response('+BVRA: 0')">Stop Voice Assistant</button>
<hr>
<h4>Calls</h4>
<div id="call-lists">
<template id="call-template">
<div class="row call-row">
<div class="input-group mb-3">
<label class="input-group-text">Index</label>
<input class="form-control call-index" value="1">
<label class="input-group-text">Number</label>
<input class="form-control call-number">
<label class="input-group-text">Direction</label>
<select class="form-select call-direction">
<option selected value="0">Originated</option>
<option value="1">Terminated</option>
</select>
<label class="input-group-text">Status</label>
<select class="form-select call-status">
<option value="0">ACTIVE</option>
<option value="1">HELD</option>
<option value="2">DIALING</option>
<option value="3">ALERTING</option>
<option value="4">INCOMING</option>
<option value="5">WAITING</option>
</select>
<button class="btn btn-primary call-remover"></button>
</div>
</div>
</template>
</div>
<button class="btn btn-primary" onclick="add_call()"> Add Call</button>
<button class="btn btn-primary" onclick="update_calls()">🗘 Update Calls</button>
<hr>
<div id="socketStateContainer" class="bg-body-tertiary p-3 rounded-2">
<h3>Log</h3>
<code id="log" style="white-space: pre-line;"></code>
</div>
</div>
<script>
let atResponseInput = document.getElementById("at_response")
let gainInput = document.getElementById('browser-gain')
let log = document.getElementById("log")
let socket = new WebSocket('ws://localhost:8888');
let sampleRate = 0;
let player;
socket.binaryType = "arraybuffer";
socket.onopen = _ => {
log.textContent += 'SOCKET OPEN\n'
}
socket.onclose = _ => {
log.textContent += 'SOCKET CLOSED\n'
}
socket.onerror = (error) => {
log.textContent += 'SOCKET ERROR\n'
console.log(`ERROR: ${error}`)
}
socket.onmessage = function (message) {
if (typeof message.data === 'string' || message.data instanceof String) {
log.textContent += `<-- ${event.data}\n`
const jsonMessage = JSON.parse(event.data)
if (jsonMessage.type == 'speaker_volume') {
document.getElementById('speaker_volume').value = jsonMessage.level;
} else if (jsonMessage.type == 'microphone_volume') {
document.getElementById('microphone_volume').value = jsonMessage.level;
} else if (jsonMessage.type == 'sco_state_change') {
sampleRate = jsonMessage.sample_rate;
console.log(sampleRate);
if (player != null) {
player = new PCMPlayer({
inputCodec: 'Int16',
channels: 1,
sampleRate: sampleRate,
flushTime: 7.5,
});
player.volume(gainInput.value);
}
}
} else {
// BINARY audio data.
if (player == null) return;
player.feed(message.data);
}
};
function send(message) {
if (socket && socket.readyState == WebSocket.OPEN) {
let jsonMessage = JSON.stringify(message)
log.textContent += `--> ${jsonMessage}\n`
socket.send(jsonMessage)
} else {
log.textContent += 'NOT CONNECTED\n'
}
}
function send_at_response(response) {
send({ type: 'at_response', response: response })
}
function update_ag_indicator(indicator) {
const value = document.getElementById(indicator).value
send({ type: 'ag_indicator', indicator: indicator, value: value })
}
function connect_sco() {
send({ type: 'connect_sco' })
}
function negotiate_codec() {
const codec = document.getElementById('codec').value
send({ type: 'negotiate_codec', codec: codec })
}
function disconnect_sco() {
send({ type: 'disconnect_sco' })
}
function add_call() {
let callLists = document.getElementById('call-lists');
let template = document.getElementById('call-template');
let newNode = document.importNode(template.content, true);
newNode.querySelector('.call-remover').onclick = function (event) {
event.target.closest('.call-row').remove();
}
callLists.appendChild(newNode);
}
function update_calls() {
let callLists = document.getElementById('call-lists');
send({
type: 'update_calls',
calls: Array.from(
callLists.querySelectorAll('.call-row')).map(
function (element) {
return {
index: element.querySelector('.call-index').value,
number: element.querySelector('.call-number').value,
direction: element.querySelector('.call-direction').value,
status: element.querySelector('.call-status').value,
}
}
),
}
)
}
function connectAudio() {
player = new PCMPlayer({
inputCodec: 'Int16',
channels: 1,
sampleRate: sampleRate,
flushTime: 7.5,
});
player.volume(gainInput.value);
}
function setGain() {
if (player != null) {
player.volume(gainInput.value);
}
}
</script>
</div>
</body>
</html>

View File

@@ -1,4 +1,5 @@
{
"name": "Bumble Phone",
"class_of_device": 6291980
"class_of_device": 6291980,
"keystore": "JsonKeyStore"
}

View File

@@ -16,9 +16,14 @@
# Imports
# -----------------------------------------------------------------------------
import asyncio
import json
import sys
import os
import io
import logging
import websockets
from typing import Optional
import bumble.core
from bumble.device import Device
@@ -26,12 +31,15 @@ from bumble.transport import open_transport_or_link
from bumble.core import (
BT_BR_EDR_TRANSPORT,
)
from bumble import rfcomm, hfp
from bumble.hci import HCI_SynchronousDataPacket
from bumble import hci, rfcomm, hfp
logger = logging.getLogger(__name__)
ws: Optional[websockets.WebSocketServerProtocol] = None
ag_protocol: Optional[hfp.AgProtocol] = None
source_file: Optional[io.BufferedReader] = None
def _default_configuration() -> hfp.AgConfiguration:
return hfp.AgConfiguration(
@@ -41,12 +49,13 @@ def _default_configuration() -> hfp.AgConfiguration:
hfp.AgFeature.REJECT_CALL,
hfp.AgFeature.CODEC_NEGOTIATION,
hfp.AgFeature.ESCO_S4_SETTINGS_SUPPORTED,
hfp.AgFeature.ENHANCED_CALL_STATUS,
],
supported_ag_indicators=[
hfp.AgIndicatorState.call(),
hfp.AgIndicatorState.callsetup(),
hfp.AgIndicatorState.callheld(),
hfp.AgIndicatorState.service(),
hfp.AgIndicatorState.callsetup(),
hfp.AgIndicatorState.callsetup(),
hfp.AgIndicatorState.signal(),
hfp.AgIndicatorState.roam(),
hfp.AgIndicatorState.battchg(),
@@ -60,17 +69,123 @@ def _default_configuration() -> hfp.AgConfiguration:
)
def send_message(type: str, **kwargs) -> None:
if ws:
asyncio.create_task(ws.send(json.dumps({'type': type, **kwargs})))
def on_speaker_volume(level: int):
send_message(type='speaker_volume', level=level)
def on_microphone_volume(level: int):
send_message(type='microphone_volume', level=level)
def on_sco_state_change(codec: int):
if codec == hfp.AudioCodec.CVSD:
sample_rate = 8000
elif codec == hfp.AudioCodec.MSBC:
sample_rate = 16000
else:
sample_rate = 0
send_message(type='sco_state_change', sample_rate=sample_rate)
def on_sco_packet(packet: hci.HCI_SynchronousDataPacket):
if ws:
asyncio.create_task(ws.send(packet.data))
if source_file and (pcm_data := source_file.read(packet.data_total_length)):
assert ag_protocol
host = ag_protocol.dlc.multiplexer.l2cap_channel.connection.device.host
host.send_hci_packet(
hci.HCI_SynchronousDataPacket(
connection_handle=packet.connection_handle,
packet_status=0,
data_total_length=len(pcm_data),
data=pcm_data,
)
)
def on_hfp_state_change(connected: bool):
send_message(type='hfp_state_change', connected=connected)
async def ws_server(ws_client: websockets.WebSocketServerProtocol, path: str):
del path
global ws
ws = ws_client
async for message in ws_client:
if not ag_protocol:
continue
json_message = json.loads(message)
message_type = json_message['type']
connection = ag_protocol.dlc.multiplexer.l2cap_channel.connection
device = connection.device
try:
if message_type == 'at_response':
ag_protocol.send_response(json_message['response'])
elif message_type == 'ag_indicator':
ag_protocol.update_ag_indicator(
hfp.AgIndicator(json_message['indicator']),
int(json_message['value']),
)
elif message_type == 'negotiate_codec':
codec = hfp.AudioCodec(int(json_message['codec']))
await ag_protocol.negotiate_codec(codec)
elif message_type == 'connect_sco':
if ag_protocol.active_codec == hfp.AudioCodec.CVSD:
esco_param = hfp.ESCO_PARAMETERS[
hfp.DefaultCodecParameters.ESCO_CVSD_S4
]
elif ag_protocol.active_codec == hfp.AudioCodec.MSBC:
esco_param = hfp.ESCO_PARAMETERS[
hfp.DefaultCodecParameters.ESCO_MSBC_T2
]
else:
raise ValueError(f'Unsupported codec {codec}')
await device.send_command(
hci.HCI_Enhanced_Setup_Synchronous_Connection_Command(
connection_handle=connection.handle, **esco_param.asdict()
)
)
elif message_type == 'disconnect_sco':
# Copy the values to avoid iteration error.
for sco_link in list(device.sco_links.values()):
await sco_link.disconnect()
elif message_type == 'update_calls':
ag_protocol.calls = [
hfp.CallInfo(
index=int(call['index']),
direction=hfp.CallInfoDirection(int(call['direction'])),
status=hfp.CallInfoStatus(int(call['status'])),
number=call['number'],
multi_party=hfp.CallInfoMultiParty.NOT_IN_CONFERENCE,
mode=hfp.CallInfoMode.VOICE,
)
for call in json_message['calls']
]
except Exception as e:
send_message(type='error', message=e)
# -----------------------------------------------------------------------------
async def main() -> None:
if len(sys.argv) < 4:
if len(sys.argv) < 3:
print(
'Usage: run_hfp_gateway.py <device-config> <transport-spec> '
'<bluetooth-address>'
'[bluetooth-address] [wav-file-for-source]'
)
print(
' specifying a channel number, or "discover" to list all RFCOMM channels'
'example: run_hfp_gateway.py hfp_gateway.json usb:0 E1:CA:72:48:C4:E8 sample.wav'
)
print('example: run_hfp_gateway.py hfp_gateway.json usb:0 E1:CA:72:48:C4:E8')
return
print('<<< connecting to HCI...')
@@ -84,56 +199,85 @@ async def main() -> None:
device.classic_enabled = True
await device.power_on()
# Connect to a peer
target_address = sys.argv[3]
print(f'=== Connecting to {target_address}...')
connection = await device.connect(target_address, transport=BT_BR_EDR_TRANSPORT)
print(f'=== Connected to {connection.peer_address}!')
rfcomm_server = rfcomm.Server(device)
configuration = _default_configuration()
# Get a list of all the Handsfree services (should only be 1)
if not (hfp_record := await hfp.find_hf_sdp_record(connection)):
print('!!! no service found')
return
def on_dlc(dlc: rfcomm.DLC):
global ag_protocol
ag_protocol = hfp.AgProtocol(dlc, configuration)
ag_protocol.on('speaker_volume', on_speaker_volume)
ag_protocol.on('microphone_volume', on_microphone_volume)
on_hfp_state_change(True)
dlc.multiplexer.l2cap_channel.on(
'close', lambda: on_hfp_state_change(False)
)
# Pick the first one
channel, version, hf_sdp_features = hfp_record
print(f'HF version: {version}')
print(f'HF features: {hf_sdp_features}')
channel = rfcomm_server.listen(on_dlc)
device.sdp_service_records = {
1: hfp.make_ag_sdp_records(1, channel, configuration)
}
# Request authentication
print('*** Authenticating...')
await connection.authenticate()
print('*** Authenticated')
def on_sco_connection(sco_link):
assert ag_protocol
on_sco_state_change(ag_protocol.active_codec)
sco_link.on('disconnection', lambda _: on_sco_state_change(0))
sco_link.on('pdu', on_sco_packet)
# Enable encryption
print('*** Enabling encryption...')
await connection.encrypt()
print('*** Encryption on')
device.on('sco_connection', on_sco_connection)
if len(sys.argv) >= 4:
# Connect to a peer
target_address = sys.argv[3]
print(f'=== Connecting to {target_address}...')
connection = await device.connect(
target_address, transport=BT_BR_EDR_TRANSPORT
)
print(f'=== Connected to {connection.peer_address}!')
# Create a client and start it
print('@@@ Starting to RFCOMM client...')
rfcomm_client = rfcomm.Client(connection)
rfcomm_mux = await rfcomm_client.start()
print('@@@ Started')
# Get a list of all the Handsfree services (should only be 1)
if not (hfp_record := await hfp.find_hf_sdp_record(connection)):
print('!!! no service found')
return
print(f'### Opening session for channel {channel}...')
try:
session = await rfcomm_mux.open_dlc(channel)
print('### Session open', session)
except bumble.core.ConnectionError as error:
print(f'### Session open failed: {error}')
await rfcomm_mux.disconnect()
print('@@@ Disconnected from RFCOMM server')
return
# Pick the first one
channel, version, hf_sdp_features = hfp_record
print(f'HF version: {version}')
print(f'HF features: {hf_sdp_features}')
def on_sco(connection_handle: int, packet: HCI_SynchronousDataPacket):
# Reset packet and loopback
packet.packet_status = 0
device.host.send_hci_packet(packet)
# Request authentication
print('*** Authenticating...')
await connection.authenticate()
print('*** Authenticated')
device.host.on('sco_packet', on_sco)
# Enable encryption
print('*** Enabling encryption...')
await connection.encrypt()
print('*** Encryption on')
ag_protocol = hfp.AgProtocol(session, _default_configuration())
# Create a client and start it
print('@@@ Starting to RFCOMM client...')
rfcomm_client = rfcomm.Client(connection)
rfcomm_mux = await rfcomm_client.start()
print('@@@ Started')
print(f'### Opening session for channel {channel}...')
try:
session = await rfcomm_mux.open_dlc(channel)
print('### Session open', session)
except bumble.core.ConnectionError as error:
print(f'### Session open failed: {error}')
await rfcomm_mux.disconnect()
print('@@@ Disconnected from RFCOMM server')
return
on_dlc(session)
await websockets.serve(ws_server, port=8888)
if len(sys.argv) >= 5:
global source_file
source_file = open(sys.argv[4], 'rb')
# Skip header
source_file.seek(44)
await hci_transport.source.terminated

View File

@@ -43,6 +43,9 @@ def _default_hf_configuration() -> hfp.HfConfiguration:
hfp.HfFeature.CODEC_NEGOTIATION,
hfp.HfFeature.ESCO_S4_SETTINGS_SUPPORTED,
hfp.HfFeature.HF_INDICATORS,
hfp.HfFeature.ENHANCED_CALL_STATUS,
hfp.HfFeature.THREE_WAY_CALLING,
hfp.HfFeature.CLI_PRESENTATION_CAPABILITY,
],
supported_hf_indicators=[
hfp.HfIndicator.ENHANCED_SAFETY,
@@ -57,7 +60,11 @@ def _default_hf_configuration() -> hfp.HfConfiguration:
# -----------------------------------------------------------------------------
def _default_hf_sdp_features() -> hfp.HfSdpFeature:
return hfp.HfSdpFeature.WIDE_BAND
return (
hfp.HfSdpFeature.WIDE_BAND
| hfp.HfSdpFeature.THREE_WAY_CALLING
| hfp.HfSdpFeature.CLI_PRESENTATION_CAPABILITY
)
# -----------------------------------------------------------------------------
@@ -69,6 +76,8 @@ def _default_ag_configuration() -> hfp.AgConfiguration:
hfp.AgFeature.REJECT_CALL,
hfp.AgFeature.CODEC_NEGOTIATION,
hfp.AgFeature.ESCO_S4_SETTINGS_SUPPORTED,
hfp.AgFeature.ENHANCED_CALL_STATUS,
hfp.AgFeature.THREE_WAY_CALLING,
],
supported_ag_indicators=[
hfp.AgIndicatorState.call(),
@@ -83,14 +92,26 @@ def _default_ag_configuration() -> hfp.AgConfiguration:
hfp.HfIndicator.ENHANCED_SAFETY,
hfp.HfIndicator.BATTERY_LEVEL,
],
supported_ag_call_hold_operations=[],
supported_ag_call_hold_operations=[
hfp.CallHoldOperation.ADD_HELD_CALL,
hfp.CallHoldOperation.HOLD_ALL_ACTIVE_CALLS,
hfp.CallHoldOperation.HOLD_ALL_CALLS_EXCEPT,
hfp.CallHoldOperation.RELEASE_ALL_ACTIVE_CALLS,
hfp.CallHoldOperation.RELEASE_ALL_HELD_CALLS,
hfp.CallHoldOperation.RELEASE_SPECIFIC_CALL,
hfp.CallHoldOperation.CONNECT_TWO_CALLS,
],
supported_audio_codecs=[hfp.AudioCodec.CVSD, hfp.AudioCodec.MSBC],
)
# -----------------------------------------------------------------------------
def _default_ag_sdp_features() -> hfp.AgSdpFeature:
return hfp.AgSdpFeature.WIDE_BAND | hfp.AgSdpFeature.IN_BAND_RING_TONE_CAPABILITY
return (
hfp.AgSdpFeature.WIDE_BAND
| hfp.AgSdpFeature.IN_BAND_RING_TONE_CAPABILITY
| hfp.AgSdpFeature.THREE_WAY_CALLING
)
# -----------------------------------------------------------------------------
@@ -165,6 +186,7 @@ async def test_slc_with_minimal_features():
assert hf.supported_ag_features == ag.supported_ag_features
assert hf.supported_hf_features == ag.supported_hf_features
assert hf.supported_ag_call_hold_operations == ag.supported_ag_call_hold_operations
for a, b in zip(hf.ag_indicators, ag.ag_indicators):
assert a.indicator == b.indicator
assert a.current_status == b.current_status
@@ -177,6 +199,7 @@ async def test_slc(hfp_connections: Tuple[hfp.HfProtocol, hfp.AgProtocol]):
assert hf.supported_ag_features == ag.supported_ag_features
assert hf.supported_hf_features == ag.supported_hf_features
assert hf.supported_ag_call_hold_operations == ag.supported_ag_call_hold_operations
for a, b in zip(hf.ag_indicators, ag.ag_indicators):
assert a.indicator == b.indicator
assert a.current_status == b.current_status
@@ -281,6 +304,175 @@ async def test_terminate_call(hfp_connections: Tuple[hfp.HfProtocol, hfp.AgProto
await future
# -----------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_query_calls_without_calls(
hfp_connections: Tuple[hfp.HfProtocol, hfp.AgProtocol]
):
hf, ag = hfp_connections
assert await hf.query_current_calls() == []
# -----------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_query_calls_with_calls(
hfp_connections: Tuple[hfp.HfProtocol, hfp.AgProtocol]
):
hf, ag = hfp_connections
ag.calls.append(
hfp.CallInfo(
index=1,
direction=hfp.CallInfoDirection.MOBILE_ORIGINATED_CALL,
status=hfp.CallInfoStatus.ACTIVE,
mode=hfp.CallInfoMode.VOICE,
multi_party=hfp.CallInfoMultiParty.NOT_IN_CONFERENCE,
number='123456789',
)
)
assert await hf.query_current_calls() == ag.calls
# -----------------------------------------------------------------------------
@pytest.mark.asyncio
@pytest.mark.parametrize(
"operation,",
(
hfp.CallHoldOperation.RELEASE_ALL_HELD_CALLS,
hfp.CallHoldOperation.RELEASE_ALL_ACTIVE_CALLS,
hfp.CallHoldOperation.HOLD_ALL_ACTIVE_CALLS,
hfp.CallHoldOperation.ADD_HELD_CALL,
hfp.CallHoldOperation.CONNECT_TWO_CALLS,
),
)
async def test_hold_call_without_call_index(
hfp_connections: Tuple[hfp.HfProtocol, hfp.AgProtocol],
operation: hfp.CallHoldOperation,
):
hf, ag = hfp_connections
call_hold_future = asyncio.get_running_loop().create_future()
ag.on("call_hold", lambda op, index: call_hold_future.set_result((op, index)))
await hf.execute_command(f"AT+CHLD={operation.value}")
assert (await call_hold_future) == (operation, None)
# -----------------------------------------------------------------------------
@pytest.mark.asyncio
@pytest.mark.parametrize(
"operation,",
(
hfp.CallHoldOperation.RELEASE_SPECIFIC_CALL,
hfp.CallHoldOperation.HOLD_ALL_CALLS_EXCEPT,
),
)
async def test_hold_call_with_call_index(
hfp_connections: Tuple[hfp.HfProtocol, hfp.AgProtocol],
operation: hfp.CallHoldOperation,
):
hf, ag = hfp_connections
call_hold_future = asyncio.get_running_loop().create_future()
ag.on("call_hold", lambda op, index: call_hold_future.set_result((op, index)))
ag.calls.append(
hfp.CallInfo(
index=1,
direction=hfp.CallInfoDirection.MOBILE_ORIGINATED_CALL,
status=hfp.CallInfoStatus.ACTIVE,
mode=hfp.CallInfoMode.VOICE,
multi_party=hfp.CallInfoMultiParty.NOT_IN_CONFERENCE,
number='123456789',
)
)
await hf.execute_command(f"AT+CHLD={operation.value.replace('x', '1')}")
assert (await call_hold_future) == (operation, 1)
# -----------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_ring(hfp_connections: Tuple[hfp.HfProtocol, hfp.AgProtocol]):
hf, ag = hfp_connections
ring_future = asyncio.get_running_loop().create_future()
hf.on("ring", lambda: ring_future.set_result(None))
ag.send_ring()
await ring_future
# -----------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_speaker_volume(hfp_connections: Tuple[hfp.HfProtocol, hfp.AgProtocol]):
hf, ag = hfp_connections
speaker_volume_future = asyncio.get_running_loop().create_future()
hf.on("speaker_volume", speaker_volume_future.set_result)
ag.set_speaker_volume(10)
assert await speaker_volume_future == 10
# -----------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_microphone_volume(
hfp_connections: Tuple[hfp.HfProtocol, hfp.AgProtocol]
):
hf, ag = hfp_connections
microphone_volume_future = asyncio.get_running_loop().create_future()
hf.on("microphone_volume", microphone_volume_future.set_result)
ag.set_microphone_volume(10)
assert await microphone_volume_future == 10
# -----------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_cli_notification(hfp_connections: Tuple[hfp.HfProtocol, hfp.AgProtocol]):
hf, ag = hfp_connections
cli_notification_future = asyncio.get_running_loop().create_future()
hf.on("cli_notification", cli_notification_future.set_result)
ag.send_cli_notification(
hfp.CallLineIdentification(number="\"123456789\"", type=129, alpha="\"Bumble\"")
)
assert await cli_notification_future == hfp.CallLineIdentification(
number="123456789", type=129, alpha="Bumble", subaddr="", satype=None
)
# -----------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_voice_recognition_from_hf(
hfp_connections: Tuple[hfp.HfProtocol, hfp.AgProtocol]
):
hf, ag = hfp_connections
voice_recognition_future = asyncio.get_running_loop().create_future()
ag.on("voice_recognition", voice_recognition_future.set_result)
await hf.execute_command("AT+BVRA=1")
assert await voice_recognition_future == hfp.VoiceRecognitionState.ENABLE
# -----------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_voice_recognition_from_ag(
hfp_connections: Tuple[hfp.HfProtocol, hfp.AgProtocol]
):
hf, ag = hfp_connections
voice_recognition_future = asyncio.get_running_loop().create_future()
hf.on("voice_recognition", voice_recognition_future.set_result)
ag.send_response("+BVRA: 1")
assert await voice_recognition_future == hfp.VoiceRecognitionState.ENABLE
# -----------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_hf_sdp_record():

View File

@@ -32,6 +32,8 @@ from bumble.rfcomm import (
RFCOMM_PSM,
)
_TIMEOUT = 0.1
# -----------------------------------------------------------------------------
def basic_frame_check(x):
@@ -82,6 +84,29 @@ async def test_basic_connection() -> None:
assert await queues[0].get() == b'Lorem ipsum dolor sit amet'
# -----------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_receive_pdu_before_open_dlc_returns() -> None:
devices = await test_utils.TwoDevices.create_with_connection()
DATA = b'123'
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()
open_dlc_task = asyncio.create_task(multiplexer.open_dlc(channel))
dlc_responder = await accept_future
dlc_responder.write(DATA)
dlc_initiator = await open_dlc_task
dlc_initiator_queue = asyncio.Queue() # type: ignore[var-annotated]
dlc_initiator.sink = dlc_initiator_queue.put_nowait
assert await asyncio.wait_for(dlc_initiator_queue.get(), timeout=_TIMEOUT) == DATA
# -----------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_service_record():

View File

@@ -16,7 +16,8 @@
# Imports
# -----------------------------------------------------------------------------
import asyncio
from typing import List, Optional
from typing import List, Optional, Type
from typing_extensions import Self
from bumble.controller import Controller
from bumble.link import LocalLink
@@ -81,6 +82,12 @@ class TwoDevices:
def __getitem__(self, index: int) -> Device:
return self.devices[index]
@classmethod
async def create_with_connection(cls: Type[Self]) -> Self:
devices = cls()
await devices.setup_connection()
return devices
# -----------------------------------------------------------------------------
async def async_barrier():

View File

@@ -24,6 +24,11 @@ class PacketSource {
}
class PacketSink {
constructor() {
this.queue = [];
this.isProcessing = false;
}
on_packet(packet) {
if (!this.writer) {
return;
@@ -31,11 +36,24 @@ class PacketSink {
const buffer = packet.toJs({create_proxies : false});
packet.destroy();
//console.log(`HCI[host->controller]: ${bufferToHex(buffer)}`);
// TODO: create an async queue here instead of blindly calling write without awaiting
this.writer(buffer);
this.queue.push(buffer);
this.processQueue();
}
async processQueue() {
if (this.isProcessing) {
return;
}
this.isProcessing = true;
while (this.queue.length > 0) {
const buffer = this.queue.shift();
await this.writer(buffer);
}
this.isProcessing = false;
}
}
class LogEvent extends Event {
constructor(message) {
super('log');
@@ -185,4 +203,4 @@ export async function setupSimpleApp(appUrl, bumbleControls, log) {
bumbleControls.onBumbleLoaded();
return app;
}
}