forked from auracaster/bumble_mirror
Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 6226bfd196 | |||
| 71e11b7cf8 | |||
| 800c62fdb6 | |||
| 640b9cd53a |
@@ -1,85 +0,0 @@
|
||||
# Copyright 2023 Google LLC
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# https://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
|
||||
from typing import List, Union
|
||||
|
||||
|
||||
def tokenize_parameters(buffer: bytes) -> List[bytes]:
|
||||
"""Split input parameters into tokens.
|
||||
Removes space characters outside of double quote blocks:
|
||||
T-rec-V-25 - 5.2.1 Command line general format: "Space characters (IA5 2/0)
|
||||
are ignored [..], unless they are embedded in numeric or string constants"
|
||||
Raises ValueError in case of invalid input string."""
|
||||
|
||||
tokens = []
|
||||
in_quotes = False
|
||||
token = bytearray()
|
||||
for b in buffer:
|
||||
char = bytearray([b])
|
||||
|
||||
if in_quotes:
|
||||
token.extend(char)
|
||||
if char == b'\"':
|
||||
in_quotes = False
|
||||
tokens.append(token[1:-1])
|
||||
token = bytearray()
|
||||
else:
|
||||
if char == b' ':
|
||||
pass
|
||||
elif char == b',' or char == b')':
|
||||
tokens.append(token)
|
||||
tokens.append(char)
|
||||
token = bytearray()
|
||||
elif char == b'(':
|
||||
if len(token) > 0:
|
||||
raise ValueError("open_paren following regular character")
|
||||
tokens.append(char)
|
||||
elif char == b'"':
|
||||
if len(token) > 0:
|
||||
raise ValueError("quote following regular character")
|
||||
in_quotes = True
|
||||
token.extend(char)
|
||||
else:
|
||||
token.extend(char)
|
||||
|
||||
tokens.append(token)
|
||||
return [bytes(token) for token in tokens if len(token) > 0]
|
||||
|
||||
|
||||
def parse_parameters(buffer: bytes) -> List[Union[bytes, list]]:
|
||||
"""Parse the parameters using the comma and parenthesis separators.
|
||||
Raises ValueError in case of invalid input string."""
|
||||
|
||||
tokens = tokenize_parameters(buffer)
|
||||
accumulator: List[list] = [[]]
|
||||
current: Union[bytes, list] = bytes()
|
||||
|
||||
for token in tokens:
|
||||
if token == b',':
|
||||
accumulator[-1].append(current)
|
||||
current = bytes()
|
||||
elif token == b'(':
|
||||
accumulator.append([])
|
||||
elif token == b')':
|
||||
if len(accumulator) < 2:
|
||||
raise ValueError("close_paren without matching open_paren")
|
||||
accumulator[-1].append(current)
|
||||
current = accumulator.pop()
|
||||
else:
|
||||
current = token
|
||||
|
||||
accumulator[-1].append(current)
|
||||
if len(accumulator) > 1:
|
||||
raise ValueError("missing close_paren")
|
||||
return accumulator[0]
|
||||
+11
-15
@@ -23,22 +23,18 @@
|
||||
# -----------------------------------------------------------------------------
|
||||
import logging
|
||||
import operator
|
||||
import platform
|
||||
|
||||
if platform.system() != 'Emscripten':
|
||||
import secrets
|
||||
from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
|
||||
from cryptography.hazmat.primitives.asymmetric.ec import (
|
||||
generate_private_key,
|
||||
ECDH,
|
||||
EllipticCurvePublicNumbers,
|
||||
EllipticCurvePrivateNumbers,
|
||||
SECP256R1,
|
||||
)
|
||||
from cryptography.hazmat.primitives import cmac
|
||||
else:
|
||||
# TODO: implement stubs
|
||||
pass
|
||||
import secrets
|
||||
from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
|
||||
from cryptography.hazmat.primitives.asymmetric.ec import (
|
||||
generate_private_key,
|
||||
ECDH,
|
||||
EllipticCurvePublicNumbers,
|
||||
EllipticCurvePrivateNumbers,
|
||||
SECP256R1,
|
||||
)
|
||||
from cryptography.hazmat.primitives import cmac
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Logging
|
||||
|
||||
+27
-17
@@ -2262,17 +2262,21 @@ class Device(CompositeEventEmitter):
|
||||
return keys.ltk_peripheral.value
|
||||
|
||||
async def get_link_key(self, address: Address) -> Optional[bytes]:
|
||||
# Look for the key in the keystore
|
||||
if self.keystore is not None:
|
||||
keys = await self.keystore.get(str(address))
|
||||
if keys is not None:
|
||||
logger.debug('found keys in the key store')
|
||||
if keys.link_key is None:
|
||||
logger.warning('no link key')
|
||||
return None
|
||||
if self.keystore is None:
|
||||
return None
|
||||
|
||||
return keys.link_key.value
|
||||
return None
|
||||
# Look for the key in the keystore
|
||||
keys = await self.keystore.get(str(address))
|
||||
if keys is None:
|
||||
logger.debug(f'no keys found for {address}')
|
||||
return None
|
||||
|
||||
logger.debug('found keys in the key store')
|
||||
if keys.link_key is None:
|
||||
logger.warning('no link key')
|
||||
return None
|
||||
|
||||
return keys.link_key.value
|
||||
|
||||
# [Classic only]
|
||||
async def authenticate(self, connection):
|
||||
@@ -2391,6 +2395,18 @@ class Device(CompositeEventEmitter):
|
||||
'connection_encryption_failure', on_encryption_failure
|
||||
)
|
||||
|
||||
async def update_keys(self, address: str, keys: PairingKeys) -> None:
|
||||
if self.keystore is None:
|
||||
return
|
||||
|
||||
try:
|
||||
await self.keystore.update(address, keys)
|
||||
await self.refresh_resolving_list()
|
||||
except Exception as error:
|
||||
logger.warning(f'!!! error while storing keys: {error}')
|
||||
else:
|
||||
self.emit('key_store_update')
|
||||
|
||||
# [Classic only]
|
||||
async def switch_role(self, connection: Connection, role: int):
|
||||
pending_role_change = asyncio.get_running_loop().create_future()
|
||||
@@ -2485,13 +2501,7 @@ class Device(CompositeEventEmitter):
|
||||
value=link_key, authenticated=authenticated
|
||||
)
|
||||
|
||||
async def store_keys():
|
||||
try:
|
||||
await self.keystore.update(str(bd_addr), pairing_keys)
|
||||
except Exception as error:
|
||||
logger.warning(f'!!! error while storing keys: {error}')
|
||||
|
||||
self.abort_on('flush', store_keys())
|
||||
self.abort_on('flush', self.update_keys(str(bd_addr), pairing_keys))
|
||||
|
||||
if connection := self.find_connection_by_bd_addr(
|
||||
bd_addr, transport=BT_BR_EDR_TRANSPORT
|
||||
|
||||
+55
-703
@@ -1,4 +1,4 @@
|
||||
# Copyright 2023 Google LLC
|
||||
# Copyright 2021-2022 Google LLC
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
@@ -17,31 +17,11 @@
|
||||
# -----------------------------------------------------------------------------
|
||||
import logging
|
||||
import asyncio
|
||||
import dataclasses
|
||||
import enum
|
||||
import traceback
|
||||
from typing import Dict, List, Union, Set
|
||||
import collections
|
||||
from typing import Union
|
||||
|
||||
from . import at
|
||||
from . import rfcomm
|
||||
|
||||
from bumble.core import (
|
||||
ProtocolError,
|
||||
BT_GENERIC_AUDIO_SERVICE,
|
||||
BT_HANDSFREE_SERVICE,
|
||||
BT_L2CAP_PROTOCOL_ID,
|
||||
BT_RFCOMM_PROTOCOL_ID,
|
||||
)
|
||||
from bumble.sdp import (
|
||||
DataElement,
|
||||
ServiceAttribute,
|
||||
SDP_SERVICE_RECORD_HANDLE_ATTRIBUTE_ID,
|
||||
SDP_SERVICE_CLASS_ID_LIST_ATTRIBUTE_ID,
|
||||
SDP_PROTOCOL_DESCRIPTOR_LIST_ATTRIBUTE_ID,
|
||||
SDP_BLUETOOTH_PROFILE_DESCRIPTOR_LIST_ATTRIBUTE_ID,
|
||||
SDP_SUPPORTED_FEATURES_ATTRIBUTE_ID,
|
||||
)
|
||||
|
||||
from .colors import color
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Logging
|
||||
@@ -50,700 +30,72 @@ logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Normative protocol definitions
|
||||
# Protocol Support
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
|
||||
# HF supported features (AT+BRSF=) (normative).
|
||||
# Hands-Free Profile v1.8, 4.34.2, AT Capabilities Re-Used from GSM 07.07
|
||||
# and 3GPP 27.007
|
||||
class HfFeature(enum.IntFlag):
|
||||
EC_NR = 0x001 # Echo Cancel & Noise reduction
|
||||
THREE_WAY_CALLING = 0x002
|
||||
CLI_PRESENTATION_CAPABILITY = 0x004
|
||||
VOICE_RECOGNITION_ACTIVATION = 0x008
|
||||
REMOTE_VOLUME_CONTROL = 0x010
|
||||
ENHANCED_CALL_STATUS = 0x020
|
||||
ENHANCED_CALL_CONTROL = 0x040
|
||||
CODEC_NEGOTIATION = 0x080
|
||||
HF_INDICATORS = 0x100
|
||||
ESCO_S4_SETTINGS_SUPPORTED = 0x200
|
||||
ENHANCED_VOICE_RECOGNITION_STATUS = 0x400
|
||||
VOICE_RECOGNITION_TEST = 0x800
|
||||
|
||||
|
||||
# AG supported features (+BRSF:) (normative).
|
||||
# Hands-Free Profile v1.8, 4.34.2, AT Capabilities Re-Used from GSM 07.07
|
||||
# and 3GPP 27.007
|
||||
class AgFeature(enum.IntFlag):
|
||||
THREE_WAY_CALLING = 0x001
|
||||
EC_NR = 0x002 # Echo Cancel & Noise reduction
|
||||
VOICE_RECOGNITION_FUNCTION = 0x004
|
||||
IN_BAND_RING_TONE_CAPABILITY = 0x008
|
||||
VOICE_TAG = 0x010 # Attach a number to voice tag
|
||||
REJECT_CALL = 0x020 # Ability to reject a call
|
||||
ENHANCED_CALL_STATUS = 0x040
|
||||
ENHANCED_CALL_CONTROL = 0x080
|
||||
EXTENDED_ERROR_RESULT_CODES = 0x100
|
||||
CODEC_NEGOTIATION = 0x200
|
||||
HF_INDICATORS = 0x400
|
||||
ESCO_S4_SETTINGS_SUPPORTED = 0x800
|
||||
ENHANCED_VOICE_RECOGNITION_STATUS = 0x1000
|
||||
VOICE_RECOGNITION_TEST = 0x2000
|
||||
|
||||
|
||||
# Audio Codec IDs (normative).
|
||||
# Hands-Free Profile v1.8, 10 Appendix B
|
||||
class AudioCodec(enum.IntEnum):
|
||||
CVSD = 0x01 # Support for CVSD audio codec
|
||||
MSBC = 0x02 # Support for mSBC audio codec
|
||||
|
||||
|
||||
# HF Indicators (normative).
|
||||
# Bluetooth Assigned Numbers, 6.10.1 HF Indicators
|
||||
class HfIndicator(enum.IntEnum):
|
||||
ENHANCED_SAFETY = 0x01 # Enhanced safety feature
|
||||
BATTERY_LEVEL = 0x02 # Battery level feature
|
||||
|
||||
|
||||
# Call Hold supported operations (normative).
|
||||
# AT Commands Reference Guide, 3.5.2.3.12 +CHLD - Call Holding Services
|
||||
class CallHoldOperation(enum.IntEnum):
|
||||
RELEASE_ALL_HELD_CALLS = 0 # Release all held calls
|
||||
RELEASE_ALL_ACTIVE_CALLS = 1 # Release all active calls, accept other
|
||||
HOLD_ALL_ACTIVE_CALLS = 2 # Place all active calls on hold, accept other
|
||||
ADD_HELD_CALL = 3 # Adds a held call to conversation
|
||||
|
||||
|
||||
# Response Hold status (normative).
|
||||
# Hands-Free Profile v1.8, 4.34.2, AT Capabilities Re-Used from GSM 07.07
|
||||
# and 3GPP 27.007
|
||||
class ResponseHoldStatus(enum.IntEnum):
|
||||
INC_CALL_HELD = 0 # Put incoming call on hold
|
||||
HELD_CALL_ACC = 1 # Accept a held incoming call
|
||||
HELD_CALL_REJ = 2 # Reject a held incoming call
|
||||
|
||||
|
||||
# Values for the Call Setup AG indicator (normative).
|
||||
# Hands-Free Profile v1.8, 4.34.2, AT Capabilities Re-Used from GSM 07.07
|
||||
# and 3GPP 27.007
|
||||
class CallSetupAgIndicator(enum.IntEnum):
|
||||
NOT_IN_CALL_SETUP = 0
|
||||
INCOMING_CALL_PROCESS = 1
|
||||
OUTGOING_CALL_SETUP = 2
|
||||
REMOTE_ALERTED = 3 # Remote party alerted in an outgoing call
|
||||
|
||||
|
||||
# Values for the Call Held AG indicator (normative).
|
||||
# Hands-Free Profile v1.8, 4.34.2, AT Capabilities Re-Used from GSM 07.07
|
||||
# and 3GPP 27.007
|
||||
class CallHeldAgIndicator(enum.IntEnum):
|
||||
NO_CALLS_HELD = 0
|
||||
# Call is placed on hold or active/held calls swapped
|
||||
# (The AG has both an active AND a held call)
|
||||
CALL_ON_HOLD_AND_ACTIVE_CALL = 1
|
||||
CALL_ON_HOLD_NO_ACTIVE_CALL = 2 # Call on hold, no active call
|
||||
|
||||
|
||||
# Call Info direction (normative).
|
||||
# AT Commands Reference Guide, 3.5.2.3.15 +CLCC - List Current Calls
|
||||
class CallInfoDirection(enum.IntEnum):
|
||||
MOBILE_ORIGINATED_CALL = 0
|
||||
MOBILE_TERMINATED_CALL = 1
|
||||
|
||||
|
||||
# Call Info status (normative).
|
||||
# AT Commands Reference Guide, 3.5.2.3.15 +CLCC - List Current Calls
|
||||
class CallInfoStatus(enum.IntEnum):
|
||||
ACTIVE = 0
|
||||
HELD = 1
|
||||
DIALING = 2
|
||||
ALERTING = 3
|
||||
INCOMING = 4
|
||||
WAITING = 5
|
||||
|
||||
|
||||
# Call Info mode (normative).
|
||||
# AT Commands Reference Guide, 3.5.2.3.15 +CLCC - List Current Calls
|
||||
class CallInfoMode(enum.IntEnum):
|
||||
VOICE = 0
|
||||
DATA = 1
|
||||
FAX = 2
|
||||
UNKNOWN = 9
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Hands-Free Control Interoperability Requirements
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
# Response codes.
|
||||
RESPONSE_CODES = [
|
||||
"+APLSIRI",
|
||||
"+BAC",
|
||||
"+BCC",
|
||||
"+BCS",
|
||||
"+BIA",
|
||||
"+BIEV",
|
||||
"+BIND",
|
||||
"+BINP",
|
||||
"+BLDN",
|
||||
"+BRSF",
|
||||
"+BTRH",
|
||||
"+BVRA",
|
||||
"+CCWA",
|
||||
"+CHLD",
|
||||
"+CHUP",
|
||||
"+CIND",
|
||||
"+CLCC",
|
||||
"+CLIP",
|
||||
"+CMEE",
|
||||
"+CMER",
|
||||
"+CNUM",
|
||||
"+COPS",
|
||||
"+IPHONEACCEV",
|
||||
"+NREC",
|
||||
"+VGM",
|
||||
"+VGS",
|
||||
"+VTS",
|
||||
"+XAPL",
|
||||
"A",
|
||||
"D",
|
||||
]
|
||||
|
||||
# Unsolicited responses and statuses.
|
||||
UNSOLICITED_CODES = [
|
||||
"+APLSIRI",
|
||||
"+BCS",
|
||||
"+BIND",
|
||||
"+BSIR",
|
||||
"+BTRH",
|
||||
"+BVRA",
|
||||
"+CCWA",
|
||||
"+CIEV",
|
||||
"+CLIP",
|
||||
"+VGM",
|
||||
"+VGS",
|
||||
"BLACKLISTED",
|
||||
"BUSY",
|
||||
"DELAYED",
|
||||
"NO ANSWER",
|
||||
"NO CARRIER",
|
||||
"RING",
|
||||
]
|
||||
|
||||
# Status codes
|
||||
STATUS_CODES = [
|
||||
"+CME ERROR",
|
||||
"BLACKLISTED",
|
||||
"BUSY",
|
||||
"DELAYED",
|
||||
"ERROR",
|
||||
"NO ANSWER",
|
||||
"NO CARRIER",
|
||||
"OK",
|
||||
]
|
||||
|
||||
|
||||
@dataclasses.dataclass
|
||||
class Configuration:
|
||||
supported_hf_features: List[HfFeature]
|
||||
supported_hf_indicators: List[HfIndicator]
|
||||
supported_audio_codecs: List[AudioCodec]
|
||||
|
||||
|
||||
class AtResponseType(enum.Enum):
|
||||
"""Indicate if a response is expected from an AT command, and if multiple
|
||||
responses are accepted."""
|
||||
|
||||
NONE = 0
|
||||
SINGLE = 1
|
||||
MULTIPLE = 2
|
||||
|
||||
|
||||
class AtResponse:
|
||||
code: str
|
||||
parameters: list
|
||||
|
||||
def __init__(self, response: bytearray):
|
||||
code_and_parameters = response.split(b':')
|
||||
parameters = (
|
||||
code_and_parameters[1] if len(code_and_parameters) > 1 else bytearray()
|
||||
)
|
||||
self.code = code_and_parameters[0].decode()
|
||||
self.parameters = at.parse_parameters(parameters)
|
||||
|
||||
|
||||
@dataclasses.dataclass
|
||||
class AgIndicatorState:
|
||||
description: str
|
||||
index: int
|
||||
supported_values: Set[int]
|
||||
current_status: int
|
||||
|
||||
|
||||
@dataclasses.dataclass
|
||||
class HfIndicatorState:
|
||||
supported: bool = False
|
||||
enabled: bool = False
|
||||
|
||||
|
||||
class HfProtocol:
|
||||
"""Implementation for the Hands-Free side of the Hands-Free profile.
|
||||
Reference specification Hands-Free Profile v1.8"""
|
||||
|
||||
supported_hf_features: int
|
||||
supported_audio_codecs: List[AudioCodec]
|
||||
|
||||
supported_ag_features: int
|
||||
supported_ag_call_hold_operations: List[CallHoldOperation]
|
||||
|
||||
ag_indicators: List[AgIndicatorState]
|
||||
hf_indicators: Dict[HfIndicator, HfIndicatorState]
|
||||
|
||||
class HfpProtocol:
|
||||
dlc: rfcomm.DLC
|
||||
command_lock: asyncio.Lock
|
||||
response_queue: asyncio.Queue
|
||||
unsolicited_queue: asyncio.Queue
|
||||
read_buffer: bytearray
|
||||
buffer: str
|
||||
lines: collections.deque
|
||||
lines_available: asyncio.Event
|
||||
|
||||
def __init__(self, dlc: rfcomm.DLC, configuration: Configuration):
|
||||
# Configure internal state.
|
||||
def __init__(self, dlc: rfcomm.DLC) -> None:
|
||||
self.dlc = dlc
|
||||
self.command_lock = asyncio.Lock()
|
||||
self.response_queue = asyncio.Queue()
|
||||
self.unsolicited_queue = asyncio.Queue()
|
||||
self.read_buffer = bytearray()
|
||||
self.buffer = ''
|
||||
self.lines = collections.deque()
|
||||
self.lines_available = asyncio.Event()
|
||||
|
||||
# Build local features.
|
||||
self.supported_hf_features = sum(configuration.supported_hf_features)
|
||||
self.supported_audio_codecs = configuration.supported_audio_codecs
|
||||
dlc.sink = self.feed
|
||||
|
||||
self.hf_indicators = {
|
||||
indicator: HfIndicatorState()
|
||||
for indicator in configuration.supported_hf_indicators
|
||||
}
|
||||
def feed(self, data: Union[bytes, str]) -> None:
|
||||
# Convert the data to a string if needed
|
||||
if isinstance(data, bytes):
|
||||
data = data.decode('utf-8')
|
||||
|
||||
# Clear remote features.
|
||||
self.supported_ag_features = 0
|
||||
self.supported_ag_call_hold_operations = []
|
||||
self.ag_indicators = []
|
||||
logger.debug(f'<<< Data received: {data}')
|
||||
|
||||
# Bind the AT reader to the RFCOMM channel.
|
||||
self.dlc.sink = self._read_at
|
||||
# Add to the buffer and look for lines
|
||||
self.buffer += data
|
||||
while (separator := self.buffer.find('\r')) >= 0:
|
||||
line = self.buffer[:separator].strip()
|
||||
self.buffer = self.buffer[separator + 1 :]
|
||||
if len(line) > 0:
|
||||
self.on_line(line)
|
||||
|
||||
def supports_hf_feature(self, feature: HfFeature) -> bool:
|
||||
return (self.supported_hf_features & feature) != 0
|
||||
def on_line(self, line: str) -> None:
|
||||
self.lines.append(line)
|
||||
self.lines_available.set()
|
||||
|
||||
def supports_ag_feature(self, feature: AgFeature) -> bool:
|
||||
return (self.supported_ag_features & feature) != 0
|
||||
def send_command_line(self, line: str) -> None:
|
||||
logger.debug(color(f'>>> {line}', 'yellow'))
|
||||
self.dlc.write(line + '\r')
|
||||
|
||||
# Read AT messages from the RFCOMM channel.
|
||||
# Enqueue AT commands, responses, unsolicited responses to their
|
||||
# respective queues, and set the corresponding event.
|
||||
def _read_at(self, data: bytes):
|
||||
# Append to the read buffer.
|
||||
self.read_buffer.extend(data)
|
||||
def send_response_line(self, line: str) -> None:
|
||||
logger.debug(color(f'>>> {line}', 'yellow'))
|
||||
self.dlc.write('\r\n' + line + '\r\n')
|
||||
|
||||
# Locate header and trailer.
|
||||
header = self.read_buffer.find(b'\r\n')
|
||||
trailer = self.read_buffer.find(b'\r\n', header + 2)
|
||||
if header == -1 or trailer == -1:
|
||||
return
|
||||
async def next_line(self) -> str:
|
||||
await self.lines_available.wait()
|
||||
line = self.lines.popleft()
|
||||
if not self.lines:
|
||||
self.lines_available.clear()
|
||||
logger.debug(color(f'<<< {line}', 'green'))
|
||||
return line
|
||||
|
||||
# Isolate the AT response code and parameters.
|
||||
raw_response = self.read_buffer[header + 2 : trailer]
|
||||
response = AtResponse(raw_response)
|
||||
logger.debug(f"<<< {raw_response.decode()}")
|
||||
async def initialize_service(self) -> None:
|
||||
# Perform Service Level Connection Initialization
|
||||
self.send_command_line('AT+BRSF=2072') # Retrieve Supported Features
|
||||
await (self.next_line())
|
||||
await (self.next_line())
|
||||
|
||||
# Consume the response bytes.
|
||||
self.read_buffer = self.read_buffer[trailer + 2 :]
|
||||
self.send_command_line('AT+CIND=?')
|
||||
await (self.next_line())
|
||||
await (self.next_line())
|
||||
|
||||
# Forward the received code to the correct queue.
|
||||
if self.command_lock.locked() and (
|
||||
response.code in STATUS_CODES or response.code in RESPONSE_CODES
|
||||
):
|
||||
self.response_queue.put_nowait(response)
|
||||
elif response.code in UNSOLICITED_CODES:
|
||||
self.unsolicited_queue.put_nowait(response)
|
||||
else:
|
||||
logger.warning(f"dropping unexpected response with code '{response.code}'")
|
||||
self.send_command_line('AT+CIND?')
|
||||
await (self.next_line())
|
||||
await (self.next_line())
|
||||
|
||||
# Send an AT command and wait for the peer resposne.
|
||||
# Wait for the AT responses sent by the peer, to the status code.
|
||||
# Raises asyncio.TimeoutError if the status is not received
|
||||
# after a timeout (default 1 second).
|
||||
# Raises ProtocolError if the status is not OK.
|
||||
async def execute_command(
|
||||
self,
|
||||
cmd: str,
|
||||
timeout: float = 1.0,
|
||||
response_type: AtResponseType = AtResponseType.NONE,
|
||||
) -> Union[None, AtResponse, List[AtResponse]]:
|
||||
async with self.command_lock:
|
||||
logger.debug(f">>> {cmd}")
|
||||
self.dlc.write(cmd + '\r')
|
||||
responses: List[AtResponse] = []
|
||||
|
||||
while True:
|
||||
result = await asyncio.wait_for(
|
||||
self.response_queue.get(), timeout=timeout
|
||||
)
|
||||
if result.code == 'OK':
|
||||
if response_type == AtResponseType.SINGLE and len(responses) != 1:
|
||||
raise ProtocolError("NO ANSWER")
|
||||
|
||||
if response_type == AtResponseType.MULTIPLE:
|
||||
return responses
|
||||
if response_type == AtResponseType.SINGLE:
|
||||
return responses[0]
|
||||
return None
|
||||
if result.code in STATUS_CODES:
|
||||
raise ProtocolError(result.code)
|
||||
responses.append(result)
|
||||
|
||||
# 4.2.1 Service Level Connection Initialization.
|
||||
async def initiate_slc(self):
|
||||
# 4.2.1.1 Supported features exchange
|
||||
# First, in the initialization procedure, the HF shall send the
|
||||
# AT+BRSF=<HF supported features> command to the AG to both notify
|
||||
# the AG of the supported features in the HF, as well as to retrieve the
|
||||
# supported features in the AG using the +BRSF result code.
|
||||
response = await self.execute_command(
|
||||
f"AT+BRSF={self.supported_hf_features}", response_type=AtResponseType.SINGLE
|
||||
)
|
||||
|
||||
self.supported_ag_features = int(response.parameters[0])
|
||||
logger.info(f"supported AG features: {self.supported_ag_features}")
|
||||
for feature in AgFeature:
|
||||
if self.supports_ag_feature(feature):
|
||||
logger.info(f" - {feature.name}")
|
||||
|
||||
# 4.2.1.2 Codec Negotiation
|
||||
# Secondly, in the initialization procedure, if the HF supports the
|
||||
# Codec Negotiation feature, it shall check if the AT+BRSF command
|
||||
# response from the AG has indicated that it supports the Codec
|
||||
# Negotiation feature.
|
||||
if self.supports_hf_feature(
|
||||
HfFeature.CODEC_NEGOTIATION
|
||||
) and self.supports_ag_feature(AgFeature.CODEC_NEGOTIATION):
|
||||
# If both the HF and AG do support the Codec Negotiation feature
|
||||
# then the HF shall send the AT+BAC=<HF available codecs> command to
|
||||
# the AG to notify the AG of the available codecs in the HF.
|
||||
codecs = [str(c) for c in self.supported_audio_codecs]
|
||||
await self.execute_command(f"AT+BAC={','.join(codecs)}")
|
||||
|
||||
# 4.2.1.3 AG Indicators
|
||||
# After having retrieved the supported features in the AG, the HF shall
|
||||
# determine which indicators are supported by the AG, as well as the
|
||||
# ordering of the supported indicators. This is because, according to
|
||||
# the 3GPP 27.007 specification [2], the AG may support additional
|
||||
# indicators not provided for by the Hands-Free Profile, and because the
|
||||
# ordering of the indicators is implementation specific. The HF uses
|
||||
# the AT+CIND=? Test command to retrieve information about the supported
|
||||
# indicators and their ordering.
|
||||
response = await self.execute_command(
|
||||
"AT+CIND=?", response_type=AtResponseType.SINGLE
|
||||
)
|
||||
|
||||
self.ag_indicators = []
|
||||
for index, indicator in enumerate(response.parameters):
|
||||
description = indicator[0].decode()
|
||||
supported_values = []
|
||||
for value in indicator[1]:
|
||||
value = value.split(b'-')
|
||||
value = [int(v) for v in value]
|
||||
value_min = value[0]
|
||||
value_max = value[1] if len(value) > 1 else value[0]
|
||||
supported_values.extend([v for v in range(value_min, value_max + 1)])
|
||||
|
||||
self.ag_indicators.append(
|
||||
AgIndicatorState(description, index, set(supported_values), 0)
|
||||
)
|
||||
|
||||
# Once the HF has the necessary supported indicator and ordering
|
||||
# information, it shall retrieve the current status of the indicators
|
||||
# in the AG using the AT+CIND? Read command.
|
||||
response = await self.execute_command(
|
||||
"AT+CIND?", response_type=AtResponseType.SINGLE
|
||||
)
|
||||
|
||||
for index, indicator in enumerate(response.parameters):
|
||||
self.ag_indicators[index].current_status = int(indicator)
|
||||
|
||||
# After having retrieved the status of the indicators in the AG, the HF
|
||||
# shall then enable the "Indicators status update" function in the AG by
|
||||
# issuing the AT+CMER command, to which the AG shall respond with OK.
|
||||
await self.execute_command("AT+CMER=3,,,1")
|
||||
|
||||
if self.supports_hf_feature(
|
||||
HfFeature.THREE_WAY_CALLING
|
||||
) and self.supports_ag_feature(HfFeature.THREE_WAY_CALLING):
|
||||
# After the HF has enabled the “Indicators status update” function in
|
||||
# the AG, and if the “Call waiting and 3-way calling” bit was set in the
|
||||
# supported features bitmap by both the HF and the AG, the HF shall
|
||||
# issue the AT+CHLD=? test command to retrieve the information about how
|
||||
# the call hold and multiparty services are supported in the AG. The HF
|
||||
# shall not issue the AT+CHLD=? test command in case either the HF or
|
||||
# the AG does not support the "Three-way calling" feature.
|
||||
response = await self.execute_command(
|
||||
"AT+CHLD=?", response_type=AtResponseType.SINGLE
|
||||
)
|
||||
|
||||
self.supported_ag_call_hold_operations = [
|
||||
CallHoldOperation(int(operation))
|
||||
for operation in response.parameters[0]
|
||||
if not b'x' in operation
|
||||
]
|
||||
|
||||
# 4.2.1.4 HF Indicators
|
||||
# If the HF supports the HF indicator feature, it shall check the +BRSF
|
||||
# response to see if the AG also supports the HF Indicator feature.
|
||||
if self.supports_hf_feature(
|
||||
HfFeature.HF_INDICATORS
|
||||
) and self.supports_ag_feature(AgFeature.HF_INDICATORS):
|
||||
# If both the HF and AG support the HF Indicator feature, then the HF
|
||||
# shall send the AT+BIND=<HF supported HF indicators> command to the AG
|
||||
# to notify the AG of the supported indicators’ assigned numbers in the
|
||||
# HF. The AG shall respond with OK
|
||||
indicators = [str(i) for i in self.hf_indicators.keys()]
|
||||
await self.execute_command(f"AT+BIND={','.join(indicators)}")
|
||||
|
||||
# After having provided the AG with the HF indicators it supports,
|
||||
# the HF shall send the AT+BIND=? to request HF indicators supported
|
||||
# by the AG. The AG shall reply with the +BIND response listing all
|
||||
# HF indicators that it supports followed by an OK.
|
||||
response = await self.execute_command(
|
||||
"AT+BIND=?", response_type=AtResponseType.SINGLE
|
||||
)
|
||||
|
||||
logger.info("supported HF indicators:")
|
||||
for indicator in response.parameters[0]:
|
||||
indicator = HfIndicator(int(indicator))
|
||||
logger.info(f" - {indicator.name}")
|
||||
if indicator in self.hf_indicators:
|
||||
self.hf_indicators[indicator].supported = True
|
||||
|
||||
# Once the HF receives the supported HF indicators list from the AG,
|
||||
# the HF shall send the AT+BIND? command to determine which HF
|
||||
# indicators are enabled. The AG shall respond with one or more
|
||||
# +BIND responses. The AG shall terminate the list with OK.
|
||||
# (See Section 4.36.1.3).
|
||||
responses = await self.execute_command(
|
||||
"AT+BIND?", response_type=AtResponseType.MULTIPLE
|
||||
)
|
||||
|
||||
logger.info("enabled HF indicators:")
|
||||
for response in responses:
|
||||
indicator = HfIndicator(int(response.parameters[0]))
|
||||
enabled = int(response.parameters[1]) != 0
|
||||
logger.info(f" - {indicator.name}: {enabled}")
|
||||
if indicator in self.hf_indicators:
|
||||
self.hf_indicators[indicator].enabled = True
|
||||
|
||||
logger.info("SLC setup completed")
|
||||
|
||||
# 4.11.2 Audio Connection Setup by HF
|
||||
async def setup_audio_connection(self):
|
||||
# When the HF triggers the establishment of the Codec Connection it
|
||||
# shall send the AT command AT+BCC to the AG. The AG shall respond with
|
||||
# OK if it will start the Codec Connection procedure, and with ERROR
|
||||
# if it cannot start the Codec Connection procedure.
|
||||
await self.execute_command("AT+BCC")
|
||||
|
||||
# 4.11.3 Codec Connection Setup
|
||||
async def setup_codec_connection(self, codec_id: int):
|
||||
# The AG shall send a +BCS=<Codec ID> unsolicited response to the HF.
|
||||
# The HF shall then respond to the incoming unsolicited response with
|
||||
# the AT command AT+BCS=<Codec ID>. The ID shall be the same as in the
|
||||
# unsolicited response code as long as the ID is supported.
|
||||
# If the received ID is not available, the HF shall respond with
|
||||
# AT+BAC with its available codecs.
|
||||
if codec_id not in self.supported_audio_codecs:
|
||||
codecs = [str(c) for c in self.supported_audio_codecs]
|
||||
await self.execute_command(f"AT+BAC={','.join(codecs)}")
|
||||
return
|
||||
|
||||
await self.execute_command(f"AT+BCS={codec_id}")
|
||||
|
||||
# After sending the OK response, the AG shall open the
|
||||
# Synchronous Connection with the settings that are determined by the
|
||||
# ID. The HF shall be ready to accept the synchronous connection
|
||||
# establishment as soon as it has sent the AT commands AT+BCS=<Codec ID>.
|
||||
|
||||
logger.info("codec connection setup completed")
|
||||
|
||||
# 4.13.1 Answer Incoming Call from the HF – In-Band Ringing
|
||||
async def answer_incoming_call(self):
|
||||
# The user accepts the incoming voice call by using the proper means
|
||||
# provided by the HF. The HF shall then send the ATA command
|
||||
# (see Section 4.34) to the AG. The AG shall then begin the procedure for
|
||||
# accepting the incoming call.
|
||||
await self.execute_command("ATA")
|
||||
|
||||
# 4.14.1 Reject an Incoming Call from the HF
|
||||
async def reject_incoming_call(self):
|
||||
# The user rejects the incoming call by using the User Interface on the
|
||||
# Hands-Free unit. The HF shall then send the AT+CHUP command
|
||||
# (see Section 4.34) to the AG. This may happen at any time during the
|
||||
# procedures described in Sections 4.13.1 and 4.13.2.
|
||||
await self.execute_command("AT+CHUP")
|
||||
|
||||
# 4.15.1 Terminate a Call Process from the HF
|
||||
async def terminate_call(self):
|
||||
# The user may abort the ongoing call process using whatever means
|
||||
# provided by the Hands-Free unit. The HF shall send AT+CHUP command
|
||||
# (see Section 4.34) to the AG, and the AG shall then start the
|
||||
# procedure to terminate or interrupt the current call procedure.
|
||||
# The AG shall then send the OK indication followed by the +CIEV result
|
||||
# code, with the value indicating (call=0).
|
||||
await self.execute_command("AT+CHUP")
|
||||
|
||||
async def update_ag_indicator(self, index: int, value: int):
|
||||
self.ag_indicators[index].current_status = value
|
||||
logger.info(
|
||||
f"AG indicator updated: {self.ag_indicators[index].description}, {value}"
|
||||
)
|
||||
|
||||
async def handle_unsolicited(self):
|
||||
"""Handle unsolicited result codes sent by the audio gateway."""
|
||||
result = await self.unsolicited_queue.get()
|
||||
if result.code == "+BCS":
|
||||
await self.setup_codec_connection(int(result.parameters[0]))
|
||||
elif result.code == "+CIEV":
|
||||
await self.update_ag_indicator(
|
||||
int(result.parameters[0]), int(result.parameters[1])
|
||||
)
|
||||
else:
|
||||
logging.info(f"unhandled unsolicited response {result.code}")
|
||||
|
||||
async def run(self):
|
||||
"""Main rountine for the Hands-Free side of the HFP protocol.
|
||||
Initiates the service level connection then loops handling
|
||||
unsolicited AG responses."""
|
||||
|
||||
try:
|
||||
await self.initiate_slc()
|
||||
while True:
|
||||
await self.handle_unsolicited()
|
||||
except Exception:
|
||||
logger.error("HFP-HF protocol failed with the following error:")
|
||||
logger.error(traceback.format_exc())
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Normative SDP definitions
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
|
||||
# Profile version (normative).
|
||||
# Hands-Free Profile v1.8, 5.3 SDP Interoperability Requirements
|
||||
class ProfileVersion(enum.IntEnum):
|
||||
V1_5 = 0x0105
|
||||
V1_6 = 0x0106
|
||||
V1_7 = 0x0107
|
||||
V1_8 = 0x0108
|
||||
V1_9 = 0x0109
|
||||
|
||||
|
||||
# HF supported features (normative).
|
||||
# Hands-Free Profile v1.8, 5.3 SDP Interoperability Requirements
|
||||
class HfSdpFeature(enum.IntFlag):
|
||||
EC_NR = 0x01 # Echo Cancel & Noise reduction
|
||||
THREE_WAY_CALLING = 0x02
|
||||
CLI_PRESENTATION_CAPABILITY = 0x04
|
||||
VOICE_RECOGNITION_ACTIVATION = 0x08
|
||||
REMOTE_VOLUME_CONTROL = 0x10
|
||||
WIDE_BAND = 0x20 # Wide band speech
|
||||
ENHANCED_VOICE_RECOGNITION_STATUS = 0x40
|
||||
VOICE_RECOGNITION_TEST = 0x80
|
||||
|
||||
|
||||
# AG supported features (normative).
|
||||
# Hands-Free Profile v1.8, 5.3 SDP Interoperability Requirements
|
||||
class AgSdpFeature(enum.IntFlag):
|
||||
THREE_WAY_CALLING = 0x01
|
||||
EC_NR = 0x02 # Echo Cancel & Noise reduction
|
||||
VOICE_RECOGNITION_FUNCTION = 0x04
|
||||
IN_BAND_RING_TONE_CAPABILITY = 0x08
|
||||
VOICE_TAG = 0x10 # Attach a number to voice tag
|
||||
WIDE_BAND = 0x20 # Wide band speech
|
||||
ENHANCED_VOICE_RECOGNITION_STATUS = 0x40
|
||||
VOICE_RECOGNITION_TEST = 0x80
|
||||
|
||||
|
||||
def sdp_records(
|
||||
service_record_handle: int, rfcomm_channel: int, configuration: Configuration
|
||||
) -> List[ServiceAttribute]:
|
||||
"""Generate the SDP record for HFP Hands-Free support.
|
||||
The record exposes the features supported in the input configuration,
|
||||
and the allocated RFCOMM channel."""
|
||||
|
||||
hf_supported_features = 0
|
||||
|
||||
if HfFeature.EC_NR in configuration.supported_hf_features:
|
||||
hf_supported_features |= HfSdpFeature.EC_NR
|
||||
if HfFeature.THREE_WAY_CALLING in configuration.supported_hf_features:
|
||||
hf_supported_features |= HfSdpFeature.THREE_WAY_CALLING
|
||||
if HfFeature.CLI_PRESENTATION_CAPABILITY in configuration.supported_hf_features:
|
||||
hf_supported_features |= HfSdpFeature.CLI_PRESENTATION_CAPABILITY
|
||||
if HfFeature.VOICE_RECOGNITION_ACTIVATION in configuration.supported_hf_features:
|
||||
hf_supported_features |= HfSdpFeature.VOICE_RECOGNITION_ACTIVATION
|
||||
if HfFeature.REMOTE_VOLUME_CONTROL in configuration.supported_hf_features:
|
||||
hf_supported_features |= HfSdpFeature.REMOTE_VOLUME_CONTROL
|
||||
if (
|
||||
HfFeature.ENHANCED_VOICE_RECOGNITION_STATUS
|
||||
in configuration.supported_hf_features
|
||||
):
|
||||
hf_supported_features |= HfSdpFeature.ENHANCED_VOICE_RECOGNITION_STATUS
|
||||
if HfFeature.VOICE_RECOGNITION_TEST in configuration.supported_hf_features:
|
||||
hf_supported_features |= HfSdpFeature.VOICE_RECOGNITION_TEST
|
||||
|
||||
if AudioCodec.MSBC in configuration.supported_audio_codecs:
|
||||
hf_supported_features |= HfSdpFeature.WIDE_BAND
|
||||
|
||||
return [
|
||||
ServiceAttribute(
|
||||
SDP_SERVICE_RECORD_HANDLE_ATTRIBUTE_ID,
|
||||
DataElement.unsigned_integer_32(service_record_handle),
|
||||
),
|
||||
ServiceAttribute(
|
||||
SDP_SERVICE_CLASS_ID_LIST_ATTRIBUTE_ID,
|
||||
DataElement.sequence(
|
||||
[
|
||||
DataElement.uuid(BT_HANDSFREE_SERVICE),
|
||||
DataElement.uuid(BT_GENERIC_AUDIO_SERVICE),
|
||||
]
|
||||
),
|
||||
),
|
||||
ServiceAttribute(
|
||||
SDP_PROTOCOL_DESCRIPTOR_LIST_ATTRIBUTE_ID,
|
||||
DataElement.sequence(
|
||||
[
|
||||
DataElement.sequence([DataElement.uuid(BT_L2CAP_PROTOCOL_ID)]),
|
||||
DataElement.sequence(
|
||||
[
|
||||
DataElement.uuid(BT_RFCOMM_PROTOCOL_ID),
|
||||
DataElement.unsigned_integer_8(rfcomm_channel),
|
||||
]
|
||||
),
|
||||
]
|
||||
),
|
||||
),
|
||||
ServiceAttribute(
|
||||
SDP_BLUETOOTH_PROFILE_DESCRIPTOR_LIST_ATTRIBUTE_ID,
|
||||
DataElement.sequence(
|
||||
[
|
||||
DataElement.sequence(
|
||||
[
|
||||
DataElement.uuid(BT_HANDSFREE_SERVICE),
|
||||
DataElement.unsigned_integer_16(ProfileVersion.V1_8),
|
||||
]
|
||||
)
|
||||
]
|
||||
),
|
||||
),
|
||||
ServiceAttribute(
|
||||
SDP_SUPPORTED_FEATURES_ATTRIBUTE_ID,
|
||||
DataElement.unsigned_integer_16(hf_supported_features),
|
||||
),
|
||||
]
|
||||
self.send_command_line('AT+CMER=3,0,0,1')
|
||||
await (self.next_line())
|
||||
|
||||
@@ -94,10 +94,6 @@ SDP_CLIENT_EXECUTABLE_URL_ATTRIBUTE_ID = 0X000B
|
||||
SDP_ICON_URL_ATTRIBUTE_ID = 0X000C
|
||||
SDP_ADDITIONAL_PROTOCOL_DESCRIPTOR_LIST_ATTRIBUTE_ID = 0X000D
|
||||
|
||||
# Attribute Identifier (cf. Assigned Numbers for Service Discovery)
|
||||
# used by AVRCP, HFP and A2DP
|
||||
SDP_SUPPORTED_FEATURES_ATTRIBUTE_ID = 0x0311
|
||||
|
||||
SDP_ATTRIBUTE_ID_NAMES = {
|
||||
SDP_SERVICE_RECORD_HANDLE_ATTRIBUTE_ID: 'SDP_SERVICE_RECORD_HANDLE_ATTRIBUTE_ID',
|
||||
SDP_SERVICE_CLASS_ID_LIST_ATTRIBUTE_ID: 'SDP_SERVICE_CLASS_ID_LIST_ATTRIBUTE_ID',
|
||||
|
||||
+3
-2
@@ -1832,8 +1832,9 @@ class Manager(EventEmitter):
|
||||
) -> None:
|
||||
# Store the keys in the key store
|
||||
if self.device.keystore and identity_address is not None:
|
||||
await self.device.keystore.update(str(identity_address), keys)
|
||||
await self.device.refresh_resolving_list()
|
||||
self.device.abort_on(
|
||||
'flush', self.device.update_keys(str(identity_address), keys)
|
||||
)
|
||||
|
||||
# Notify the device
|
||||
self.device.on_pairing(session.connection, identity_address, keys, session.sc)
|
||||
|
||||
@@ -69,6 +69,7 @@ async def open_transport(name: str) -> Transport:
|
||||
* usb
|
||||
* pyusb
|
||||
* android-emulator
|
||||
* android-netsim
|
||||
"""
|
||||
|
||||
return _wrap_transport(await _open_transport(name))
|
||||
|
||||
@@ -43,7 +43,7 @@ async def open_ws_server_transport(spec):
|
||||
def __init__(self):
|
||||
source = ParserSource()
|
||||
sink = PumpedPacketSink(self.send_packet)
|
||||
self.connection = asyncio.get_running_loop().create_future()
|
||||
self.connection = None
|
||||
self.server = None
|
||||
|
||||
super().__init__(source, sink)
|
||||
@@ -63,7 +63,7 @@ async def open_ws_server_transport(spec):
|
||||
f'new connection on {connection.local_address} '
|
||||
f'from {connection.remote_address}'
|
||||
)
|
||||
self.connection.set_result(connection)
|
||||
self.connection = connection
|
||||
# pylint: disable=no-member
|
||||
try:
|
||||
async for packet in connection:
|
||||
@@ -74,12 +74,14 @@ async def open_ws_server_transport(spec):
|
||||
except websockets.WebSocketException as error:
|
||||
logger.debug(f'exception while receiving packet: {error}')
|
||||
|
||||
# Wait for a new connection
|
||||
self.connection = asyncio.get_running_loop().create_future()
|
||||
# We're now disconnected
|
||||
self.connection = None
|
||||
|
||||
async def send_packet(self, packet):
|
||||
connection = await self.connection
|
||||
return await connection.send(packet)
|
||||
if self.connection is None:
|
||||
logger.debug('no connection, dropping packet')
|
||||
return
|
||||
return await self.connection.send(packet)
|
||||
|
||||
local_host, local_port = spec.split(':')
|
||||
transport = WsServerTransport()
|
||||
|
||||
@@ -16,11 +16,9 @@
|
||||
# Imports
|
||||
# -----------------------------------------------------------------------------
|
||||
import asyncio
|
||||
import collections
|
||||
import sys
|
||||
import os
|
||||
import logging
|
||||
from typing import Union
|
||||
|
||||
from bumble.colors import color
|
||||
|
||||
@@ -32,7 +30,6 @@ from bumble.core import (
|
||||
BT_RFCOMM_PROTOCOL_ID,
|
||||
BT_BR_EDR_TRANSPORT,
|
||||
)
|
||||
from bumble import rfcomm
|
||||
from bumble.rfcomm import Client
|
||||
from bumble.sdp import (
|
||||
Client as SDP_Client,
|
||||
@@ -42,64 +39,7 @@ from bumble.sdp import (
|
||||
SDP_SERVICE_CLASS_ID_LIST_ATTRIBUTE_ID,
|
||||
SDP_BLUETOOTH_PROFILE_DESCRIPTOR_LIST_ATTRIBUTE_ID,
|
||||
)
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Protocol Support
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
class HfpProtocol:
|
||||
dlc: rfcomm.DLC
|
||||
buffer: str
|
||||
lines: collections.deque
|
||||
lines_available: asyncio.Event
|
||||
|
||||
def __init__(self, dlc: rfcomm.DLC) -> None:
|
||||
self.dlc = dlc
|
||||
self.buffer = ''
|
||||
self.lines = collections.deque()
|
||||
self.lines_available = asyncio.Event()
|
||||
|
||||
dlc.sink = self.feed
|
||||
|
||||
def feed(self, data: Union[bytes, str]) -> None:
|
||||
# Convert the data to a string if needed
|
||||
if isinstance(data, bytes):
|
||||
data = data.decode('utf-8')
|
||||
|
||||
logger.debug(f'<<< Data received: {data}')
|
||||
|
||||
# Add to the buffer and look for lines
|
||||
self.buffer += data
|
||||
while (separator := self.buffer.find('\r')) >= 0:
|
||||
line = self.buffer[:separator].strip()
|
||||
self.buffer = self.buffer[separator + 1 :]
|
||||
if len(line) > 0:
|
||||
self.on_line(line)
|
||||
|
||||
def on_line(self, line: str) -> None:
|
||||
self.lines.append(line)
|
||||
self.lines_available.set()
|
||||
|
||||
def send_command_line(self, line: str) -> None:
|
||||
logger.debug(color(f'>>> {line}', 'yellow'))
|
||||
self.dlc.write(line + '\r')
|
||||
|
||||
def send_response_line(self, line: str) -> None:
|
||||
logger.debug(color(f'>>> {line}', 'yellow'))
|
||||
self.dlc.write('\r\n' + line + '\r\n')
|
||||
|
||||
async def next_line(self) -> str:
|
||||
await self.lines_available.wait()
|
||||
line = self.lines.popleft()
|
||||
if not self.lines:
|
||||
self.lines_available.clear()
|
||||
logger.debug(color(f'<<< {line}', 'green'))
|
||||
return line
|
||||
from bumble.hfp import HfpProtocol
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
@@ -21,22 +21,82 @@ import os
|
||||
import logging
|
||||
import json
|
||||
import websockets
|
||||
from typing import Optional
|
||||
|
||||
|
||||
from bumble.device import Device
|
||||
from bumble.transport import open_transport_or_link
|
||||
from bumble.rfcomm import Server as RfcommServer
|
||||
from bumble import hfp
|
||||
from bumble.hfp import HfProtocol
|
||||
from bumble.sdp import (
|
||||
DataElement,
|
||||
ServiceAttribute,
|
||||
SDP_SERVICE_RECORD_HANDLE_ATTRIBUTE_ID,
|
||||
SDP_SERVICE_CLASS_ID_LIST_ATTRIBUTE_ID,
|
||||
SDP_PROTOCOL_DESCRIPTOR_LIST_ATTRIBUTE_ID,
|
||||
SDP_BLUETOOTH_PROFILE_DESCRIPTOR_LIST_ATTRIBUTE_ID,
|
||||
)
|
||||
from bumble.core import (
|
||||
BT_GENERIC_AUDIO_SERVICE,
|
||||
BT_HANDSFREE_SERVICE,
|
||||
BT_L2CAP_PROTOCOL_ID,
|
||||
BT_RFCOMM_PROTOCOL_ID,
|
||||
)
|
||||
from bumble.hfp import HfpProtocol
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
def make_sdp_records(rfcomm_channel):
|
||||
return {
|
||||
0x00010001: [
|
||||
ServiceAttribute(
|
||||
SDP_SERVICE_RECORD_HANDLE_ATTRIBUTE_ID,
|
||||
DataElement.unsigned_integer_32(0x00010001),
|
||||
),
|
||||
ServiceAttribute(
|
||||
SDP_SERVICE_CLASS_ID_LIST_ATTRIBUTE_ID,
|
||||
DataElement.sequence(
|
||||
[
|
||||
DataElement.uuid(BT_HANDSFREE_SERVICE),
|
||||
DataElement.uuid(BT_GENERIC_AUDIO_SERVICE),
|
||||
]
|
||||
),
|
||||
),
|
||||
ServiceAttribute(
|
||||
SDP_PROTOCOL_DESCRIPTOR_LIST_ATTRIBUTE_ID,
|
||||
DataElement.sequence(
|
||||
[
|
||||
DataElement.sequence([DataElement.uuid(BT_L2CAP_PROTOCOL_ID)]),
|
||||
DataElement.sequence(
|
||||
[
|
||||
DataElement.uuid(BT_RFCOMM_PROTOCOL_ID),
|
||||
DataElement.unsigned_integer_8(rfcomm_channel),
|
||||
]
|
||||
),
|
||||
]
|
||||
),
|
||||
),
|
||||
ServiceAttribute(
|
||||
SDP_BLUETOOTH_PROFILE_DESCRIPTOR_LIST_ATTRIBUTE_ID,
|
||||
DataElement.sequence(
|
||||
[
|
||||
DataElement.sequence(
|
||||
[
|
||||
DataElement.uuid(BT_HANDSFREE_SERVICE),
|
||||
DataElement.unsigned_integer_16(0x0105),
|
||||
]
|
||||
)
|
||||
]
|
||||
),
|
||||
),
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
class UiServer:
|
||||
protocol: Optional[HfProtocol] = None
|
||||
protocol = None
|
||||
|
||||
async def start(self):
|
||||
"""Start a Websocket server to receive events from a web page."""
|
||||
|
||||
# Start a Websocket server to receive events from a web page
|
||||
async def serve(websocket, _path):
|
||||
while True:
|
||||
try:
|
||||
@@ -47,7 +107,7 @@ class UiServer:
|
||||
message_type = parsed['type']
|
||||
if message_type == 'at_command':
|
||||
if self.protocol is not None:
|
||||
await self.protocol.execute_command(parsed['command'])
|
||||
self.protocol.send_command_line(parsed['command'])
|
||||
|
||||
except websockets.exceptions.ConnectionClosedOK:
|
||||
pass
|
||||
@@ -57,11 +117,19 @@ class UiServer:
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
def on_dlc(dlc, configuration: hfp.Configuration):
|
||||
async def protocol_loop(protocol):
|
||||
await protocol.initialize_service()
|
||||
|
||||
while True:
|
||||
await (protocol.next_line())
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
def on_dlc(dlc):
|
||||
print('*** DLC connected', dlc)
|
||||
protocol = HfProtocol(dlc, configuration)
|
||||
protocol = HfpProtocol(dlc)
|
||||
UiServer.protocol = protocol
|
||||
asyncio.create_task(protocol.run())
|
||||
asyncio.create_task(protocol_loop(protocol))
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
@@ -75,27 +143,6 @@ async def main():
|
||||
async with await open_transport_or_link(sys.argv[2]) as (hci_source, hci_sink):
|
||||
print('<<< connected')
|
||||
|
||||
# Hands-Free profile configuration.
|
||||
# TODO: load configuration from file.
|
||||
configuration = hfp.Configuration(
|
||||
supported_hf_features=[
|
||||
hfp.HfFeature.THREE_WAY_CALLING,
|
||||
hfp.HfFeature.REMOTE_VOLUME_CONTROL,
|
||||
hfp.HfFeature.ENHANCED_CALL_STATUS,
|
||||
hfp.HfFeature.ENHANCED_CALL_CONTROL,
|
||||
hfp.HfFeature.CODEC_NEGOTIATION,
|
||||
hfp.HfFeature.HF_INDICATORS,
|
||||
hfp.HfFeature.ESCO_S4_SETTINGS_SUPPORTED,
|
||||
],
|
||||
supported_hf_indicators=[
|
||||
hfp.HfIndicator.BATTERY_LEVEL,
|
||||
],
|
||||
supported_audio_codecs=[
|
||||
hfp.AudioCodec.CVSD,
|
||||
hfp.AudioCodec.MSBC,
|
||||
],
|
||||
)
|
||||
|
||||
# Create a device
|
||||
device = Device.from_config_file_with_hci(sys.argv[1], hci_source, hci_sink)
|
||||
device.classic_enabled = True
|
||||
@@ -104,13 +151,11 @@ async def main():
|
||||
rfcomm_server = RfcommServer(device)
|
||||
|
||||
# Listen for incoming DLC connections
|
||||
channel_number = rfcomm_server.listen(lambda dlc: on_dlc(dlc, configuration))
|
||||
channel_number = rfcomm_server.listen(on_dlc)
|
||||
print(f'### Listening for connection on channel {channel_number}')
|
||||
|
||||
# Advertise the HFP RFComm channel in the SDP
|
||||
device.sdp_service_records = {
|
||||
0x00010001: hfp.sdp_records(0x00010001, channel_number, configuration)
|
||||
}
|
||||
device.sdp_service_records = make_sdp_records(channel_number)
|
||||
|
||||
# Let's go!
|
||||
await device.power_on()
|
||||
|
||||
@@ -32,17 +32,17 @@ package_dir =
|
||||
include_package_data = True
|
||||
install_requires =
|
||||
aiohttp ~= 3.8; platform_system!='Emscripten'
|
||||
appdirs >= 1.4
|
||||
bt-test-interfaces >= 0.0.2
|
||||
appdirs >= 1.4; platform_system!='Emscripten'
|
||||
bt-test-interfaces >= 0.0.2; platform_system!='Emscripten'
|
||||
click == 8.1.3; platform_system!='Emscripten'
|
||||
cryptography == 35; platform_system!='Emscripten'
|
||||
cryptography == 39; platform_system!='Emscripten'
|
||||
grpcio == 1.51.1; platform_system!='Emscripten'
|
||||
humanize >= 4.6.0
|
||||
humanize >= 4.6.0; platform_system!='Emscripten'
|
||||
libusb1 >= 2.0.1; platform_system!='Emscripten'
|
||||
libusb-package == 1.0.26.1; platform_system!='Emscripten'
|
||||
prompt_toolkit >= 3.0.16; platform_system!='Emscripten'
|
||||
prettytable >= 3.6.0
|
||||
protobuf >= 3.12.4
|
||||
prettytable >= 3.6.0; platform_system!='Emscripten'
|
||||
protobuf >= 3.12.4; platform_system!='Emscripten'
|
||||
pyee >= 8.2.2
|
||||
pyserial-asyncio >= 0.5; platform_system!='Emscripten'
|
||||
pyserial >= 3.5; platform_system!='Emscripten'
|
||||
|
||||
@@ -1,28 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<title>Audio WAV Player</title>
|
||||
</head>
|
||||
<body>
|
||||
<h1>Audio WAV Player</h1>
|
||||
<audio id="audioPlayer" controls>
|
||||
<source src="" type="audio/wav">
|
||||
</audio>
|
||||
|
||||
<script>
|
||||
const audioPlayer = document.getElementById('audioPlayer');
|
||||
const ws = new WebSocket('ws://localhost:8080');
|
||||
|
||||
let mediaSource = new MediaSource();
|
||||
audioPlayer.src = URL.createObjectURL(mediaSource);
|
||||
|
||||
mediaSource.addEventListener('sourceopen', function(event) {
|
||||
const sourceBuffer = mediaSource.addSourceBuffer('audio/wav');
|
||||
|
||||
ws.onmessage = function(event) {
|
||||
sourceBuffer.appendBuffer(event.data);
|
||||
};
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -177,3 +177,33 @@ project_tasks.add_task(lint)
|
||||
project_tasks.add_task(format_code, name="format")
|
||||
project_tasks.add_task(check_types, name="check-types")
|
||||
project_tasks.add_task(pre_commit)
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Web
|
||||
# -----------------------------------------------------------------------------
|
||||
web_tasks = Collection()
|
||||
ns.add_collection(web_tasks, name="web")
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
@task
|
||||
def serve(ctx, port=8000):
|
||||
"""
|
||||
Run a simple HTTP server for the examples under the `web` directory.
|
||||
"""
|
||||
import http.server
|
||||
|
||||
address = ("", port)
|
||||
|
||||
class Handler(http.server.SimpleHTTPRequestHandler):
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, directory="web", **kwargs)
|
||||
|
||||
server = http.server.HTTPServer(address, Handler)
|
||||
print(f"Now serving on port {port} 🕸️")
|
||||
server.serve_forever()
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
web_tasks.add_task(serve)
|
||||
|
||||
@@ -1,35 +0,0 @@
|
||||
# Copyright 2023 Google LLC
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# https://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
|
||||
from bumble import at
|
||||
|
||||
|
||||
def test_tokenize_parameters():
|
||||
assert at.tokenize_parameters(b'1, 2, 3') == [b'1', b',', b'2', b',', b'3']
|
||||
assert at.tokenize_parameters(b'"1, 2, 3"') == [b'1, 2, 3']
|
||||
assert at.tokenize_parameters(b'(1, "2, 3")') == [b'(', b'1', b',', b'2, 3', b')']
|
||||
|
||||
|
||||
def test_parse_parameters():
|
||||
assert at.parse_parameters(b'1, 2, 3') == [b'1', b'2', b'3']
|
||||
assert at.parse_parameters(b'1,, 3') == [b'1', b'', b'3']
|
||||
assert at.parse_parameters(b'"1, 2, 3"') == [b'1, 2, 3']
|
||||
assert at.parse_parameters(b'1, (2, (3))') == [b'1', [b'2', [b'3']]]
|
||||
assert at.parse_parameters(b'1, (2, "3, 4"), 5') == [b'1', [b'2', b'3, 4'], b'5']
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
if __name__ == '__main__':
|
||||
test_tokenize_parameters()
|
||||
test_parse_parameters()
|
||||
@@ -0,0 +1,48 @@
|
||||
Bumble For Web Browsers
|
||||
=======================
|
||||
|
||||
Early prototype the consists of running the Bumble stack in a web browser
|
||||
environment, using [pyodide](https://pyodide.org/)
|
||||
|
||||
Two examples are included here:
|
||||
|
||||
* scanner - a simple scanner
|
||||
* speaker - a pure-web-based version of the Speaker app
|
||||
|
||||
Both examples rely on the shared code in `bumble.js`.
|
||||
|
||||
Running The Examples
|
||||
--------------------
|
||||
|
||||
To run the examples, you will need an HTTP server to serve the HTML and JS files, and
|
||||
and a WebSocket server serving an HCI transport.
|
||||
|
||||
For HCI over WebSocket, recent versions of the `netsim` virtual controller support it,
|
||||
or you may use the Bumble HCI Bridge app to bridge a WebSocket server to a virtual
|
||||
controller using some other transport (ex: `python apps/hci_bridge.py ws-server:_:9999 usb:0`).
|
||||
|
||||
For HTTP, start an HTTP server with the `web` directory as its
|
||||
root. You can use the invoke task `inv web.serve` for convenience.
|
||||
|
||||
In a browser, open either `scanner/scanner.html` or `speaker/speaker.html`.
|
||||
You can pass optional query parameters:
|
||||
|
||||
* `package` may be set to point to a local build of Bumble (`.whl` files).
|
||||
The filename must be URL-encoded of course, and must be located under
|
||||
the `web` directory (the HTTP server won't serve files not under its
|
||||
root directory).
|
||||
* `hci` may be set to specify a non-default WebSocket URL to use as the HCI
|
||||
transport (the default is: `"ws://localhost:9922/hci`). This also needs
|
||||
to be URL-encoded.
|
||||
|
||||
Example:
|
||||
With a local HTTP server running on port 8000, to run the `scanner` example
|
||||
with a locally-built Bumble package `../bumble-0.0.163.dev5+g6f832b6.d20230812-py3-none-any.whl`
|
||||
(assuming that `bumble-0.0.163.dev5+g6f832b6.d20230812-py3-none-any.whl` exists under the `web`
|
||||
directory and the HCI WebSocket transport at `ws://localhost:9999/hci`, the URL with the
|
||||
URL-encoded query parameters would be:
|
||||
`http://localhost:8000/scanner/scanner.html?hci=ws%3A%2F%2Flocalhost%3A9999%2Fhci&package=..%2Fbumble-0.0.163.dev5%2Bg6f832b6.d20230812-py3-none-any.whl`
|
||||
|
||||
|
||||
NOTE: to get a local build of the Bumble package, use `inv build`, the built `.whl` file can be found in the `dist` directory.
|
||||
Make a copy of the built `.whl` file in the `web` directory.
|
||||
@@ -0,0 +1,92 @@
|
||||
function bufferToHex(buffer) {
|
||||
return [...new Uint8Array(buffer)].map(x => x.toString(16).padStart(2, '0')).join('');
|
||||
}
|
||||
|
||||
class PacketSource {
|
||||
constructor(pyodide) {
|
||||
this.parser = pyodide.runPython(`
|
||||
from bumble.transport.common import PacketParser
|
||||
class ProxiedPacketParser(PacketParser):
|
||||
def feed_data(self, js_data):
|
||||
super().feed_data(bytes(js_data.to_py()))
|
||||
ProxiedPacketParser()
|
||||
`);
|
||||
}
|
||||
|
||||
set_packet_sink(sink) {
|
||||
this.parser.set_packet_sink(sink);
|
||||
}
|
||||
|
||||
data_received(data) {
|
||||
console.log(`HCI[controller->host]: ${bufferToHex(data)}`);
|
||||
this.parser.feed_data(data);
|
||||
}
|
||||
}
|
||||
|
||||
class PacketSink {
|
||||
constructor(writer) {
|
||||
this.writer = writer;
|
||||
}
|
||||
|
||||
on_packet(packet) {
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
export async function connectWebSocketTransport(pyodide, hciWsUrl) {
|
||||
return new Promise((resolve, reject) => {
|
||||
let resolved = false;
|
||||
|
||||
let ws = new WebSocket(hciWsUrl);
|
||||
ws.binaryType = "arraybuffer";
|
||||
|
||||
ws.onopen = () => {
|
||||
console.log("WebSocket open");
|
||||
resolve({
|
||||
packet_source,
|
||||
packet_sink
|
||||
});
|
||||
resolved = true;
|
||||
}
|
||||
|
||||
ws.onclose = () => {
|
||||
console.log("WebSocket close");
|
||||
if (!resolved) {
|
||||
reject(`Failed to connect to ${hciWsUrl}`)
|
||||
}
|
||||
}
|
||||
|
||||
ws.onmessage = (event) => {
|
||||
packet_source.data_received(event.data);
|
||||
}
|
||||
|
||||
const packet_source = new PacketSource(pyodide);
|
||||
const packet_sink = new PacketSink((packet) => ws.send(packet));
|
||||
})
|
||||
}
|
||||
|
||||
export async function loadBumble(pyodide, bumblePackage) {
|
||||
// Load the Bumble module
|
||||
await pyodide.loadPackage("micropip");
|
||||
await pyodide.runPythonAsync(`
|
||||
import micropip
|
||||
await micropip.install("cryptography")
|
||||
await micropip.install("${bumblePackage}")
|
||||
package_list = micropip.list()
|
||||
print(package_list)
|
||||
`)
|
||||
|
||||
// Mount a filesystem so that we can persist data like the Key Store
|
||||
let mountDir = "/bumble";
|
||||
pyodide.FS.mkdir(mountDir);
|
||||
pyodide.FS.mount(pyodide.FS.filesystems.IDBFS, { root: "." }, mountDir);
|
||||
|
||||
// Sync previously persisted filesystem data into memory
|
||||
pyodide.FS.syncfs(true, () => {
|
||||
console.log("FS synced in")
|
||||
});
|
||||
}
|
||||
-131
@@ -1,131 +0,0 @@
|
||||
<html>
|
||||
<head>
|
||||
<script src="https://cdn.jsdelivr.net/pyodide/v0.19.1/full/pyodide.js"></script>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<button onclick="runUSB()">USB</button>
|
||||
<button onclick="runSerial()">Serial</button>
|
||||
<br />
|
||||
<br />
|
||||
<div>Output:</div>
|
||||
<textarea id="output" style="width: 100%;" rows="30" disabled></textarea>
|
||||
|
||||
<script>
|
||||
function bufferToHex(buffer) {
|
||||
return [...new Uint8Array(buffer)].map(x => x.toString(16).padStart(2, '0')).join('');
|
||||
}
|
||||
|
||||
const output = document.getElementById("output");
|
||||
const code = document.getElementById("code");
|
||||
|
||||
function addToOutput(s) {
|
||||
output.value += s + "\n";
|
||||
}
|
||||
|
||||
output.value = "Initializing...\n";
|
||||
|
||||
async function main() {
|
||||
let pyodide = await loadPyodide({
|
||||
indexURL: "https://cdn.jsdelivr.net/pyodide/v0.19.1/full/",
|
||||
})
|
||||
output.value += "Ready!\n"
|
||||
|
||||
return pyodide;
|
||||
}
|
||||
|
||||
let pyodideReadyPromise = main();
|
||||
|
||||
async function readLoop(port, packet_source) {
|
||||
const reader = port.readable.getReader()
|
||||
try {
|
||||
while (true) {
|
||||
console.log('@@@ Reading...')
|
||||
const { done, value } = await reader.read()
|
||||
if (done) {
|
||||
console.log("--- DONE!")
|
||||
break
|
||||
}
|
||||
|
||||
console.log('@@@ Serial data:', bufferToHex(value))
|
||||
if (packet_source.delegate !== undefined) {
|
||||
packet_source.delegate.data_received(value)
|
||||
} else {
|
||||
console.warn('@@@ delegate not set yet, dropping data')
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
} finally {
|
||||
reader.releaseLock()
|
||||
}
|
||||
}
|
||||
|
||||
async function runUSB() {
|
||||
const device = await navigator.usb.requestDevice({
|
||||
filters: [
|
||||
{
|
||||
classCode: 0xE0,
|
||||
subclassCode: 0x01
|
||||
}
|
||||
]
|
||||
});
|
||||
|
||||
if (device.configuration === null) {
|
||||
await device.selectConfiguration(1);
|
||||
}
|
||||
await device.claimInterface(0)
|
||||
}
|
||||
|
||||
async function runSerial() {
|
||||
const ports = await navigator.serial.getPorts()
|
||||
console.log('Paired ports:', ports)
|
||||
|
||||
const port = await navigator.serial.requestPort()
|
||||
await port.open({ baudRate: 1000000 })
|
||||
const writer = port.writable.getWriter()
|
||||
}
|
||||
|
||||
async function run() {
|
||||
|
||||
let pyodide = await pyodideReadyPromise;
|
||||
try {
|
||||
const script = await(await fetch('scanner.py')).text()
|
||||
await pyodide.loadPackage('micropip')
|
||||
await pyodide.runPythonAsync(`
|
||||
import micropip
|
||||
await micropip.install('../dist/bumble-0.0.36.dev0+g3adbfe7.d20210807-py3-none-any.whl')
|
||||
`)
|
||||
let output = await pyodide.runPythonAsync(script)
|
||||
addToOutput(output)
|
||||
|
||||
const pythonMain = pyodide.globals.get('main')
|
||||
const packet_source = {}
|
||||
const packet_sink = {
|
||||
on_packet: (packet) => {
|
||||
// Variant A, with the conversion done in Javascript
|
||||
const buffer = packet.toJs()
|
||||
console.log(`$$$ on_packet: ${bufferToHex(buffer)}`)
|
||||
// TODO: create an sync queue here instead of blindly calling write without awaiting
|
||||
/*await*/ writer.write(buffer)
|
||||
packet.destroy()
|
||||
|
||||
// Variant B, with the conversion `to_js` done at the Python layer
|
||||
// console.log(`$$$ on_packet: ${bufferToHex(packet)}`)
|
||||
// /*await*/ writer.write(packet)
|
||||
}
|
||||
}
|
||||
serialLooper = readLoop(port, packet_source)
|
||||
pythonResult = await pythonMain(packet_source, packet_sink)
|
||||
console.log(pythonResult)
|
||||
serialResult = await serialLooper
|
||||
writer.releaseLock()
|
||||
await port.close()
|
||||
console.log('### done')
|
||||
} catch (err) {
|
||||
addToOutput(err);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,129 @@
|
||||
<html>
|
||||
|
||||
<head>
|
||||
<script src="https://cdn.jsdelivr.net/pyodide/v0.23.2/full/pyodide.js"></script>
|
||||
<style>
|
||||
body {
|
||||
font-family: monospace;
|
||||
}
|
||||
|
||||
table, th, td {
|
||||
padding: 2px;
|
||||
white-space: pre;
|
||||
border: 1px solid black;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<button id="connectButton" disabled>Connect</button>
|
||||
<br />
|
||||
<br />
|
||||
<div>Log Output</div><br>
|
||||
<textarea id="output" style="width: 100%;" rows="10" disabled></textarea>
|
||||
<div id="scanTableContainer"><table></table></div>
|
||||
|
||||
<script type="module">
|
||||
import { loadBumble, connectWebSocketTransport } from "../bumble.js"
|
||||
let pyodide;
|
||||
let output;
|
||||
|
||||
function logToOutput(s) {
|
||||
output.value += s + "\n";
|
||||
console.log(s);
|
||||
}
|
||||
|
||||
async function run() {
|
||||
const params = (new URL(document.location)).searchParams;
|
||||
const hciWsUrl = params.get("hci") || "ws://localhost:9922/hci";
|
||||
|
||||
try {
|
||||
// Create a WebSocket HCI transport
|
||||
let transport
|
||||
try {
|
||||
transport = await connectWebSocketTransport(pyodide, hciWsUrl);
|
||||
} catch (error) {
|
||||
logToOutput(error);
|
||||
return;
|
||||
}
|
||||
|
||||
// Run the scanner example
|
||||
const script = await (await fetch("scanner.py")).text();
|
||||
await pyodide.runPythonAsync(script);
|
||||
const pythonMain = pyodide.globals.get("main");
|
||||
logToOutput("Starting scanner...");
|
||||
await pythonMain(transport.packet_source, transport.packet_sink, onScanUpdate);
|
||||
logToOutput("Scanner running");
|
||||
} catch (err) {
|
||||
logToOutput(err);
|
||||
}
|
||||
}
|
||||
|
||||
function onScanUpdate(scanEntries) {
|
||||
scanEntries = scanEntries.toJs();
|
||||
|
||||
const scanTable = document.createElement("table");
|
||||
|
||||
const tableHeader = document.createElement("tr");
|
||||
for (const name of ["Address", "Address Type", "RSSI", "Data"]) {
|
||||
const header = document.createElement("th");
|
||||
header.appendChild(document.createTextNode(name));
|
||||
tableHeader.appendChild(header);
|
||||
}
|
||||
scanTable.appendChild(tableHeader);
|
||||
|
||||
scanEntries.forEach(entry => {
|
||||
const row = document.createElement("tr");
|
||||
|
||||
const addressCell = document.createElement("td");
|
||||
addressCell.appendChild(document.createTextNode(entry.address));
|
||||
row.appendChild(addressCell);
|
||||
|
||||
const addressTypeCell = document.createElement("td");
|
||||
addressTypeCell.appendChild(document.createTextNode(entry.address_type));
|
||||
row.appendChild(addressTypeCell);
|
||||
|
||||
const rssiCell = document.createElement("td");
|
||||
rssiCell.appendChild(document.createTextNode(entry.rssi));
|
||||
row.appendChild(rssiCell);
|
||||
|
||||
const dataCell = document.createElement("td");
|
||||
dataCell.appendChild(document.createTextNode(entry.data));
|
||||
row.appendChild(dataCell);
|
||||
|
||||
scanTable.appendChild(row);
|
||||
});
|
||||
|
||||
const scanTableContainer = document.getElementById("scanTableContainer");
|
||||
scanTableContainer.replaceChild(scanTable, scanTableContainer.firstChild);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
async function main() {
|
||||
output = document.getElementById("output");
|
||||
|
||||
// Load pyodide
|
||||
logToOutput("Loading Pyodide");
|
||||
pyodide = await loadPyodide();
|
||||
|
||||
// Load Bumble
|
||||
logToOutput("Loading Bumble");
|
||||
const params = (new URL(document.location)).searchParams;
|
||||
const bumblePackage = params.get("package") || "bumble";
|
||||
await loadBumble(pyodide, bumblePackage);
|
||||
|
||||
logToOutput("Ready!")
|
||||
|
||||
// Enable the Connect button
|
||||
const connectButton = document.getElementById("connectButton");
|
||||
connectButton.disabled = false
|
||||
connectButton.addEventListener("click", run)
|
||||
}
|
||||
|
||||
main();
|
||||
</script>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
@@ -15,50 +15,38 @@
|
||||
# -----------------------------------------------------------------------------
|
||||
# Imports
|
||||
# -----------------------------------------------------------------------------
|
||||
import time
|
||||
|
||||
from bumble.device import Device
|
||||
from bumble.transport.common import PacketParser
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
class ScanEntry:
|
||||
def __init__(self, advertisement):
|
||||
self.address = str(advertisement.address).replace("/P", "")
|
||||
self.address_type = ('Public', 'Random', 'Public Identity', 'Random Identity')[
|
||||
advertisement.address.address_type
|
||||
]
|
||||
self.rssi = advertisement.rssi
|
||||
self.data = advertisement.data.to_string("\n")
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
class ScannerListener(Device.Listener):
|
||||
def __init__(self, callback):
|
||||
self.callback = callback
|
||||
self.entries = {}
|
||||
|
||||
def on_advertisement(self, advertisement):
|
||||
address_type_string = ('P', 'R', 'PI', 'RI')[advertisement.address.address_type]
|
||||
print(
|
||||
f'>>> {advertisement.address} [{address_type_string}]: RSSI={advertisement.rssi}, {advertisement.ad_data}'
|
||||
)
|
||||
|
||||
|
||||
class HciSource:
|
||||
def __init__(self, host_source):
|
||||
self.parser = PacketParser()
|
||||
host_source.delegate = self
|
||||
|
||||
def set_packet_sink(self, sink):
|
||||
self.parser.set_packet_sink(sink)
|
||||
|
||||
# host source delegation
|
||||
def data_received(self, data):
|
||||
print('*** DATA from JS:', data)
|
||||
buffer = bytes(data.to_py())
|
||||
self.parser.feed_data(buffer)
|
||||
|
||||
|
||||
# class HciSink:
|
||||
# def __init__(self, host_sink):
|
||||
# self.host_sink = host_sink
|
||||
|
||||
# def on_packet(self, packet):
|
||||
# print(f'>>> PACKET from Python: {packet}')
|
||||
# self.host_sink.on_packet(packet)
|
||||
self.entries[advertisement.address] = ScanEntry(advertisement)
|
||||
self.callback(list(self.entries.values()))
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
async def main(host_source, host_sink):
|
||||
async def main(hci_source, hci_sink, callback):
|
||||
print('### Starting Scanner')
|
||||
hci_source = HciSource(host_source)
|
||||
hci_sink = host_sink
|
||||
device = Device.with_hci('Bumble', 'F0:F1:F2:F3:F4:F5', hci_source, hci_sink)
|
||||
device.listener = ScannerListener()
|
||||
device.listener = ScannerListener(callback)
|
||||
await device.power_on()
|
||||
await device.start_scanning()
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
<?xml version="1.0" encoding="UTF-8" standalone="no"?> <!-- Created with Vectornator for iOS (http://vectornator.io/) --><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
|
||||
<svg height="100%" style="fill-rule:nonzero;clip-rule:evenodd;stroke-linecap:round;stroke-linejoin:round;" xmlns:xlink="http://www.w3.org/1999/xlink" xmlns="http://www.w3.org/2000/svg" xml:space="preserve" width="100%" xmlns:vectornator="http://vectornator.io" version="1.1" viewBox="0 0 745 744.634">
|
||||
<metadata>
|
||||
<vectornator:setting key="DimensionsVisible" value="1"/>
|
||||
<vectornator:setting key="PencilOnly" value="0"/>
|
||||
<vectornator:setting key="SnapToPoints" value="0"/>
|
||||
<vectornator:setting key="OutlineMode" value="0"/>
|
||||
<vectornator:setting key="CMYKEnabledKey" value="0"/>
|
||||
<vectornator:setting key="RulersVisible" value="1"/>
|
||||
<vectornator:setting key="SnapToEdges" value="0"/>
|
||||
<vectornator:setting key="GuidesVisible" value="1"/>
|
||||
<vectornator:setting key="DisplayWhiteBackground" value="0"/>
|
||||
<vectornator:setting key="doHistoryDisabled" value="0"/>
|
||||
<vectornator:setting key="SnapToGuides" value="1"/>
|
||||
<vectornator:setting key="TimeLapseWatermarkDisabled" value="0"/>
|
||||
<vectornator:setting key="Units" value="Pixels"/>
|
||||
<vectornator:setting key="DynamicGuides" value="0"/>
|
||||
<vectornator:setting key="IsolateActiveLayer" value="0"/>
|
||||
<vectornator:setting key="SnapToGrid" value="0"/>
|
||||
</metadata>
|
||||
<defs/>
|
||||
<g id="Layer 1" vectornator:layerName="Layer 1">
|
||||
<path stroke="#000000" stroke-width="18.6464" d="M368.753+729.441L58.8847+550.539L58.8848+192.734L368.753+13.8313L678.621+192.734L678.621+550.539L368.753+729.441Z" fill="#0082fc" stroke-linecap="butt" fill-opacity="0.307489" opacity="1" stroke-linejoin="round"/>
|
||||
<g opacity="1">
|
||||
<g opacity="1">
|
||||
<path stroke="#000000" stroke-width="20" d="M292.873+289.256L442.872+289.256L442.872+539.254L292.873+539.254L292.873+289.256Z" fill="#fcd100" stroke-linecap="butt" opacity="1" stroke-linejoin="round"/>
|
||||
<path stroke="#000000" stroke-width="20" d="M292.873+289.256C292.873+247.835+326.452+214.257+367.873+214.257C409.294+214.257+442.872+247.835+442.872+289.256C442.872+330.677+409.294+364.256+367.873+364.256C326.452+364.256+292.873+330.677+292.873+289.256Z" fill="#fcd100" stroke-linecap="butt" opacity="1" stroke-linejoin="round"/>
|
||||
<path stroke="#000000" stroke-width="20" d="M292.873+539.254C292.873+497.833+326.452+464.255+367.873+464.255C409.294+464.255+442.872+497.833+442.872+539.254C442.872+580.675+409.294+614.254+367.873+614.254C326.452+614.254+292.873+580.675+292.873+539.254Z" fill="#fcd100" stroke-linecap="butt" opacity="1" stroke-linejoin="round"/>
|
||||
<path stroke="#0082fc" stroke-width="0.1" d="M302.873+289.073L432.872+289.073L432.872+539.072L302.873+539.072L302.873+289.073Z" fill="#fcd100" stroke-linecap="butt" opacity="1" stroke-linejoin="round"/>
|
||||
</g>
|
||||
<path stroke="#000000" stroke-width="0.1" d="M103.161+309.167L226.956+443.903L366.671+309.604L103.161+309.167Z" fill="#0082fc" stroke-linecap="round" opacity="1" stroke-linejoin="round"/>
|
||||
<path stroke="#000000" stroke-width="0.1" d="M383.411+307.076L508.887+440.112L650.5+307.507L383.411+307.076Z" fill="#0082fc" stroke-linecap="round" opacity="1" stroke-linejoin="round"/>
|
||||
<path stroke="#000000" stroke-width="20" d="M522.045+154.808L229.559+448.882L83.8397+300.104L653.666+302.936L511.759+444.785L223.101+156.114" fill="none" stroke-linecap="round" opacity="1" stroke-linejoin="round"/>
|
||||
<path stroke="#000000" stroke-width="61.8698" d="M295.857+418.738L438.9+418.738" fill="none" stroke-linecap="butt" opacity="1" stroke-linejoin="round"/>
|
||||
<path stroke="#000000" stroke-width="61.8698" d="M295.857+521.737L438.9+521.737" fill="none" stroke-linecap="butt" opacity="1" stroke-linejoin="round"/>
|
||||
<g opacity="1">
|
||||
<path stroke="#0082fc" stroke-width="0.1" d="M367.769+667.024L367.821+616.383L403.677+616.336C383.137+626.447+368.263+638.69+367.769+667.024Z" fill="#000000" stroke-linecap="butt" opacity="1" stroke-linejoin="round"/>
|
||||
<path stroke="#0082fc" stroke-width="0.1" d="M367.836+667.024L367.784+616.383L331.928+616.336C352.468+626.447+367.341+638.69+367.836+667.024Z" fill="#000000" stroke-linecap="butt" opacity="1" stroke-linejoin="round"/>
|
||||
</g>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 4.1 KiB |
@@ -0,0 +1,76 @@
|
||||
body, h1, h2, h3, h4, h5, h6 {
|
||||
font-family: sans-serif;
|
||||
}
|
||||
|
||||
#controlsDiv {
|
||||
margin: 6px;
|
||||
}
|
||||
|
||||
#errorText {
|
||||
background-color: rgb(239, 89, 75);
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
padding: 8px;
|
||||
display: inline-block;
|
||||
margin: 4px;
|
||||
}
|
||||
|
||||
#startButton {
|
||||
padding: 4px;
|
||||
margin: 6px;
|
||||
}
|
||||
|
||||
#fftCanvas {
|
||||
border-radius: 16px;
|
||||
margin: 6px;
|
||||
}
|
||||
|
||||
#bandwidthCanvas {
|
||||
border: grey;
|
||||
border-style: solid;
|
||||
border-radius: 8px;
|
||||
margin: 6px;
|
||||
}
|
||||
|
||||
#streamStateText {
|
||||
background-color: rgb(93, 165, 93);
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
padding: 10px 20px;
|
||||
display: inline-block;
|
||||
margin: 6px;
|
||||
}
|
||||
|
||||
#connectionStateText {
|
||||
background-color: rgb(112, 146, 206);
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
padding: 10px 20px;
|
||||
display: inline-block;
|
||||
margin: 6px;
|
||||
}
|
||||
|
||||
#propertiesTable {
|
||||
border: grey;
|
||||
border-style: solid;
|
||||
border-radius: 4px;
|
||||
padding: 4px;
|
||||
margin: 6px;
|
||||
margin-left: 0px;
|
||||
}
|
||||
|
||||
th, td {
|
||||
padding-left: 6px;
|
||||
padding-right: 6px;
|
||||
}
|
||||
|
||||
.properties td:nth-child(even) {
|
||||
background-color: #D6EEEE;
|
||||
font-family: monospace;
|
||||
}
|
||||
|
||||
.properties td:nth-child(odd) {
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.properties tr td:nth-child(2) { width: 150px; }
|
||||
@@ -0,0 +1,34 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<title>Bumble Speaker</title>
|
||||
<script src="https://cdn.jsdelivr.net/pyodide/v0.23.2/full/pyodide.js"></script>
|
||||
<script type="module" src="speaker.js"></script>
|
||||
<link rel="stylesheet" href="speaker.css">
|
||||
</head>
|
||||
<body>
|
||||
<h1><img src="logo.svg" width=100 height=100 style="vertical-align:middle" alt=""/>Bumble Virtual Speaker</h1>
|
||||
<div id="errorText"></div>
|
||||
<div id="speaker">
|
||||
<table><tr>
|
||||
<td>
|
||||
<table id="propertiesTable" class="properties">
|
||||
<tr><td>Codec</td><td><span id="codecText"></span></td></tr>
|
||||
<tr><td>Packets</td><td><span id="packetsReceivedText"></span></td></tr>
|
||||
<tr><td>Bytes</td><td><span id="bytesReceivedText"></span></td></tr>
|
||||
</table>
|
||||
</td>
|
||||
<td>
|
||||
<canvas id="bandwidthCanvas" width="500", height="100">Bandwidth Graph</canvas>
|
||||
</td>
|
||||
</tr></table>
|
||||
<span id="streamStateText">IDLE</span>
|
||||
<span id="connectionStateText">NOT CONNECTED</span>
|
||||
<div id="controlsDiv">
|
||||
<button id="audioOnButton">Audio On</button>
|
||||
</div>
|
||||
<canvas id="fftCanvas" width="1024", height="300">Audio Frequencies Animation</canvas>
|
||||
<audio id="audio"></audio>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,289 @@
|
||||
import { loadBumble, connectWebSocketTransport } from "../bumble.js";
|
||||
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
let codecText;
|
||||
let packetsReceivedText;
|
||||
let bytesReceivedText;
|
||||
let streamStateText;
|
||||
let connectionStateText;
|
||||
let errorText;
|
||||
let audioOnButton;
|
||||
let mediaSource;
|
||||
let sourceBuffer;
|
||||
let audioElement;
|
||||
let audioContext;
|
||||
let audioAnalyzer;
|
||||
let audioFrequencyBinCount;
|
||||
let audioFrequencyData;
|
||||
let packetsReceived = 0;
|
||||
let bytesReceived = 0;
|
||||
let audioState = "stopped";
|
||||
let streamState = "IDLE";
|
||||
let fftCanvas;
|
||||
let fftCanvasContext;
|
||||
let bandwidthCanvas;
|
||||
let bandwidthCanvasContext;
|
||||
let bandwidthBinCount;
|
||||
let bandwidthBins = [];
|
||||
let pyodide;
|
||||
|
||||
const FFT_WIDTH = 800;
|
||||
const FFT_HEIGHT = 256;
|
||||
const BANDWIDTH_WIDTH = 500;
|
||||
const BANDWIDTH_HEIGHT = 100;
|
||||
|
||||
|
||||
function init() {
|
||||
initUI();
|
||||
initMediaSource();
|
||||
initAudioElement();
|
||||
initAnalyzer();
|
||||
initBumble();
|
||||
}
|
||||
|
||||
function initUI() {
|
||||
audioOnButton = document.getElementById("audioOnButton");
|
||||
codecText = document.getElementById("codecText");
|
||||
packetsReceivedText = document.getElementById("packetsReceivedText");
|
||||
bytesReceivedText = document.getElementById("bytesReceivedText");
|
||||
streamStateText = document.getElementById("streamStateText");
|
||||
errorText = document.getElementById("errorText");
|
||||
connectionStateText = document.getElementById("connectionStateText");
|
||||
|
||||
audioOnButton.onclick = () => startAudio();
|
||||
|
||||
codecText.innerText = "AAC";
|
||||
setErrorText("");
|
||||
|
||||
requestAnimationFrame(onAnimationFrame);
|
||||
}
|
||||
|
||||
function initMediaSource() {
|
||||
mediaSource = new MediaSource();
|
||||
mediaSource.onsourceopen = onMediaSourceOpen;
|
||||
mediaSource.onsourceclose = onMediaSourceClose;
|
||||
mediaSource.onsourceended = onMediaSourceEnd;
|
||||
}
|
||||
|
||||
function initAudioElement() {
|
||||
audioElement = document.getElementById("audio");
|
||||
audioElement.src = URL.createObjectURL(mediaSource);
|
||||
// audioElement.controls = true;
|
||||
}
|
||||
|
||||
function initAnalyzer() {
|
||||
fftCanvas = document.getElementById("fftCanvas");
|
||||
fftCanvas.width = FFT_WIDTH
|
||||
fftCanvas.height = FFT_HEIGHT
|
||||
fftCanvasContext = fftCanvas.getContext('2d');
|
||||
fftCanvasContext.fillStyle = "rgb(0, 0, 0)";
|
||||
fftCanvasContext.fillRect(0, 0, FFT_WIDTH, FFT_HEIGHT);
|
||||
|
||||
bandwidthCanvas = document.getElementById("bandwidthCanvas");
|
||||
bandwidthCanvas.width = BANDWIDTH_WIDTH
|
||||
bandwidthCanvas.height = BANDWIDTH_HEIGHT
|
||||
bandwidthCanvasContext = bandwidthCanvas.getContext('2d');
|
||||
bandwidthCanvasContext.fillStyle = "rgb(255, 255, 255)";
|
||||
bandwidthCanvasContext.fillRect(0, 0, BANDWIDTH_WIDTH, BANDWIDTH_HEIGHT);
|
||||
}
|
||||
|
||||
async function initBumble() {
|
||||
// Load pyodide
|
||||
console.log("Loading Pyodide");
|
||||
pyodide = await loadPyodide();
|
||||
|
||||
// Load Bumble
|
||||
console.log("Loading Bumble");
|
||||
const params = (new URL(document.location)).searchParams;
|
||||
const bumblePackage = params.get("package") || "bumble";
|
||||
await loadBumble(pyodide, bumblePackage);
|
||||
|
||||
console.log("Ready!")
|
||||
|
||||
const hciWsUrl = params.get("hci") || "ws://localhost:9922/hci";
|
||||
try {
|
||||
// Create a WebSocket HCI transport
|
||||
let transport
|
||||
try {
|
||||
transport = await connectWebSocketTransport(pyodide, hciWsUrl);
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
setErrorText(error);
|
||||
return;
|
||||
}
|
||||
|
||||
// Run the scanner example
|
||||
const script = await (await fetch("speaker.py")).text();
|
||||
await pyodide.runPythonAsync(script);
|
||||
const pythonMain = pyodide.globals.get("main");
|
||||
console.log("Starting speaker...");
|
||||
await pythonMain(transport.packet_source, transport.packet_sink, onEvent);
|
||||
console.log("Speaker running");
|
||||
} catch (err) {
|
||||
console.log(err);
|
||||
}
|
||||
}
|
||||
|
||||
function startAnalyzer() {
|
||||
// FFT
|
||||
if (audioElement.captureStream !== undefined) {
|
||||
audioContext = new AudioContext();
|
||||
audioAnalyzer = audioContext.createAnalyser();
|
||||
audioAnalyzer.fftSize = 128;
|
||||
audioFrequencyBinCount = audioAnalyzer.frequencyBinCount;
|
||||
audioFrequencyData = new Uint8Array(audioFrequencyBinCount);
|
||||
const stream = audioElement.captureStream();
|
||||
const source = audioContext.createMediaStreamSource(stream);
|
||||
source.connect(audioAnalyzer);
|
||||
}
|
||||
|
||||
// Bandwidth
|
||||
bandwidthBinCount = BANDWIDTH_WIDTH / 2;
|
||||
bandwidthBins = [];
|
||||
}
|
||||
|
||||
function setErrorText(message) {
|
||||
errorText.innerText = message;
|
||||
if (message.length == 0) {
|
||||
errorText.style.display = "none";
|
||||
} else {
|
||||
errorText.style.display = "inline-block";
|
||||
}
|
||||
}
|
||||
|
||||
function setStreamState(state) {
|
||||
streamState = state;
|
||||
streamStateText.innerText = streamState;
|
||||
}
|
||||
|
||||
function onAnimationFrame() {
|
||||
// FFT
|
||||
if (audioAnalyzer !== undefined) {
|
||||
audioAnalyzer.getByteFrequencyData(audioFrequencyData);
|
||||
fftCanvasContext.fillStyle = "rgb(0, 0, 0)";
|
||||
fftCanvasContext.fillRect(0, 0, FFT_WIDTH, FFT_HEIGHT);
|
||||
const barCount = audioFrequencyBinCount;
|
||||
const barWidth = (FFT_WIDTH / audioFrequencyBinCount) - 1;
|
||||
for (let bar = 0; bar < barCount; bar++) {
|
||||
const barHeight = audioFrequencyData[bar];
|
||||
fftCanvasContext.fillStyle = `rgb(${barHeight / 256 * 200 + 50}, 50, ${50 + 2 * bar})`;
|
||||
fftCanvasContext.fillRect(bar * (barWidth + 1), FFT_HEIGHT - barHeight, barWidth, barHeight);
|
||||
}
|
||||
}
|
||||
|
||||
// Bandwidth
|
||||
bandwidthCanvasContext.fillStyle = "rgb(255, 255, 255)";
|
||||
bandwidthCanvasContext.fillRect(0, 0, BANDWIDTH_WIDTH, BANDWIDTH_HEIGHT);
|
||||
bandwidthCanvasContext.fillStyle = `rgb(100, 100, 100)`;
|
||||
for (let t = 0; t < bandwidthBins.length; t++) {
|
||||
const lineHeight = (bandwidthBins[t] / 1000) * BANDWIDTH_HEIGHT;
|
||||
bandwidthCanvasContext.fillRect(t * 2, BANDWIDTH_HEIGHT - lineHeight, 2, lineHeight);
|
||||
}
|
||||
|
||||
// Display again at the next frame
|
||||
requestAnimationFrame(onAnimationFrame);
|
||||
}
|
||||
|
||||
function onMediaSourceOpen() {
|
||||
console.log(this.readyState);
|
||||
sourceBuffer = mediaSource.addSourceBuffer("audio/aac");
|
||||
}
|
||||
|
||||
function onMediaSourceClose() {
|
||||
console.log(this.readyState);
|
||||
}
|
||||
|
||||
function onMediaSourceEnd() {
|
||||
console.log(this.readyState);
|
||||
}
|
||||
|
||||
async function startAudio() {
|
||||
try {
|
||||
console.log("starting audio...");
|
||||
audioOnButton.disabled = true;
|
||||
audioState = "starting";
|
||||
await audioElement.play();
|
||||
console.log("audio started");
|
||||
audioState = "playing";
|
||||
startAnalyzer();
|
||||
} catch (error) {
|
||||
console.error(`play failed: ${error}`);
|
||||
audioState = "stopped";
|
||||
audioOnButton.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function onEvent(name, params) {
|
||||
// Dispatch the message.
|
||||
const handlerName = `on${name.charAt(0).toUpperCase()}${name.slice(1)}`
|
||||
const handler = eventHandlers[handlerName];
|
||||
if (handler !== undefined) {
|
||||
handler(params);
|
||||
} else {
|
||||
console.warn(`unhandled event: ${name}`)
|
||||
}
|
||||
}
|
||||
|
||||
function onStart() {
|
||||
setStreamState("STARTED");
|
||||
}
|
||||
|
||||
function onStop() {
|
||||
setStreamState("STOPPED");
|
||||
}
|
||||
|
||||
function onSuspend() {
|
||||
setStreamState("SUSPENDED");
|
||||
}
|
||||
|
||||
function onConnection(params) {
|
||||
connectionStateText.innerText = `CONNECTED: ${params.get('peer_name')} (${params.get('peer_address')})`;
|
||||
}
|
||||
|
||||
function onDisconnection(params) {
|
||||
connectionStateText.innerText = "DISCONNECTED";
|
||||
}
|
||||
|
||||
function onAudio(python_packet) {
|
||||
const packet = python_packet.toJs({create_proxies : false});
|
||||
python_packet.destroy();
|
||||
if (audioState != "stopped") {
|
||||
// Queue the audio packet.
|
||||
sourceBuffer.appendBuffer(packet);
|
||||
}
|
||||
|
||||
packetsReceived += 1;
|
||||
packetsReceivedText.innerText = packetsReceived;
|
||||
bytesReceived += packet.byteLength;
|
||||
bytesReceivedText.innerText = bytesReceived;
|
||||
|
||||
bandwidthBins[bandwidthBins.length] = packet.byteLength;
|
||||
if (bandwidthBins.length > bandwidthBinCount) {
|
||||
bandwidthBins.shift();
|
||||
}
|
||||
}
|
||||
|
||||
function onKeystoreupdate() {
|
||||
// Sync the FS
|
||||
pyodide.FS.syncfs(() => {
|
||||
console.log("FS synced out")
|
||||
});
|
||||
}
|
||||
|
||||
const eventHandlers = {
|
||||
onStart,
|
||||
onStop,
|
||||
onSuspend,
|
||||
onConnection,
|
||||
onDisconnection,
|
||||
onAudio,
|
||||
onKeystoreupdate
|
||||
}
|
||||
|
||||
window.onload = (event) => {
|
||||
init();
|
||||
}
|
||||
|
||||
}());
|
||||
@@ -0,0 +1,321 @@
|
||||
# Copyright 2021-2023 Google LLC
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# https://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Imports
|
||||
# -----------------------------------------------------------------------------
|
||||
from __future__ import annotations
|
||||
import enum
|
||||
import logging
|
||||
from typing import Dict, List
|
||||
|
||||
from bumble.core import BT_BR_EDR_TRANSPORT, CommandTimeoutError
|
||||
from bumble.device import Device, DeviceConfiguration
|
||||
from bumble.pairing import PairingConfig
|
||||
from bumble.sdp import ServiceAttribute
|
||||
from bumble.avdtp import (
|
||||
AVDTP_AUDIO_MEDIA_TYPE,
|
||||
Listener,
|
||||
MediaCodecCapabilities,
|
||||
MediaPacket,
|
||||
Protocol,
|
||||
)
|
||||
from bumble.a2dp import (
|
||||
make_audio_sink_service_sdp_records,
|
||||
MPEG_2_AAC_LC_OBJECT_TYPE,
|
||||
A2DP_SBC_CODEC_TYPE,
|
||||
A2DP_MPEG_2_4_AAC_CODEC_TYPE,
|
||||
SBC_MONO_CHANNEL_MODE,
|
||||
SBC_DUAL_CHANNEL_MODE,
|
||||
SBC_SNR_ALLOCATION_METHOD,
|
||||
SBC_LOUDNESS_ALLOCATION_METHOD,
|
||||
SBC_STEREO_CHANNEL_MODE,
|
||||
SBC_JOINT_STEREO_CHANNEL_MODE,
|
||||
SbcMediaCodecInformation,
|
||||
AacMediaCodecInformation,
|
||||
)
|
||||
from bumble.utils import AsyncRunner
|
||||
from bumble.codecs import AacAudioRtpPacket
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Logging
|
||||
# -----------------------------------------------------------------------------
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
class AudioExtractor:
|
||||
@staticmethod
|
||||
def create(codec: str):
|
||||
if codec == 'aac':
|
||||
return AacAudioExtractor()
|
||||
if codec == 'sbc':
|
||||
return SbcAudioExtractor()
|
||||
|
||||
def extract_audio(self, packet: MediaPacket) -> bytes:
|
||||
raise NotImplementedError()
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
class AacAudioExtractor:
|
||||
def extract_audio(self, packet: MediaPacket) -> bytes:
|
||||
return AacAudioRtpPacket(packet.payload).to_adts()
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
class SbcAudioExtractor:
|
||||
def extract_audio(self, packet: MediaPacket) -> bytes:
|
||||
# header = packet.payload[0]
|
||||
# fragmented = header >> 7
|
||||
# start = (header >> 6) & 0x01
|
||||
# last = (header >> 5) & 0x01
|
||||
# number_of_frames = header & 0x0F
|
||||
|
||||
# TODO: support fragmented payloads
|
||||
return packet.payload[1:]
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
class Speaker:
|
||||
class StreamState(enum.Enum):
|
||||
IDLE = 0
|
||||
STOPPED = 1
|
||||
STARTED = 2
|
||||
SUSPENDED = 3
|
||||
|
||||
def __init__(self, hci_source, hci_sink, emit_event, codec, discover):
|
||||
self.hci_source = hci_source
|
||||
self.hci_sink = hci_sink
|
||||
self.emit_event = emit_event
|
||||
self.codec = codec
|
||||
self.discover = discover
|
||||
self.device = None
|
||||
self.connection = None
|
||||
self.listener = None
|
||||
self.packets_received = 0
|
||||
self.bytes_received = 0
|
||||
self.stream_state = Speaker.StreamState.IDLE
|
||||
self.audio_extractor = AudioExtractor.create(codec)
|
||||
|
||||
def sdp_records(self) -> Dict[int, List[ServiceAttribute]]:
|
||||
service_record_handle = 0x00010001
|
||||
return {
|
||||
service_record_handle: make_audio_sink_service_sdp_records(
|
||||
service_record_handle
|
||||
)
|
||||
}
|
||||
|
||||
def codec_capabilities(self) -> MediaCodecCapabilities:
|
||||
if self.codec == 'aac':
|
||||
return self.aac_codec_capabilities()
|
||||
|
||||
if self.codec == 'sbc':
|
||||
return self.sbc_codec_capabilities()
|
||||
|
||||
raise RuntimeError('unsupported codec')
|
||||
|
||||
def aac_codec_capabilities(self) -> MediaCodecCapabilities:
|
||||
return MediaCodecCapabilities(
|
||||
media_type=AVDTP_AUDIO_MEDIA_TYPE,
|
||||
media_codec_type=A2DP_MPEG_2_4_AAC_CODEC_TYPE,
|
||||
media_codec_information=AacMediaCodecInformation.from_lists(
|
||||
object_types=[MPEG_2_AAC_LC_OBJECT_TYPE],
|
||||
sampling_frequencies=[48000, 44100],
|
||||
channels=[1, 2],
|
||||
vbr=1,
|
||||
bitrate=256000,
|
||||
),
|
||||
)
|
||||
|
||||
def sbc_codec_capabilities(self) -> MediaCodecCapabilities:
|
||||
return MediaCodecCapabilities(
|
||||
media_type=AVDTP_AUDIO_MEDIA_TYPE,
|
||||
media_codec_type=A2DP_SBC_CODEC_TYPE,
|
||||
media_codec_information=SbcMediaCodecInformation.from_lists(
|
||||
sampling_frequencies=[48000, 44100, 32000, 16000],
|
||||
channel_modes=[
|
||||
SBC_MONO_CHANNEL_MODE,
|
||||
SBC_DUAL_CHANNEL_MODE,
|
||||
SBC_STEREO_CHANNEL_MODE,
|
||||
SBC_JOINT_STEREO_CHANNEL_MODE,
|
||||
],
|
||||
block_lengths=[4, 8, 12, 16],
|
||||
subbands=[4, 8],
|
||||
allocation_methods=[
|
||||
SBC_LOUDNESS_ALLOCATION_METHOD,
|
||||
SBC_SNR_ALLOCATION_METHOD,
|
||||
],
|
||||
minimum_bitpool_value=2,
|
||||
maximum_bitpool_value=53,
|
||||
),
|
||||
)
|
||||
|
||||
def on_key_store_update(self):
|
||||
print("Key Store updated")
|
||||
self.emit_event('keystoreupdate', None)
|
||||
|
||||
def on_bluetooth_connection(self, connection):
|
||||
print(f'Connection: {connection}')
|
||||
self.connection = connection
|
||||
connection.on('disconnection', self.on_bluetooth_disconnection)
|
||||
peer_name = '' if connection.peer_name is None else connection.peer_name
|
||||
peer_address = str(connection.peer_address).replace('/P', '')
|
||||
self.emit_event(
|
||||
'connection', {'peer_name': peer_name, 'peer_address': peer_address}
|
||||
)
|
||||
|
||||
def on_bluetooth_disconnection(self, reason):
|
||||
print(f'Disconnection ({reason})')
|
||||
self.connection = None
|
||||
AsyncRunner.spawn(self.advertise())
|
||||
self.emit_event('disconnection', None)
|
||||
|
||||
def on_avdtp_connection(self, protocol):
|
||||
print('Audio Stream Open')
|
||||
|
||||
# Add a sink endpoint to the server
|
||||
sink = protocol.add_sink(self.codec_capabilities())
|
||||
sink.on('start', self.on_sink_start)
|
||||
sink.on('stop', self.on_sink_stop)
|
||||
sink.on('suspend', self.on_sink_suspend)
|
||||
sink.on('configuration', lambda: self.on_sink_configuration(sink.configuration))
|
||||
sink.on('rtp_packet', self.on_rtp_packet)
|
||||
sink.on('rtp_channel_open', self.on_rtp_channel_open)
|
||||
sink.on('rtp_channel_close', self.on_rtp_channel_close)
|
||||
|
||||
# Listen for close events
|
||||
protocol.on('close', self.on_avdtp_close)
|
||||
|
||||
# Discover all endpoints on the remote device is requested
|
||||
if self.discover:
|
||||
AsyncRunner.spawn(self.discover_remote_endpoints(protocol))
|
||||
|
||||
def on_avdtp_close(self):
|
||||
print("Audio Stream Closed")
|
||||
|
||||
def on_sink_start(self):
|
||||
print("Sink Started")
|
||||
self.stream_state = self.StreamState.STARTED
|
||||
self.emit_event('start', None)
|
||||
|
||||
def on_sink_stop(self):
|
||||
print("Sink Stopped")
|
||||
self.stream_state = self.StreamState.STOPPED
|
||||
self.emit_event('stop', None)
|
||||
|
||||
def on_sink_suspend(self):
|
||||
print("Sink Suspended")
|
||||
self.stream_state = self.StreamState.SUSPENDED
|
||||
self.emit_event('suspend', None)
|
||||
|
||||
def on_sink_configuration(self, config):
|
||||
print("Sink Configuration:")
|
||||
print('\n'.join([" " + str(capability) for capability in config]))
|
||||
|
||||
def on_rtp_channel_open(self):
|
||||
print("RTP Channel Open")
|
||||
|
||||
def on_rtp_channel_close(self):
|
||||
print("RTP Channel Closed")
|
||||
self.stream_state = self.StreamState.IDLE
|
||||
|
||||
def on_rtp_packet(self, packet):
|
||||
self.packets_received += 1
|
||||
self.bytes_received += len(packet.payload)
|
||||
self.emit_event("audio", self.audio_extractor.extract_audio(packet))
|
||||
|
||||
async def advertise(self):
|
||||
await self.device.set_discoverable(True)
|
||||
await self.device.set_connectable(True)
|
||||
|
||||
async def connect(self, address):
|
||||
# Connect to the source
|
||||
print(f'=== Connecting to {address}...')
|
||||
connection = await self.device.connect(address, transport=BT_BR_EDR_TRANSPORT)
|
||||
print(f'=== Connected to {connection.peer_address}')
|
||||
|
||||
# Request authentication
|
||||
print('*** Authenticating...')
|
||||
await connection.authenticate()
|
||||
print('*** Authenticated')
|
||||
|
||||
# Enable encryption
|
||||
print('*** Enabling encryption...')
|
||||
await connection.encrypt()
|
||||
print('*** Encryption on')
|
||||
|
||||
protocol = await Protocol.connect(connection)
|
||||
self.listener.set_server(connection, protocol)
|
||||
self.on_avdtp_connection(protocol)
|
||||
|
||||
async def discover_remote_endpoints(self, protocol):
|
||||
endpoints = await protocol.discover_remote_endpoints()
|
||||
print(f'@@@ Found {len(endpoints)} endpoints')
|
||||
for endpoint in endpoints:
|
||||
print('@@@', endpoint)
|
||||
|
||||
async def run(self, connect_address):
|
||||
# Create a device
|
||||
device_config = DeviceConfiguration()
|
||||
device_config.name = "Bumble Speaker"
|
||||
device_config.class_of_device = 0x240414
|
||||
device_config.keystore = "JsonKeyStore:/bumble/keystore.json"
|
||||
device_config.classic_enabled = True
|
||||
device_config.le_enabled = False
|
||||
self.device = Device.from_config_with_hci(
|
||||
device_config, self.hci_source, self.hci_sink
|
||||
)
|
||||
|
||||
# Setup the SDP to expose the sink service
|
||||
self.device.sdp_service_records = self.sdp_records()
|
||||
|
||||
# Don't require MITM when pairing.
|
||||
self.device.pairing_config_factory = lambda connection: PairingConfig(
|
||||
mitm=False
|
||||
)
|
||||
|
||||
# Start the controller
|
||||
await self.device.power_on()
|
||||
|
||||
# Listen for Bluetooth connections
|
||||
self.device.on('connection', self.on_bluetooth_connection)
|
||||
|
||||
# Listen for changes to the key store
|
||||
self.device.on('key_store_update', self.on_key_store_update)
|
||||
|
||||
# Create a listener to wait for AVDTP connections
|
||||
self.listener = Listener(Listener.create_registrar(self.device))
|
||||
self.listener.on('connection', self.on_avdtp_connection)
|
||||
|
||||
print(f'Speaker ready to play, codec={self.codec}')
|
||||
|
||||
if connect_address:
|
||||
# Connect to the source
|
||||
try:
|
||||
await self.connect(connect_address)
|
||||
except CommandTimeoutError:
|
||||
print("Connection timed out")
|
||||
return
|
||||
else:
|
||||
# Start being discoverable and connectable
|
||||
print("Waiting for connection...")
|
||||
await self.advertise()
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
async def main(hci_source, hci_sink, emit_event):
|
||||
# logging.basicConfig(level='DEBUG')
|
||||
speaker = Speaker(hci_source, hci_sink, emit_event, "aac", False)
|
||||
await speaker.run(None)
|
||||
Reference in New Issue
Block a user