forked from auracaster/bumble_mirror
Compare commits
11 Commits
gbg/comman
...
gbg/gh-act
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2005b4a11b | ||
|
|
951fdc8bdd | ||
|
|
8781943646 | ||
|
|
7fbfdb634c | ||
|
|
9682077f6b | ||
|
|
22eb405fde | ||
|
|
593c61973f | ||
|
|
ccff32102f | ||
|
|
851d62c6c9 | ||
|
|
26e6650038 | ||
|
|
c48568aabe |
2
.github/workflows/code-check.yml
vendored
2
.github/workflows/code-check.yml
vendored
@@ -16,7 +16,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
matrix:
|
||||
python-version: ["3.8", "3.9", "3.10", "3.11"]
|
||||
python-version: ["3.8", "3.9", "3.10", "3.11", "3.12"]
|
||||
fail-fast: false
|
||||
|
||||
steps:
|
||||
|
||||
4
.github/workflows/python-build-test.yml
vendored
4
.github/workflows/python-build-test.yml
vendored
@@ -16,7 +16,7 @@ jobs:
|
||||
strategy:
|
||||
matrix:
|
||||
os: ['ubuntu-latest', 'macos-latest', 'windows-latest']
|
||||
python-version: ["3.8", "3.9", "3.10", "3.11"]
|
||||
python-version: ["3.8", "3.9", "3.10", "3.11", "3.12"]
|
||||
fail-fast: false
|
||||
|
||||
steps:
|
||||
@@ -46,7 +46,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
matrix:
|
||||
python-version: [ "3.8", "3.9", "3.10", "3.11" ]
|
||||
python-version: [ "3.8", "3.9", "3.10", "3.11", "3.12" ]
|
||||
rust-version: [ "1.76.0", "stable" ]
|
||||
fail-fast: false
|
||||
steps:
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
@@ -951,7 +951,7 @@ class HfProtocol(pyee.EventEmitter):
|
||||
|
||||
self.supported_ag_call_hold_operations = [
|
||||
CallHoldOperation(operation.decode())
|
||||
for operation in response.parameters
|
||||
for operation in response.parameters[0]
|
||||
]
|
||||
|
||||
# 4.2.1.4 HF Indicators
|
||||
@@ -1081,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 = response.parameters[5]
|
||||
call_info.type = int(response.parameters[6])
|
||||
calls.append(call_info)
|
||||
return calls
|
||||
@@ -1155,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.
|
||||
@@ -1167,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
|
||||
@@ -1190,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:
|
||||
@@ -1217,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()
|
||||
|
||||
@@ -1422,9 +1430,11 @@ class AgProtocol(pyee.EventEmitter):
|
||||
return
|
||||
|
||||
self.send_response(
|
||||
'+CHLD:'
|
||||
+ ','.join(
|
||||
operation.value for operation in self.supported_ag_call_hold_operations
|
||||
'+CHLD: ({})'.format(
|
||||
','.join(
|
||||
operation.value
|
||||
for operation in self.supported_ag_call_hold_operations
|
||||
)
|
||||
)
|
||||
)
|
||||
self.send_ok()
|
||||
@@ -1462,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}'
|
||||
@@ -1476,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()
|
||||
@@ -1557,15 +1576,16 @@ class AgProtocol(pyee.EventEmitter):
|
||||
|
||||
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',\"{call.number}\"'
|
||||
if call.number is not None
|
||||
else '' f',{call.type}' if call.type is not None else ''
|
||||
f'{number_text}'
|
||||
f'{type_text}'
|
||||
)
|
||||
self.send_response(response)
|
||||
self.send_ok()
|
||||
@@ -1574,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()
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
@@ -1757,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),
|
||||
]
|
||||
),
|
||||
@@ -1784,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),
|
||||
]
|
||||
)
|
||||
@@ -1816,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:
|
||||
@@ -1835,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
|
||||
|
||||
|
||||
@@ -1855,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,
|
||||
|
||||
@@ -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
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
|
||||
|
||||
|
||||
@@ -81,7 +81,7 @@ console_scripts =
|
||||
build =
|
||||
build >= 0.7
|
||||
test =
|
||||
pytest >= 8.0
|
||||
pytest >= 8.2
|
||||
pytest-asyncio >= 0.23.5
|
||||
pytest-html >= 3.2.0
|
||||
coverage >= 6.4
|
||||
@@ -89,9 +89,9 @@ development =
|
||||
black == 24.3
|
||||
grpcio-tools >= 1.62.1
|
||||
invoke >= 1.7.3
|
||||
mypy == 1.8.0
|
||||
mypy == 1.10.0
|
||||
nox >= 2022
|
||||
pylint == 2.15.8
|
||||
pylint == 3.1.0
|
||||
pyyaml >= 6.0
|
||||
types-appdirs >= 1.4.3
|
||||
types-invoke >= 1.7.3
|
||||
|
||||
@@ -214,6 +214,12 @@ async def test_device_connect_parallel():
|
||||
d1.host.set_packet_sink(Sink(d1_flow()))
|
||||
d2.host.set_packet_sink(Sink(d2_flow()))
|
||||
|
||||
d1_accept_task = asyncio.create_task(d1.accept(peer_address=d0.public_address))
|
||||
d2_accept_task = asyncio.create_task(d2.accept())
|
||||
|
||||
# Ensure that the accept tasks have started.
|
||||
await async_barrier()
|
||||
|
||||
[c01, c02, a10, a20] = await asyncio.gather(
|
||||
*[
|
||||
asyncio.create_task(
|
||||
@@ -222,8 +228,8 @@ async def test_device_connect_parallel():
|
||||
asyncio.create_task(
|
||||
d0.connect(d2.public_address, transport=BT_BR_EDR_TRANSPORT)
|
||||
),
|
||||
asyncio.create_task(d1.accept(peer_address=d0.public_address)),
|
||||
asyncio.create_task(d2.accept()),
|
||||
d1_accept_task,
|
||||
d2_accept_task,
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
@@ -311,7 +311,7 @@ async def test_query_calls_without_calls(
|
||||
):
|
||||
hf, ag = hfp_connections
|
||||
|
||||
await hf.query_current_calls() == []
|
||||
assert await hf.query_current_calls() == []
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
@@ -331,7 +331,7 @@ async def test_query_calls_with_calls(
|
||||
)
|
||||
)
|
||||
|
||||
await hf.query_current_calls() == ag.calls
|
||||
assert await hf.query_current_calls() == ag.calls
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
@@ -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():
|
||||
|
||||
@@ -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():
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user