HFP: Add example and fix AG errors

This commit is contained in:
Josh Wu
2024-05-02 19:28:42 +08:00
parent 26e6650038
commit ccff32102f
4 changed files with 589 additions and 61 deletions

View File

@@ -50,7 +50,7 @@ from bumble.core import (
ProtocolError, ProtocolError,
BT_GENERIC_AUDIO_SERVICE, BT_GENERIC_AUDIO_SERVICE,
BT_HANDSFREE_SERVICE, BT_HANDSFREE_SERVICE,
BT_HEADSET_AUDIO_GATEWAY_SERVICE, BT_HANDSFREE_AUDIO_GATEWAY_SERVICE,
BT_L2CAP_PROTOCOL_ID, BT_L2CAP_PROTOCOL_ID,
BT_RFCOMM_PROTOCOL_ID, BT_RFCOMM_PROTOCOL_ID,
) )
@@ -1156,7 +1156,7 @@ class AgProtocol(pyee.EventEmitter):
active_codec: AudioCodec active_codec: AudioCodec
hf_indicator: When HF update their indicators, notify the new state. hf_indicator: When HF update their indicators, notify the new state.
Args: Args:
hf_indicator: HfIndicator hf_indicator: HfIndicatorState
codec_connection_request: Emit when HF sends AT+BCC to request codec connection. codec_connection_request: Emit when HF sends AT+BCC to request codec connection.
answer: Emit when HF sends ATA to answer phone call. answer: Emit when HF sends ATA to answer phone call.
hang_up: Emit when HF sends AT+CHUP to hang up phone call. hang_up: Emit when HF sends AT+CHUP to hang up phone call.
@@ -1168,7 +1168,12 @@ class AgProtocol(pyee.EventEmitter):
Args: Args:
operation: CallHoldOperation operation: CallHoldOperation
call_index: Optional[int] 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 supported_hf_features: int
@@ -1191,6 +1196,7 @@ class AgProtocol(pyee.EventEmitter):
inband_ringtone_enabled: bool inband_ringtone_enabled: bool
cme_error_enabled: bool cme_error_enabled: bool
cli_notification_enabled: bool cli_notification_enabled: bool
call_waiting_enabled: bool
_remained_slc_setup_features: Set[HfFeature] _remained_slc_setup_features: Set[HfFeature]
def __init__(self, dlc: rfcomm.DLC, configuration: AgConfiguration) -> None: def __init__(self, dlc: rfcomm.DLC, configuration: AgConfiguration) -> None:
@@ -1218,6 +1224,7 @@ class AgProtocol(pyee.EventEmitter):
self.indicator_report_enabled = False self.indicator_report_enabled = False
self.cme_error_enabled = False self.cme_error_enabled = False
self.cli_notification_enabled = False self.cli_notification_enabled = False
self.call_waiting_enabled = False
self.hf_indicators = collections.OrderedDict() self.hf_indicators = collections.OrderedDict()
@@ -1465,7 +1472,12 @@ class AgProtocol(pyee.EventEmitter):
display: Optional[bytes] = None, display: Optional[bytes] = None,
indicator: bytes = b'', indicator: bytes = b'',
) -> None: ) -> 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( logger.error(
f'Unexpected values: mode={mode!r}, keypad={keypad!r}, ' f'Unexpected values: mode={mode!r}, keypad={keypad!r}, '
f'display={display!r}, indicator={indicator!r}' f'display={display!r}, indicator={indicator!r}'
@@ -1479,6 +1491,10 @@ class AgProtocol(pyee.EventEmitter):
self.cme_error_enabled = bool(int(enabled)) self.cme_error_enabled = bool(int(enabled))
self.send_ok() 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: def _on_bind(self, *args) -> None:
if not self.supports_ag_feature(AgFeature.HF_INDICATORS): if not self.supports_ag_feature(AgFeature.HF_INDICATORS):
self.send_error() self.send_error()
@@ -1578,6 +1594,15 @@ class AgProtocol(pyee.EventEmitter):
if not self.supports_hf_feature(HfFeature.CLI_PRESENTATION_CAPABILITY): if not self.supports_hf_feature(HfFeature.CLI_PRESENTATION_CAPABILITY):
logger.error('Remote doesn not support CLI but sends AT+CLIP') logger.error('Remote doesn not support CLI but sends AT+CLIP')
self.cli_notification_enabled = True if enabled == b'1' else False 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()
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
@@ -1761,7 +1786,7 @@ def make_ag_sdp_records(
sdp.SDP_SERVICE_CLASS_ID_LIST_ATTRIBUTE_ID, sdp.SDP_SERVICE_CLASS_ID_LIST_ATTRIBUTE_ID,
sdp.DataElement.sequence( 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), sdp.DataElement.uuid(BT_GENERIC_AUDIO_SERVICE),
] ]
), ),
@@ -1788,7 +1813,7 @@ def make_ag_sdp_records(
[ [
sdp.DataElement.sequence( 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), sdp.DataElement.unsigned_integer_16(version),
] ]
) )
@@ -1820,6 +1845,7 @@ async def find_hf_sdp_record(
sdp.SDP_PROTOCOL_DESCRIPTOR_LIST_ATTRIBUTE_ID, sdp.SDP_PROTOCOL_DESCRIPTOR_LIST_ATTRIBUTE_ID,
sdp.SDP_BLUETOOTH_PROFILE_DESCRIPTOR_LIST_ATTRIBUTE_ID, sdp.SDP_BLUETOOTH_PROFILE_DESCRIPTOR_LIST_ATTRIBUTE_ID,
sdp.SDP_SUPPORTED_FEATURES_ATTRIBUTE_ID, sdp.SDP_SUPPORTED_FEATURES_ATTRIBUTE_ID,
sdp.SDP_SERVICE_CLASS_ID_LIST_ATTRIBUTE_ID,
], ],
) )
for attribute_lists in search_result: for attribute_lists in search_result:
@@ -1839,10 +1865,17 @@ async def find_hf_sdp_record(
version = ProfileVersion(profile_descriptor_list[0].value[1].value) version = ProfileVersion(profile_descriptor_list[0].value[1].value)
elif attribute.id == sdp.SDP_SUPPORTED_FEATURES_ATTRIBUTE_ID: elif attribute.id == sdp.SDP_SUPPORTED_FEATURES_ATTRIBUTE_ID:
features = HfSdpFeature(attribute.value.value) features = HfSdpFeature(attribute.value.value)
if not channel or not version or features is None: elif attribute.id == sdp.SDP_SERVICE_CLASS_ID_LIST_ATTRIBUTE_ID:
logger.warning(f"Bad result {attribute_lists}.") class_id_list = attribute.value.value
return None uuid = class_id_list[0].value
return (channel, version, features) # 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 return None
@@ -1859,7 +1892,7 @@ async def find_ag_sdp_record(
""" """
async with sdp.Client(connection) as sdp_client: async with sdp.Client(connection) as sdp_client:
search_result = await sdp_client.search_attributes( search_result = await sdp_client.search_attributes(
uuids=[BT_HEADSET_AUDIO_GATEWAY_SERVICE], uuids=[BT_HANDSFREE_AUDIO_GATEWAY_SERVICE],
attribute_ids=[ attribute_ids=[
sdp.SDP_PROTOCOL_DESCRIPTOR_LIST_ATTRIBUTE_ID, sdp.SDP_PROTOCOL_DESCRIPTOR_LIST_ATTRIBUTE_ID,
sdp.SDP_BLUETOOTH_PROFILE_DESCRIPTOR_LIST_ATTRIBUTE_ID, sdp.SDP_BLUETOOTH_PROFILE_DESCRIPTOR_LIST_ATTRIBUTE_ID,

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", "name": "Bumble Phone",
"class_of_device": 6291980 "class_of_device": 6291980,
"keystore": "JsonKeyStore"
} }

View File

@@ -16,9 +16,14 @@
# Imports # Imports
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
import asyncio import asyncio
import json
import sys import sys
import os import os
import io
import logging import logging
import websockets
from typing import Optional
import bumble.core import bumble.core
from bumble.device import Device from bumble.device import Device
@@ -26,12 +31,15 @@ from bumble.transport import open_transport_or_link
from bumble.core import ( from bumble.core import (
BT_BR_EDR_TRANSPORT, BT_BR_EDR_TRANSPORT,
) )
from bumble import rfcomm, hfp from bumble import hci, rfcomm, hfp
from bumble.hci import HCI_SynchronousDataPacket
logger = logging.getLogger(__name__) 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: def _default_configuration() -> hfp.AgConfiguration:
return hfp.AgConfiguration( return hfp.AgConfiguration(
@@ -41,12 +49,13 @@ def _default_configuration() -> hfp.AgConfiguration:
hfp.AgFeature.REJECT_CALL, hfp.AgFeature.REJECT_CALL,
hfp.AgFeature.CODEC_NEGOTIATION, hfp.AgFeature.CODEC_NEGOTIATION,
hfp.AgFeature.ESCO_S4_SETTINGS_SUPPORTED, hfp.AgFeature.ESCO_S4_SETTINGS_SUPPORTED,
hfp.AgFeature.ENHANCED_CALL_STATUS,
], ],
supported_ag_indicators=[ supported_ag_indicators=[
hfp.AgIndicatorState.call(), hfp.AgIndicatorState.call(),
hfp.AgIndicatorState.callsetup(),
hfp.AgIndicatorState.callheld(),
hfp.AgIndicatorState.service(), hfp.AgIndicatorState.service(),
hfp.AgIndicatorState.callsetup(),
hfp.AgIndicatorState.callsetup(),
hfp.AgIndicatorState.signal(), hfp.AgIndicatorState.signal(),
hfp.AgIndicatorState.roam(), hfp.AgIndicatorState.roam(),
hfp.AgIndicatorState.battchg(), 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: async def main() -> None:
if len(sys.argv) < 4: if len(sys.argv) < 3:
print( print(
'Usage: run_hfp_gateway.py <device-config> <transport-spec> ' 'Usage: run_hfp_gateway.py <device-config> <transport-spec> '
'<bluetooth-address>' '[bluetooth-address] [wav-file-for-source]'
) )
print( 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 return
print('<<< connecting to HCI...') print('<<< connecting to HCI...')
@@ -84,56 +199,85 @@ async def main() -> None:
device.classic_enabled = True device.classic_enabled = True
await device.power_on() await device.power_on()
# Connect to a peer rfcomm_server = rfcomm.Server(device)
target_address = sys.argv[3] configuration = _default_configuration()
print(f'=== Connecting to {target_address}...')
connection = await device.connect(target_address, transport=BT_BR_EDR_TRANSPORT)
print(f'=== Connected to {connection.peer_address}!')
# Get a list of all the Handsfree services (should only be 1) def on_dlc(dlc: rfcomm.DLC):
if not (hfp_record := await hfp.find_hf_sdp_record(connection)): global ag_protocol
print('!!! no service found') ag_protocol = hfp.AgProtocol(dlc, configuration)
return 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 = rfcomm_server.listen(on_dlc)
channel, version, hf_sdp_features = hfp_record device.sdp_service_records = {
print(f'HF version: {version}') 1: hfp.make_ag_sdp_records(1, channel, configuration)
print(f'HF features: {hf_sdp_features}') }
# Request authentication def on_sco_connection(sco_link):
print('*** Authenticating...') assert ag_protocol
await connection.authenticate() on_sco_state_change(ag_protocol.active_codec)
print('*** Authenticated') sco_link.on('disconnection', lambda _: on_sco_state_change(0))
sco_link.on('pdu', on_sco_packet)
# Enable encryption device.on('sco_connection', on_sco_connection)
print('*** Enabling encryption...') if len(sys.argv) >= 4:
await connection.encrypt() # Connect to a peer
print('*** Encryption on') 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 # Get a list of all the Handsfree services (should only be 1)
print('@@@ Starting to RFCOMM client...') if not (hfp_record := await hfp.find_hf_sdp_record(connection)):
rfcomm_client = rfcomm.Client(connection) print('!!! no service found')
rfcomm_mux = await rfcomm_client.start() return
print('@@@ Started')
print(f'### Opening session for channel {channel}...') # Pick the first one
try: channel, version, hf_sdp_features = hfp_record
session = await rfcomm_mux.open_dlc(channel) print(f'HF version: {version}')
print('### Session open', session) print(f'HF features: {hf_sdp_features}')
except bumble.core.ConnectionError as error:
print(f'### Session open failed: {error}')
await rfcomm_mux.disconnect()
print('@@@ Disconnected from RFCOMM server')
return
def on_sco(connection_handle: int, packet: HCI_SynchronousDataPacket): # Request authentication
# Reset packet and loopback print('*** Authenticating...')
packet.packet_status = 0 await connection.authenticate()
device.host.send_hci_packet(packet) 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 await hci_transport.source.terminated