forked from auracaster/bumble_mirror
HFP: Add example and fix AG errors
This commit is contained in:
@@ -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,
|
||||
)
|
||||
@@ -1156,7 +1156,7 @@ 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.
|
||||
@@ -1168,7 +1168,12 @@ class AgProtocol(pyee.EventEmitter):
|
||||
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
|
||||
@@ -1191,6 +1196,7 @@ class AgProtocol(pyee.EventEmitter):
|
||||
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:
|
||||
@@ -1218,6 +1224,7 @@ class AgProtocol(pyee.EventEmitter):
|
||||
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()
|
||||
|
||||
@@ -1465,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}'
|
||||
@@ -1479,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()
|
||||
@@ -1578,6 +1594,15 @@ class AgProtocol(pyee.EventEmitter):
|
||||
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()
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
@@ -1761,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),
|
||||
]
|
||||
),
|
||||
@@ -1788,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),
|
||||
]
|
||||
)
|
||||
@@ -1820,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:
|
||||
@@ -1839,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
|
||||
|
||||
|
||||
@@ -1859,7 +1892,7 @@ async def find_ag_sdp_record(
|
||||
"""
|
||||
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,
|
||||
|
||||
350
examples/hfp_gateway.html
Normal file
350
examples/hfp_gateway.html
Normal 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>
|
||||
@@ -1,4 +1,5 @@
|
||||
{
|
||||
"name": "Bumble Phone",
|
||||
"class_of_device": 6291980
|
||||
"class_of_device": 6291980,
|
||||
"keystore": "JsonKeyStore"
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user