forked from auracaster/bumble_mirror
Compare commits
36 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9303f4fc5b | ||
|
|
9732eb8836 | ||
|
|
5ae668bc70 | ||
|
|
fd4d1bcca3 | ||
|
|
0a251c9f8e | ||
|
|
351d77be59 | ||
|
|
0e2fc80509 | ||
|
|
8f3fdecb93 | ||
|
|
249a205d8e | ||
|
|
7485801222 | ||
|
|
4678e59737 | ||
|
|
952d351c00 | ||
|
|
901eb55b0e | ||
|
|
727586e40e | ||
|
|
3aa678a58e | ||
|
|
fc7c1a8113 | ||
|
|
f62a0bbe75 | ||
|
|
7341172739 | ||
|
|
91b9fbe450 | ||
|
|
e6b566b848 | ||
|
|
2527a711dc | ||
|
|
5fba6b1cae | ||
|
|
43e632f83c | ||
|
|
623298b0e9 | ||
|
|
85a61dc39d | ||
|
|
6e8c44b5e6 | ||
|
|
ec4dcc174e | ||
|
|
b247aca3b4 | ||
|
|
6226bfd196 | ||
|
|
71e11b7cf8 | ||
|
|
800c62fdb6 | ||
|
|
640b9cd53a | ||
|
|
f4add16aea | ||
|
|
2bfec3c4ed | ||
|
|
9963b51c04 | ||
|
|
2af3494d8c |
17
.github/workflows/python-build-test.yml
vendored
17
.github/workflows/python-build-test.yml
vendored
@@ -45,7 +45,8 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
matrix:
|
||||
python-version: [ "3.8", "3.9", "3.10" ]
|
||||
python-version: [ "3.8", "3.9", "3.10", "3.11" ]
|
||||
rust-version: [ "1.70.0", "stable" ]
|
||||
fail-fast: false
|
||||
steps:
|
||||
- name: Check out from Git
|
||||
@@ -62,9 +63,15 @@ jobs:
|
||||
uses: actions-rust-lang/setup-rust-toolchain@v1
|
||||
with:
|
||||
components: clippy,rustfmt
|
||||
- name: Rust Lints
|
||||
run: cd rust && cargo fmt --check && cargo clippy --all-targets -- --deny warnings
|
||||
toolchain: ${{ matrix.rust-version }}
|
||||
- name: Rust Build
|
||||
run: cd rust && cargo build --all-targets
|
||||
run: cd rust && cargo build --all-targets && cargo build --all-features --all-targets
|
||||
# Lints after build so what clippy needs is already built
|
||||
- name: Rust Lints
|
||||
run: cd rust && cargo fmt --check && cargo clippy --all-targets -- --deny warnings && cargo clippy --all-features --all-targets -- --deny warnings
|
||||
- name: Rust Tests
|
||||
run: cd rust && cargo test
|
||||
run: cd rust && cargo test
|
||||
# At some point, hook up publishing the binary. For now, just make sure it builds.
|
||||
# Once we're ready to publish binaries, this should be built with `--release`.
|
||||
- name: Build Bumble CLI
|
||||
run: cd rust && cargo build --features bumble-tools --bin bumble
|
||||
@@ -105,7 +105,7 @@ class ServerBridge:
|
||||
asyncio.create_task(self.pipe.l2cap_channel.disconnect())
|
||||
|
||||
def data_received(self, data):
|
||||
print(f'<<< Received on TCP: {len(data)}')
|
||||
print(color(f'<<< [TCP DATA]: {len(data)} bytes', 'blue'))
|
||||
self.pipe.l2cap_channel.write(data)
|
||||
|
||||
try:
|
||||
@@ -123,6 +123,7 @@ class ServerBridge:
|
||||
await self.l2cap_channel.disconnect()
|
||||
|
||||
def on_l2cap_close(self):
|
||||
print(color('*** L2CAP channel closed', 'red'))
|
||||
self.l2cap_channel = None
|
||||
if self.tcp_transport is not None:
|
||||
self.tcp_transport.close()
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import asyncio
|
||||
import click
|
||||
import logging
|
||||
import json
|
||||
|
||||
from bumble.pandora import PandoraDevice, serve
|
||||
from typing import Dict, Any
|
||||
|
||||
BUMBLE_SERVER_GRPC_PORT = 7999
|
||||
ROOTCANAL_PORT_CUTTLEFISH = 7300
|
||||
@@ -18,13 +20,30 @@ ROOTCANAL_PORT_CUTTLEFISH = 7300
|
||||
help='HCI transport',
|
||||
default=f'tcp-client:127.0.0.1:<rootcanal-port>',
|
||||
)
|
||||
def main(grpc_port: int, rootcanal_port: int, transport: str) -> None:
|
||||
@click.option(
|
||||
'--config',
|
||||
help='Bumble json configuration file',
|
||||
)
|
||||
def main(grpc_port: int, rootcanal_port: int, transport: str, config: str) -> None:
|
||||
if '<rootcanal-port>' in transport:
|
||||
transport = transport.replace('<rootcanal-port>', str(rootcanal_port))
|
||||
device = PandoraDevice({'transport': transport})
|
||||
|
||||
bumble_config = retrieve_config(config)
|
||||
if 'transport' not in bumble_config.keys():
|
||||
bumble_config.update({'transport': transport})
|
||||
device = PandoraDevice(bumble_config)
|
||||
|
||||
logging.basicConfig(level=logging.DEBUG)
|
||||
asyncio.run(serve(device, port=grpc_port))
|
||||
|
||||
|
||||
def retrieve_config(config: str) -> Dict[str, Any]:
|
||||
if not config:
|
||||
return {}
|
||||
|
||||
with open(config, 'r') as f:
|
||||
return json.load(f)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main() # pylint: disable=no-value-for-parameter
|
||||
|
||||
@@ -228,10 +228,11 @@ class FfplayOutput(QueuedOutput):
|
||||
subprocess: Optional[asyncio.subprocess.Process]
|
||||
ffplay_task: Optional[asyncio.Task]
|
||||
|
||||
def __init__(self) -> None:
|
||||
super().__init__(AacAudioExtractor())
|
||||
def __init__(self, codec: str) -> None:
|
||||
super().__init__(AudioExtractor.create(codec))
|
||||
self.subprocess = None
|
||||
self.ffplay_task = None
|
||||
self.codec = codec
|
||||
|
||||
async def start(self):
|
||||
if self.started:
|
||||
@@ -240,7 +241,7 @@ class FfplayOutput(QueuedOutput):
|
||||
await super().start()
|
||||
|
||||
self.subprocess = await asyncio.create_subprocess_shell(
|
||||
'ffplay -acodec aac pipe:0',
|
||||
f'ffplay -f {self.codec} pipe:0',
|
||||
stdin=asyncio.subprocess.PIPE,
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.PIPE,
|
||||
@@ -419,7 +420,7 @@ class Speaker:
|
||||
self.outputs = []
|
||||
for output in outputs:
|
||||
if output == '@ffplay':
|
||||
self.outputs.append(FfplayOutput())
|
||||
self.outputs.append(FfplayOutput(codec))
|
||||
continue
|
||||
|
||||
# Default to FileOutput
|
||||
@@ -708,17 +709,6 @@ def speaker(
|
||||
):
|
||||
"""Run the speaker."""
|
||||
|
||||
# ffplay only works with AAC for now
|
||||
if codec != 'aac' and '@ffplay' in output:
|
||||
print(
|
||||
color(
|
||||
f'{codec} not supported with @ffplay output, '
|
||||
'@ffplay output will be skipped',
|
||||
'yellow',
|
||||
)
|
||||
)
|
||||
output = list(filter(lambda x: x != '@ffplay', output))
|
||||
|
||||
if '@ffplay' in output:
|
||||
# Check if ffplay is installed
|
||||
try:
|
||||
|
||||
85
bumble/at.py
Normal file
85
bumble/at.py
Normal file
@@ -0,0 +1,85 @@
|
||||
# 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]
|
||||
@@ -188,6 +188,8 @@ class Controller:
|
||||
if link:
|
||||
link.add_controller(self)
|
||||
|
||||
self.terminated = asyncio.get_running_loop().create_future()
|
||||
|
||||
@property
|
||||
def host(self):
|
||||
return self.hci_sink
|
||||
@@ -288,10 +290,9 @@ class Controller:
|
||||
if self.host:
|
||||
self.host.on_packet(packet.to_bytes())
|
||||
|
||||
# This method allow the controller to emulate the same API as a transport source
|
||||
# This method allows the controller to emulate the same API as a transport source
|
||||
async def wait_for_termination(self):
|
||||
# For now, just wait forever
|
||||
await asyncio.get_running_loop().create_future()
|
||||
await self.terminated
|
||||
|
||||
############################################################
|
||||
# Link connections
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -652,7 +652,7 @@ class Connection(CompositeEventEmitter):
|
||||
def is_incomplete(self) -> bool:
|
||||
return self.handle is None
|
||||
|
||||
def send_l2cap_pdu(self, cid, pdu):
|
||||
def send_l2cap_pdu(self, cid: int, pdu: bytes) -> None:
|
||||
self.device.send_l2cap_pdu(self.handle, cid, pdu)
|
||||
|
||||
def create_l2cap_connector(self, psm):
|
||||
@@ -1096,7 +1096,7 @@ class Device(CompositeEventEmitter):
|
||||
return self._host
|
||||
|
||||
@host.setter
|
||||
def host(self, host):
|
||||
def host(self, host: Host) -> None:
|
||||
# Unsubscribe from events from the current host
|
||||
if self._host:
|
||||
for event_name in device_host_event_handlers:
|
||||
@@ -1183,7 +1183,7 @@ class Device(CompositeEventEmitter):
|
||||
connection, psm, max_credits, mtu, mps
|
||||
)
|
||||
|
||||
def send_l2cap_pdu(self, connection_handle, cid, pdu):
|
||||
def send_l2cap_pdu(self, connection_handle: int, cid: int, pdu: bytes) -> None:
|
||||
self.host.send_l2cap_pdu(connection_handle, cid, pdu)
|
||||
|
||||
async def send_command(self, command, check_result=False):
|
||||
@@ -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
|
||||
@@ -2743,20 +2753,6 @@ class Device(CompositeEventEmitter):
|
||||
)
|
||||
connection.emit('connection_authentication_failure', error)
|
||||
|
||||
@host_event_handler
|
||||
@with_connection_from_address
|
||||
def on_ssp_complete(self, connection):
|
||||
# On Secure Simple Pairing complete, in case:
|
||||
# - Connection isn't already authenticated
|
||||
# - AND we are not the initiator of the authentication
|
||||
# We must trigger authentication to know if we are truly authenticated
|
||||
if not connection.authenticating and not connection.authenticated:
|
||||
logger.debug(
|
||||
f'*** Trigger Connection Authentication: [0x{connection.handle:04X}] '
|
||||
f'{connection.peer_address}'
|
||||
)
|
||||
asyncio.create_task(connection.authenticate())
|
||||
|
||||
# [Classic only]
|
||||
@host_event_handler
|
||||
@with_connection_from_address
|
||||
@@ -3111,6 +3107,18 @@ class Device(CompositeEventEmitter):
|
||||
connection.emit('role_change_failure', error)
|
||||
self.emit('role_change_failure', address, error)
|
||||
|
||||
# [Classic only]
|
||||
@host_event_handler
|
||||
@with_connection_from_address
|
||||
def on_classic_pairing(self, connection: Connection) -> None:
|
||||
connection.emit('classic_pairing')
|
||||
|
||||
# [Classic only]
|
||||
@host_event_handler
|
||||
@with_connection_from_address
|
||||
def on_classic_pairing_failure(self, connection: Connection, status) -> None:
|
||||
connection.emit('classic_pairing_failure', status)
|
||||
|
||||
def on_pairing_start(self, connection: Connection) -> None:
|
||||
connection.emit('pairing_start')
|
||||
|
||||
@@ -3159,7 +3167,7 @@ class Device(CompositeEventEmitter):
|
||||
|
||||
@host_event_handler
|
||||
@with_connection_from_handle
|
||||
def on_l2cap_pdu(self, connection, cid, pdu):
|
||||
def on_l2cap_pdu(self, connection: Connection, cid: int, pdu: bytes):
|
||||
self.l2cap_channel_manager.on_pdu(connection, cid, pdu)
|
||||
|
||||
def __str__(self):
|
||||
|
||||
@@ -21,6 +21,9 @@ like loading firmware after a cold start.
|
||||
# -----------------------------------------------------------------------------
|
||||
import abc
|
||||
import logging
|
||||
import pathlib
|
||||
import platform
|
||||
import platformdirs
|
||||
from . import rtk
|
||||
|
||||
|
||||
@@ -66,3 +69,22 @@ async def get_driver_for_host(host):
|
||||
return driver
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def project_data_dir() -> pathlib.Path:
|
||||
"""
|
||||
Returns:
|
||||
A path to an OS-specific directory for bumble data. The directory is created if
|
||||
it doesn't exist.
|
||||
"""
|
||||
if platform.system() == 'Darwin':
|
||||
# platformdirs doesn't handle macOS right: it doesn't assemble a bundle id
|
||||
# out of author & project
|
||||
return platformdirs.user_data_path(
|
||||
appname='com.google.bumble', ensure_exists=True
|
||||
)
|
||||
else:
|
||||
# windows and linux don't use the com qualifier
|
||||
return platformdirs.user_data_path(
|
||||
appname='bumble', appauthor='google', ensure_exists=True
|
||||
)
|
||||
|
||||
@@ -125,6 +125,7 @@ RTK_USB_PRODUCTS = {
|
||||
(0x2550, 0x8761),
|
||||
(0x2B89, 0x8761),
|
||||
(0x7392, 0xC611),
|
||||
(0x0BDA, 0x877B),
|
||||
# Realtek 8821AE
|
||||
(0x0B05, 0x17DC),
|
||||
(0x13D3, 0x3414),
|
||||
@@ -445,6 +446,11 @@ class Driver:
|
||||
# When the environment variable is set, don't look elsewhere
|
||||
return None
|
||||
|
||||
# Then, look where the firmware download tool writes by default
|
||||
if (path := rtk_firmware_dir() / file_name).is_file():
|
||||
logger.debug(f"{file_name} found in project data dir")
|
||||
return path
|
||||
|
||||
# Then, look in the package's driver directory
|
||||
if (path := pathlib.Path(__file__).parent / "rtk_fw" / file_name).is_file():
|
||||
logger.debug(f"{file_name} found in package dir")
|
||||
@@ -645,3 +651,16 @@ class Driver:
|
||||
await self.download_firmware()
|
||||
await self.host.send_command(HCI_Reset_Command(), check_result=True)
|
||||
logger.info(f"loaded FW image {self.driver_info.fw_name}")
|
||||
|
||||
|
||||
def rtk_firmware_dir() -> pathlib.Path:
|
||||
"""
|
||||
Returns:
|
||||
A path to a subdir of the project data dir for Realtek firmware.
|
||||
The directory is created if it doesn't exist.
|
||||
"""
|
||||
from bumble.drivers import project_data_dir
|
||||
|
||||
p = project_data_dir() / "firmware" / "realtek"
|
||||
p.mkdir(parents=True, exist_ok=True)
|
||||
return p
|
||||
|
||||
@@ -20,7 +20,7 @@ import struct
|
||||
import collections
|
||||
import logging
|
||||
import functools
|
||||
from typing import Dict, Type, Union
|
||||
from typing import Dict, Type, Union, Callable, Any, Optional
|
||||
|
||||
from .colors import color
|
||||
from .core import (
|
||||
@@ -1918,7 +1918,7 @@ class HCI_Packet:
|
||||
hci_packet_type: int
|
||||
|
||||
@staticmethod
|
||||
def from_bytes(packet):
|
||||
def from_bytes(packet: bytes) -> HCI_Packet:
|
||||
packet_type = packet[0]
|
||||
|
||||
if packet_type == HCI_COMMAND_PACKET:
|
||||
@@ -1992,7 +1992,7 @@ class HCI_Command(HCI_Packet):
|
||||
return inner
|
||||
|
||||
@staticmethod
|
||||
def from_bytes(packet):
|
||||
def from_bytes(packet: bytes) -> HCI_Command:
|
||||
op_code, length = struct.unpack_from('<HB', packet, 1)
|
||||
parameters = packet[4:]
|
||||
if len(parameters) != length:
|
||||
@@ -2011,7 +2011,7 @@ class HCI_Command(HCI_Packet):
|
||||
HCI_Object.init_from_bytes(self, parameters, 0, fields)
|
||||
return self
|
||||
|
||||
return cls.from_parameters(parameters)
|
||||
return cls.from_parameters(parameters) # type: ignore
|
||||
|
||||
@staticmethod
|
||||
def command_name(op_code):
|
||||
@@ -4350,13 +4350,14 @@ class HCI_Event(HCI_Packet):
|
||||
return event_class
|
||||
|
||||
@staticmethod
|
||||
def from_bytes(packet):
|
||||
def from_bytes(packet: bytes) -> HCI_Event:
|
||||
event_code = packet[1]
|
||||
length = packet[2]
|
||||
parameters = packet[3:]
|
||||
if len(parameters) != length:
|
||||
raise ValueError('invalid packet length')
|
||||
|
||||
cls: Type[HCI_Event | HCI_LE_Meta_Event] | None
|
||||
if event_code == HCI_LE_META_EVENT:
|
||||
# We do this dispatch here and not in the subclass in order to avoid call
|
||||
# loops
|
||||
@@ -4373,7 +4374,7 @@ class HCI_Event(HCI_Packet):
|
||||
return HCI_Event(event_code, parameters)
|
||||
|
||||
# Invoke the factory to create a new instance
|
||||
return cls.from_parameters(parameters)
|
||||
return cls.from_parameters(parameters) # type: ignore
|
||||
|
||||
@classmethod
|
||||
def from_parameters(cls, parameters):
|
||||
@@ -5086,6 +5087,7 @@ class HCI_Command_Complete_Event(HCI_Event):
|
||||
'''
|
||||
|
||||
return_parameters = b''
|
||||
command_opcode: int
|
||||
|
||||
def map_return_parameters(self, return_parameters):
|
||||
'''Map simple 'status' return parameters to their named constant form'''
|
||||
@@ -5605,7 +5607,7 @@ class HCI_Remote_Host_Supported_Features_Notification_Event(HCI_Event):
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
class HCI_AclDataPacket:
|
||||
class HCI_AclDataPacket(HCI_Packet):
|
||||
'''
|
||||
See Bluetooth spec @ 5.4.2 HCI ACL Data Packets
|
||||
'''
|
||||
@@ -5613,7 +5615,7 @@ class HCI_AclDataPacket:
|
||||
hci_packet_type = HCI_ACL_DATA_PACKET
|
||||
|
||||
@staticmethod
|
||||
def from_bytes(packet):
|
||||
def from_bytes(packet: bytes) -> HCI_AclDataPacket:
|
||||
# Read the header
|
||||
h, data_total_length = struct.unpack_from('<HH', packet, 1)
|
||||
connection_handle = h & 0xFFF
|
||||
@@ -5655,12 +5657,14 @@ class HCI_AclDataPacket:
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
class HCI_AclDataPacketAssembler:
|
||||
def __init__(self, callback):
|
||||
current_data: Optional[bytes]
|
||||
|
||||
def __init__(self, callback: Callable[[bytes], Any]) -> None:
|
||||
self.callback = callback
|
||||
self.current_data = None
|
||||
self.l2cap_pdu_length = 0
|
||||
|
||||
def feed_packet(self, packet):
|
||||
def feed_packet(self, packet: HCI_AclDataPacket) -> None:
|
||||
if packet.pb_flag in (
|
||||
HCI_ACL_PB_FIRST_NON_FLUSHABLE,
|
||||
HCI_ACL_PB_FIRST_FLUSHABLE,
|
||||
@@ -5674,6 +5678,7 @@ class HCI_AclDataPacketAssembler:
|
||||
return
|
||||
self.current_data += packet.data
|
||||
|
||||
assert self.current_data is not None
|
||||
if len(self.current_data) == self.l2cap_pdu_length + 4:
|
||||
# The packet is complete, invoke the callback
|
||||
logger.debug(f'<<< ACL PDU: {self.current_data.hex()}')
|
||||
|
||||
758
bumble/hfp.py
758
bumble/hfp.py
@@ -1,4 +1,4 @@
|
||||
# Copyright 2021-2022 Google LLC
|
||||
# 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.
|
||||
@@ -17,11 +17,31 @@
|
||||
# -----------------------------------------------------------------------------
|
||||
import logging
|
||||
import asyncio
|
||||
import collections
|
||||
from typing import Union
|
||||
import dataclasses
|
||||
import enum
|
||||
import traceback
|
||||
from typing import Dict, List, Union, Set
|
||||
|
||||
from . import at
|
||||
from . import rfcomm
|
||||
from .colors import color
|
||||
|
||||
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,
|
||||
)
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Logging
|
||||
@@ -30,72 +50,700 @@ logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Protocol Support
|
||||
# Normative protocol definitions
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
|
||||
# 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
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
class HfpProtocol:
|
||||
# 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]
|
||||
|
||||
dlc: rfcomm.DLC
|
||||
buffer: str
|
||||
lines: collections.deque
|
||||
lines_available: asyncio.Event
|
||||
command_lock: asyncio.Lock
|
||||
response_queue: asyncio.Queue
|
||||
unsolicited_queue: asyncio.Queue
|
||||
read_buffer: bytearray
|
||||
|
||||
def __init__(self, dlc: rfcomm.DLC) -> None:
|
||||
def __init__(self, dlc: rfcomm.DLC, configuration: Configuration):
|
||||
# Configure internal state.
|
||||
self.dlc = dlc
|
||||
self.buffer = ''
|
||||
self.lines = collections.deque()
|
||||
self.lines_available = asyncio.Event()
|
||||
self.command_lock = asyncio.Lock()
|
||||
self.response_queue = asyncio.Queue()
|
||||
self.unsolicited_queue = asyncio.Queue()
|
||||
self.read_buffer = bytearray()
|
||||
|
||||
dlc.sink = self.feed
|
||||
# Build local features.
|
||||
self.supported_hf_features = sum(configuration.supported_hf_features)
|
||||
self.supported_audio_codecs = configuration.supported_audio_codecs
|
||||
|
||||
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')
|
||||
self.hf_indicators = {
|
||||
indicator: HfIndicatorState()
|
||||
for indicator in configuration.supported_hf_indicators
|
||||
}
|
||||
|
||||
logger.debug(f'<<< Data received: {data}')
|
||||
# Clear remote features.
|
||||
self.supported_ag_features = 0
|
||||
self.supported_ag_call_hold_operations = []
|
||||
self.ag_indicators = []
|
||||
|
||||
# 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)
|
||||
# Bind the AT reader to the RFCOMM channel.
|
||||
self.dlc.sink = self._read_at
|
||||
|
||||
def on_line(self, line: str) -> None:
|
||||
self.lines.append(line)
|
||||
self.lines_available.set()
|
||||
def supports_hf_feature(self, feature: HfFeature) -> bool:
|
||||
return (self.supported_hf_features & feature) != 0
|
||||
|
||||
def send_command_line(self, line: str) -> None:
|
||||
logger.debug(color(f'>>> {line}', 'yellow'))
|
||||
self.dlc.write(line + '\r')
|
||||
def supports_ag_feature(self, feature: AgFeature) -> bool:
|
||||
return (self.supported_ag_features & feature) != 0
|
||||
|
||||
def send_response_line(self, line: str) -> None:
|
||||
logger.debug(color(f'>>> {line}', 'yellow'))
|
||||
self.dlc.write('\r\n' + line + '\r\n')
|
||||
# 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)
|
||||
|
||||
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
|
||||
# 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 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())
|
||||
# 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()}")
|
||||
|
||||
self.send_command_line('AT+CIND=?')
|
||||
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+CMER=3,0,0,1')
|
||||
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),
|
||||
),
|
||||
]
|
||||
|
||||
@@ -15,23 +15,24 @@
|
||||
# -----------------------------------------------------------------------------
|
||||
# Imports
|
||||
# -----------------------------------------------------------------------------
|
||||
from __future__ import annotations
|
||||
import asyncio
|
||||
import collections
|
||||
import logging
|
||||
import struct
|
||||
|
||||
from typing import Optional
|
||||
|
||||
from bumble.colors import color
|
||||
from bumble.l2cap import L2CAP_PDU
|
||||
from bumble.snoop import Snooper
|
||||
from bumble import drivers
|
||||
|
||||
from typing import Optional
|
||||
|
||||
from .hci import (
|
||||
Address,
|
||||
HCI_ACL_DATA_PACKET,
|
||||
HCI_COMMAND_COMPLETE_EVENT,
|
||||
HCI_COMMAND_PACKET,
|
||||
HCI_COMMAND_COMPLETE_EVENT,
|
||||
HCI_EVENT_PACKET,
|
||||
HCI_LE_READ_BUFFER_SIZE_COMMAND,
|
||||
HCI_LE_READ_LOCAL_SUPPORTED_FEATURES_COMMAND,
|
||||
@@ -45,8 +46,11 @@ from .hci import (
|
||||
HCI_VERSION_BLUETOOTH_CORE_4_0,
|
||||
HCI_AclDataPacket,
|
||||
HCI_AclDataPacketAssembler,
|
||||
HCI_Command,
|
||||
HCI_Command_Complete_Event,
|
||||
HCI_Constant,
|
||||
HCI_Error,
|
||||
HCI_Event,
|
||||
HCI_LE_Long_Term_Key_Request_Negative_Reply_Command,
|
||||
HCI_LE_Long_Term_Key_Request_Reply_Command,
|
||||
HCI_LE_Read_Buffer_Size_Command,
|
||||
@@ -63,16 +67,15 @@ from .hci import (
|
||||
HCI_Read_Local_Version_Information_Command,
|
||||
HCI_Reset_Command,
|
||||
HCI_Set_Event_Mask_Command,
|
||||
map_null_terminated_utf8_string,
|
||||
)
|
||||
from .core import (
|
||||
BT_BR_EDR_TRANSPORT,
|
||||
BT_CENTRAL_ROLE,
|
||||
BT_LE_TRANSPORT,
|
||||
ConnectionPHY,
|
||||
ConnectionParameters,
|
||||
)
|
||||
from .utils import AbortableEventEmitter
|
||||
from .transport.common import TransportLostError
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
@@ -96,17 +99,17 @@ HOST_HC_TOTAL_NUM_ACL_DATA_PACKETS = 1
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
class Connection:
|
||||
def __init__(self, host, handle, peer_address, transport):
|
||||
def __init__(self, host: Host, handle: int, peer_address: Address, transport: int):
|
||||
self.host = host
|
||||
self.handle = handle
|
||||
self.peer_address = peer_address
|
||||
self.assembler = HCI_AclDataPacketAssembler(self.on_acl_pdu)
|
||||
self.transport = transport
|
||||
|
||||
def on_hci_acl_data_packet(self, packet):
|
||||
def on_hci_acl_data_packet(self, packet: HCI_AclDataPacket) -> None:
|
||||
self.assembler.feed_packet(packet)
|
||||
|
||||
def on_acl_pdu(self, pdu):
|
||||
def on_acl_pdu(self, pdu: bytes) -> None:
|
||||
l2cap_pdu = L2CAP_PDU.from_bytes(pdu)
|
||||
self.host.on_l2cap_pdu(self, l2cap_pdu.cid, l2cap_pdu.payload)
|
||||
|
||||
@@ -308,7 +311,7 @@ class Host(AbortableEventEmitter):
|
||||
def set_packet_sink(self, sink):
|
||||
self.hci_sink = sink
|
||||
|
||||
def send_hci_packet(self, packet):
|
||||
def send_hci_packet(self, packet: HCI_Packet) -> None:
|
||||
if self.snooper:
|
||||
self.snooper.snoop(bytes(packet), Snooper.Direction.HOST_TO_CONTROLLER)
|
||||
|
||||
@@ -349,7 +352,7 @@ class Host(AbortableEventEmitter):
|
||||
return response
|
||||
except Exception as error:
|
||||
logger.warning(
|
||||
f'{color("!!! Exception while sending HCI packet:", "red")} {error}'
|
||||
f'{color("!!! Exception while sending command:", "red")} {error}'
|
||||
)
|
||||
raise error
|
||||
finally:
|
||||
@@ -357,13 +360,13 @@ class Host(AbortableEventEmitter):
|
||||
self.pending_response = None
|
||||
|
||||
# Use this method to send a command from a task
|
||||
def send_command_sync(self, command):
|
||||
async def send_command(command):
|
||||
def send_command_sync(self, command: HCI_Command) -> None:
|
||||
async def send_command(command: HCI_Command) -> None:
|
||||
await self.send_command(command)
|
||||
|
||||
asyncio.create_task(send_command(command))
|
||||
|
||||
def send_l2cap_pdu(self, connection_handle, cid, pdu):
|
||||
def send_l2cap_pdu(self, connection_handle: int, cid: int, pdu: bytes) -> None:
|
||||
l2cap_pdu = bytes(L2CAP_PDU(cid, pdu))
|
||||
|
||||
# Send the data to the controller via ACL packets
|
||||
@@ -388,7 +391,7 @@ class Host(AbortableEventEmitter):
|
||||
offset += data_total_length
|
||||
bytes_remaining -= data_total_length
|
||||
|
||||
def queue_acl_packet(self, acl_packet):
|
||||
def queue_acl_packet(self, acl_packet: HCI_AclDataPacket) -> None:
|
||||
self.acl_packet_queue.appendleft(acl_packet)
|
||||
self.check_acl_packet_queue()
|
||||
|
||||
@@ -398,7 +401,7 @@ class Host(AbortableEventEmitter):
|
||||
f'{len(self.acl_packet_queue)} in queue'
|
||||
)
|
||||
|
||||
def check_acl_packet_queue(self):
|
||||
def check_acl_packet_queue(self) -> None:
|
||||
# Send all we can (TODO: support different LE/Classic limits)
|
||||
while (
|
||||
len(self.acl_packet_queue) > 0
|
||||
@@ -444,47 +447,53 @@ class Host(AbortableEventEmitter):
|
||||
]
|
||||
|
||||
# Packet Sink protocol (packets coming from the controller via HCI)
|
||||
def on_packet(self, packet):
|
||||
def on_packet(self, packet: bytes) -> None:
|
||||
hci_packet = HCI_Packet.from_bytes(packet)
|
||||
if self.ready or (
|
||||
hci_packet.hci_packet_type == HCI_EVENT_PACKET
|
||||
and hci_packet.event_code == HCI_COMMAND_COMPLETE_EVENT
|
||||
isinstance(hci_packet, HCI_Command_Complete_Event)
|
||||
and hci_packet.command_opcode == HCI_RESET_COMMAND
|
||||
):
|
||||
self.on_hci_packet(hci_packet)
|
||||
else:
|
||||
logger.debug('reset not done, ignoring packet from controller')
|
||||
|
||||
def on_hci_packet(self, packet):
|
||||
def on_transport_lost(self):
|
||||
# Called by the source when the transport has been lost.
|
||||
if self.pending_response:
|
||||
self.pending_response.set_exception(TransportLostError('transport lost'))
|
||||
|
||||
self.emit('flush')
|
||||
|
||||
def on_hci_packet(self, packet: HCI_Packet) -> None:
|
||||
logger.debug(f'{color("### CONTROLLER -> HOST", "green")}: {packet}')
|
||||
|
||||
if self.snooper:
|
||||
self.snooper.snoop(bytes(packet), Snooper.Direction.CONTROLLER_TO_HOST)
|
||||
|
||||
# If the packet is a command, invoke the handler for this packet
|
||||
if packet.hci_packet_type == HCI_COMMAND_PACKET:
|
||||
if isinstance(packet, HCI_Command):
|
||||
self.on_hci_command_packet(packet)
|
||||
elif packet.hci_packet_type == HCI_EVENT_PACKET:
|
||||
elif isinstance(packet, HCI_Event):
|
||||
self.on_hci_event_packet(packet)
|
||||
elif packet.hci_packet_type == HCI_ACL_DATA_PACKET:
|
||||
elif isinstance(packet, HCI_AclDataPacket):
|
||||
self.on_hci_acl_data_packet(packet)
|
||||
else:
|
||||
logger.warning(f'!!! unknown packet type {packet.hci_packet_type}')
|
||||
|
||||
def on_hci_command_packet(self, command):
|
||||
def on_hci_command_packet(self, command: HCI_Command) -> None:
|
||||
logger.warning(f'!!! unexpected command packet: {command}')
|
||||
|
||||
def on_hci_event_packet(self, event):
|
||||
def on_hci_event_packet(self, event: HCI_Event) -> None:
|
||||
handler_name = f'on_{event.name.lower()}'
|
||||
handler = getattr(self, handler_name, self.on_hci_event)
|
||||
handler(event)
|
||||
|
||||
def on_hci_acl_data_packet(self, packet):
|
||||
def on_hci_acl_data_packet(self, packet: HCI_AclDataPacket) -> None:
|
||||
# Look for the connection to which this data belongs
|
||||
if connection := self.connections.get(packet.connection_handle):
|
||||
connection.on_hci_acl_data_packet(packet)
|
||||
|
||||
def on_l2cap_pdu(self, connection, cid, pdu):
|
||||
def on_l2cap_pdu(self, connection: Connection, cid: int, pdu: bytes) -> None:
|
||||
self.emit('l2cap_pdu', connection.handle, cid, pdu)
|
||||
|
||||
def on_command_processed(self, event):
|
||||
@@ -822,6 +831,10 @@ class Host(AbortableEventEmitter):
|
||||
f'simple pairing complete for {event.bd_addr}: '
|
||||
f'status={HCI_Constant.status_name(event.status)}'
|
||||
)
|
||||
if event.status == HCI_SUCCESS:
|
||||
self.emit('classic_pairing', event.bd_addr)
|
||||
else:
|
||||
self.emit('classic_pairing_failure', event.bd_addr, event.status)
|
||||
|
||||
def on_hci_pin_code_request_event(self, event):
|
||||
self.emit('pin_code_request', event.bd_addr)
|
||||
|
||||
@@ -33,6 +33,7 @@ from typing import (
|
||||
Union,
|
||||
Deque,
|
||||
Iterable,
|
||||
SupportsBytes,
|
||||
TYPE_CHECKING,
|
||||
)
|
||||
|
||||
@@ -47,6 +48,7 @@ from .hci import (
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from bumble.device import Connection
|
||||
from bumble.host import Host
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Logging
|
||||
@@ -728,7 +730,7 @@ class Channel(EventEmitter):
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
manager: 'ChannelManager',
|
||||
manager: ChannelManager,
|
||||
connection: Connection,
|
||||
signaling_cid: int,
|
||||
psm: int,
|
||||
@@ -755,13 +757,13 @@ class Channel(EventEmitter):
|
||||
)
|
||||
self.state = new_state
|
||||
|
||||
def send_pdu(self, pdu) -> None:
|
||||
def send_pdu(self, pdu: SupportsBytes | bytes) -> None:
|
||||
self.manager.send_pdu(self.connection, self.destination_cid, pdu)
|
||||
|
||||
def send_control_frame(self, frame) -> None:
|
||||
def send_control_frame(self, frame: L2CAP_Control_Frame) -> None:
|
||||
self.manager.send_control_frame(self.connection, self.signaling_cid, frame)
|
||||
|
||||
async def send_request(self, request) -> bytes:
|
||||
async def send_request(self, request: SupportsBytes) -> bytes:
|
||||
# Check that there isn't already a request pending
|
||||
if self.response:
|
||||
raise InvalidStateError('request already pending')
|
||||
@@ -772,7 +774,7 @@ class Channel(EventEmitter):
|
||||
self.send_pdu(request)
|
||||
return await self.response
|
||||
|
||||
def on_pdu(self, pdu) -> None:
|
||||
def on_pdu(self, pdu: bytes) -> None:
|
||||
if self.response:
|
||||
self.response.set_result(pdu)
|
||||
self.response = None
|
||||
@@ -1041,7 +1043,7 @@ class LeConnectionOrientedChannel(EventEmitter):
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
manager: 'ChannelManager',
|
||||
manager: ChannelManager,
|
||||
connection: Connection,
|
||||
le_psm: int,
|
||||
source_cid: int,
|
||||
@@ -1096,10 +1098,10 @@ class LeConnectionOrientedChannel(EventEmitter):
|
||||
elif new_state == self.DISCONNECTED:
|
||||
self.emit('close')
|
||||
|
||||
def send_pdu(self, pdu) -> None:
|
||||
def send_pdu(self, pdu: SupportsBytes | bytes) -> None:
|
||||
self.manager.send_pdu(self.connection, self.destination_cid, pdu)
|
||||
|
||||
def send_control_frame(self, frame) -> None:
|
||||
def send_control_frame(self, frame: L2CAP_Control_Frame) -> None:
|
||||
self.manager.send_control_frame(self.connection, L2CAP_LE_SIGNALING_CID, frame)
|
||||
|
||||
async def connect(self) -> LeConnectionOrientedChannel:
|
||||
@@ -1154,7 +1156,7 @@ class LeConnectionOrientedChannel(EventEmitter):
|
||||
if self.state == self.CONNECTED:
|
||||
self.change_state(self.DISCONNECTED)
|
||||
|
||||
def on_pdu(self, pdu) -> None:
|
||||
def on_pdu(self, pdu: bytes) -> None:
|
||||
if self.sink is None:
|
||||
logger.warning('received pdu without a sink')
|
||||
return
|
||||
@@ -1384,6 +1386,7 @@ class ChannelManager:
|
||||
]
|
||||
le_coc_requests: Dict[int, L2CAP_LE_Credit_Based_Connection_Request]
|
||||
fixed_channels: Dict[int, Optional[Callable[[int, bytes], Any]]]
|
||||
_host: Optional[Host]
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
@@ -1407,11 +1410,12 @@ class ChannelManager:
|
||||
self.connectionless_mtu = connectionless_mtu
|
||||
|
||||
@property
|
||||
def host(self):
|
||||
def host(self) -> Host:
|
||||
assert self._host
|
||||
return self._host
|
||||
|
||||
@host.setter
|
||||
def host(self, host):
|
||||
def host(self, host: Host) -> None:
|
||||
if self._host is not None:
|
||||
self._host.remove_listener('disconnection', self.on_disconnection)
|
||||
self._host = host
|
||||
@@ -1565,7 +1569,7 @@ class ChannelManager:
|
||||
if connection_handle in self.identifiers:
|
||||
del self.identifiers[connection_handle]
|
||||
|
||||
def send_pdu(self, connection, cid: int, pdu) -> None:
|
||||
def send_pdu(self, connection, cid: int, pdu: SupportsBytes | bytes) -> None:
|
||||
pdu_str = pdu.hex() if isinstance(pdu, bytes) else str(pdu)
|
||||
logger.debug(
|
||||
f'{color(">>> Sending L2CAP PDU", "blue")} '
|
||||
@@ -1574,7 +1578,7 @@ class ChannelManager:
|
||||
)
|
||||
self.host.send_l2cap_pdu(connection.handle, cid, bytes(pdu))
|
||||
|
||||
def on_pdu(self, connection: Connection, cid: int, pdu) -> None:
|
||||
def on_pdu(self, connection: Connection, cid: int, pdu: bytes) -> None:
|
||||
if cid in (L2CAP_SIGNALING_CID, L2CAP_LE_SIGNALING_CID):
|
||||
# Parse the L2CAP payload into a Control Frame object
|
||||
control_frame = L2CAP_Control_Frame.from_bytes(pdu)
|
||||
@@ -1596,7 +1600,7 @@ class ChannelManager:
|
||||
channel.on_pdu(pdu)
|
||||
|
||||
def send_control_frame(
|
||||
self, connection: Connection, cid: int, control_frame
|
||||
self, connection: Connection, cid: int, control_frame: L2CAP_Control_Frame
|
||||
) -> None:
|
||||
logger.debug(
|
||||
f'{color(">>> Sending L2CAP Signaling Control Frame", "blue")} '
|
||||
@@ -1605,7 +1609,9 @@ class ChannelManager:
|
||||
)
|
||||
self.host.send_l2cap_pdu(connection.handle, cid, bytes(control_frame))
|
||||
|
||||
def on_control_frame(self, connection: Connection, cid: int, control_frame) -> None:
|
||||
def on_control_frame(
|
||||
self, connection: Connection, cid: int, control_frame: L2CAP_Control_Frame
|
||||
) -> None:
|
||||
logger.debug(
|
||||
f'{color("<<< Received L2CAP Signaling Control Frame", "green")} '
|
||||
f'on connection [0x{connection.handle:04X}] (CID={cid}) '
|
||||
|
||||
@@ -423,6 +423,8 @@ class SecurityService(SecurityServicer):
|
||||
'pairing': try_set_success,
|
||||
'connection_authentication': try_set_success,
|
||||
'connection_encryption_change': on_encryption_change,
|
||||
'classic_pairing': try_set_success,
|
||||
'classic_pairing_failure': set_failure('pairing_failure'),
|
||||
}
|
||||
|
||||
# register event handlers
|
||||
|
||||
233
bumble/rfcomm.py
233
bumble/rfcomm.py
@@ -15,16 +15,22 @@
|
||||
# -----------------------------------------------------------------------------
|
||||
# Imports
|
||||
# -----------------------------------------------------------------------------
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import asyncio
|
||||
import enum
|
||||
|
||||
from pyee import EventEmitter
|
||||
from typing import Optional, Tuple, Callable, Dict, Union
|
||||
from typing import Optional, Tuple, Callable, Dict, Union, TYPE_CHECKING
|
||||
|
||||
from . import core, l2cap
|
||||
from .colors import color
|
||||
from .core import BT_BR_EDR_TRANSPORT, InvalidStateError, ProtocolError
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from bumble.device import Device, Connection
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Logging
|
||||
# -----------------------------------------------------------------------------
|
||||
@@ -149,9 +155,9 @@ class RFCOMM_Frame:
|
||||
return RFCOMM_FRAME_TYPE_NAMES[self.type]
|
||||
|
||||
@staticmethod
|
||||
def parse_mcc(data) -> Tuple[int, int, bytes]:
|
||||
def parse_mcc(data) -> Tuple[int, bool, bytes]:
|
||||
mcc_type = data[0] >> 2
|
||||
c_r = (data[0] >> 1) & 1
|
||||
c_r = bool((data[0] >> 1) & 1)
|
||||
length = data[1]
|
||||
if data[1] & 1:
|
||||
length >>= 1
|
||||
@@ -192,7 +198,7 @@ class RFCOMM_Frame:
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def from_bytes(data: bytes):
|
||||
def from_bytes(data: bytes) -> RFCOMM_Frame:
|
||||
# Extract fields
|
||||
dlci = (data[0] >> 2) & 0x3F
|
||||
c_r = (data[0] >> 1) & 0x01
|
||||
@@ -215,7 +221,7 @@ class RFCOMM_Frame:
|
||||
|
||||
return frame
|
||||
|
||||
def __bytes__(self):
|
||||
def __bytes__(self) -> bytes:
|
||||
return (
|
||||
bytes([self.address, self.control])
|
||||
+ self.length
|
||||
@@ -223,7 +229,7 @@ class RFCOMM_Frame:
|
||||
+ bytes([self.fcs])
|
||||
)
|
||||
|
||||
def __str__(self):
|
||||
def __str__(self) -> str:
|
||||
return (
|
||||
f'{color(self.type_name(), "yellow")}'
|
||||
f'(c/r={self.c_r},'
|
||||
@@ -253,7 +259,7 @@ class RFCOMM_MCC_PN:
|
||||
max_frame_size: int,
|
||||
max_retransmissions: int,
|
||||
window_size: int,
|
||||
):
|
||||
) -> None:
|
||||
self.dlci = dlci
|
||||
self.cl = cl
|
||||
self.priority = priority
|
||||
@@ -263,7 +269,7 @@ class RFCOMM_MCC_PN:
|
||||
self.window_size = window_size
|
||||
|
||||
@staticmethod
|
||||
def from_bytes(data: bytes):
|
||||
def from_bytes(data: bytes) -> RFCOMM_MCC_PN:
|
||||
return RFCOMM_MCC_PN(
|
||||
dlci=data[0],
|
||||
cl=data[1],
|
||||
@@ -274,7 +280,7 @@ class RFCOMM_MCC_PN:
|
||||
window_size=data[7],
|
||||
)
|
||||
|
||||
def __bytes__(self):
|
||||
def __bytes__(self) -> bytes:
|
||||
return bytes(
|
||||
[
|
||||
self.dlci & 0xFF,
|
||||
@@ -288,7 +294,7 @@ class RFCOMM_MCC_PN:
|
||||
]
|
||||
)
|
||||
|
||||
def __str__(self):
|
||||
def __str__(self) -> str:
|
||||
return (
|
||||
f'PN(dlci={self.dlci},'
|
||||
f'cl={self.cl},'
|
||||
@@ -309,7 +315,9 @@ class RFCOMM_MCC_MSC:
|
||||
ic: int
|
||||
dv: int
|
||||
|
||||
def __init__(self, dlci: int, fc: int, rtc: int, rtr: int, ic: int, dv: int):
|
||||
def __init__(
|
||||
self, dlci: int, fc: int, rtc: int, rtr: int, ic: int, dv: int
|
||||
) -> None:
|
||||
self.dlci = dlci
|
||||
self.fc = fc
|
||||
self.rtc = rtc
|
||||
@@ -318,7 +326,7 @@ class RFCOMM_MCC_MSC:
|
||||
self.dv = dv
|
||||
|
||||
@staticmethod
|
||||
def from_bytes(data: bytes):
|
||||
def from_bytes(data: bytes) -> RFCOMM_MCC_MSC:
|
||||
return RFCOMM_MCC_MSC(
|
||||
dlci=data[0] >> 2,
|
||||
fc=data[1] >> 1 & 1,
|
||||
@@ -328,7 +336,7 @@ class RFCOMM_MCC_MSC:
|
||||
dv=data[1] >> 7 & 1,
|
||||
)
|
||||
|
||||
def __bytes__(self):
|
||||
def __bytes__(self) -> bytes:
|
||||
return bytes(
|
||||
[
|
||||
(self.dlci << 2) | 3,
|
||||
@@ -341,7 +349,7 @@ class RFCOMM_MCC_MSC:
|
||||
]
|
||||
)
|
||||
|
||||
def __str__(self):
|
||||
def __str__(self) -> str:
|
||||
return (
|
||||
f'MSC(dlci={self.dlci},'
|
||||
f'fc={self.fc},'
|
||||
@@ -354,29 +362,24 @@ class RFCOMM_MCC_MSC:
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
class DLC(EventEmitter):
|
||||
# States
|
||||
INIT = 0x00
|
||||
CONNECTING = 0x01
|
||||
CONNECTED = 0x02
|
||||
DISCONNECTING = 0x03
|
||||
DISCONNECTED = 0x04
|
||||
RESET = 0x05
|
||||
|
||||
STATE_NAMES = {
|
||||
INIT: 'INIT',
|
||||
CONNECTING: 'CONNECTING',
|
||||
CONNECTED: 'CONNECTED',
|
||||
DISCONNECTING: 'DISCONNECTING',
|
||||
DISCONNECTED: 'DISCONNECTED',
|
||||
RESET: 'RESET',
|
||||
}
|
||||
class State(enum.IntEnum):
|
||||
INIT = 0x00
|
||||
CONNECTING = 0x01
|
||||
CONNECTED = 0x02
|
||||
DISCONNECTING = 0x03
|
||||
DISCONNECTED = 0x04
|
||||
RESET = 0x05
|
||||
|
||||
connection_result: Optional[asyncio.Future]
|
||||
sink: Optional[Callable[[bytes], None]]
|
||||
|
||||
def __init__(
|
||||
self, multiplexer, dlci: int, max_frame_size: int, initial_tx_credits: int
|
||||
):
|
||||
self,
|
||||
multiplexer: Multiplexer,
|
||||
dlci: int,
|
||||
max_frame_size: int,
|
||||
initial_tx_credits: int,
|
||||
) -> None:
|
||||
super().__init__()
|
||||
self.multiplexer = multiplexer
|
||||
self.dlci = dlci
|
||||
@@ -384,9 +387,9 @@ class DLC(EventEmitter):
|
||||
self.rx_threshold = self.rx_credits // 2
|
||||
self.tx_credits = initial_tx_credits
|
||||
self.tx_buffer = b''
|
||||
self.state = DLC.INIT
|
||||
self.state = DLC.State.INIT
|
||||
self.role = multiplexer.role
|
||||
self.c_r = 1 if self.role == Multiplexer.INITIATOR else 0
|
||||
self.c_r = 1 if self.role == Multiplexer.Role.INITIATOR else 0
|
||||
self.sink = None
|
||||
self.connection_result = None
|
||||
|
||||
@@ -396,14 +399,8 @@ class DLC(EventEmitter):
|
||||
max_frame_size, self.multiplexer.l2cap_channel.mtu - max_overhead
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def state_name(state: int) -> str:
|
||||
return DLC.STATE_NAMES[state]
|
||||
|
||||
def change_state(self, new_state: int) -> None:
|
||||
logger.debug(
|
||||
f'{self} state change -> {color(self.state_name(new_state), "magenta")}'
|
||||
)
|
||||
def change_state(self, new_state: State) -> None:
|
||||
logger.debug(f'{self} state change -> {color(new_state.name, "magenta")}')
|
||||
self.state = new_state
|
||||
|
||||
def send_frame(self, frame: RFCOMM_Frame) -> None:
|
||||
@@ -413,8 +410,8 @@ class DLC(EventEmitter):
|
||||
handler = getattr(self, f'on_{frame.type_name()}_frame'.lower())
|
||||
handler(frame)
|
||||
|
||||
def on_sabm_frame(self, _frame) -> None:
|
||||
if self.state != DLC.CONNECTING:
|
||||
def on_sabm_frame(self, _frame: RFCOMM_Frame) -> None:
|
||||
if self.state != DLC.State.CONNECTING:
|
||||
logger.warning(
|
||||
color('!!! received SABM when not in CONNECTING state', 'red')
|
||||
)
|
||||
@@ -430,11 +427,11 @@ class DLC(EventEmitter):
|
||||
logger.debug(f'>>> MCC MSC Command: {msc}')
|
||||
self.send_frame(RFCOMM_Frame.uih(c_r=self.c_r, dlci=0, information=mcc))
|
||||
|
||||
self.change_state(DLC.CONNECTED)
|
||||
self.change_state(DLC.State.CONNECTED)
|
||||
self.emit('open')
|
||||
|
||||
def on_ua_frame(self, _frame) -> None:
|
||||
if self.state != DLC.CONNECTING:
|
||||
def on_ua_frame(self, _frame: RFCOMM_Frame) -> None:
|
||||
if self.state != DLC.State.CONNECTING:
|
||||
logger.warning(
|
||||
color('!!! received SABM when not in CONNECTING state', 'red')
|
||||
)
|
||||
@@ -448,14 +445,14 @@ class DLC(EventEmitter):
|
||||
logger.debug(f'>>> MCC MSC Command: {msc}')
|
||||
self.send_frame(RFCOMM_Frame.uih(c_r=self.c_r, dlci=0, information=mcc))
|
||||
|
||||
self.change_state(DLC.CONNECTED)
|
||||
self.change_state(DLC.State.CONNECTED)
|
||||
self.multiplexer.on_dlc_open_complete(self)
|
||||
|
||||
def on_dm_frame(self, frame) -> None:
|
||||
def on_dm_frame(self, frame: RFCOMM_Frame) -> None:
|
||||
# TODO: handle all states
|
||||
pass
|
||||
|
||||
def on_disc_frame(self, _frame) -> None:
|
||||
def on_disc_frame(self, _frame: RFCOMM_Frame) -> None:
|
||||
# TODO: handle all states
|
||||
self.send_frame(RFCOMM_Frame.ua(c_r=1 - self.c_r, dlci=self.dlci))
|
||||
|
||||
@@ -489,10 +486,10 @@ class DLC(EventEmitter):
|
||||
# Check if there's anything to send (including credits)
|
||||
self.process_tx()
|
||||
|
||||
def on_ui_frame(self, frame) -> None:
|
||||
def on_ui_frame(self, frame: RFCOMM_Frame) -> None:
|
||||
pass
|
||||
|
||||
def on_mcc_msc(self, c_r, msc) -> None:
|
||||
def on_mcc_msc(self, c_r: bool, msc: RFCOMM_MCC_MSC) -> None:
|
||||
if c_r:
|
||||
# Command
|
||||
logger.debug(f'<<< MCC MSC Command: {msc}')
|
||||
@@ -507,15 +504,15 @@ class DLC(EventEmitter):
|
||||
logger.debug(f'<<< MCC MSC Response: {msc}')
|
||||
|
||||
def connect(self) -> None:
|
||||
if self.state != DLC.INIT:
|
||||
if self.state != DLC.State.INIT:
|
||||
raise InvalidStateError('invalid state')
|
||||
|
||||
self.change_state(DLC.CONNECTING)
|
||||
self.change_state(DLC.State.CONNECTING)
|
||||
self.connection_result = asyncio.get_running_loop().create_future()
|
||||
self.send_frame(RFCOMM_Frame.sabm(c_r=self.c_r, dlci=self.dlci))
|
||||
|
||||
def accept(self) -> None:
|
||||
if self.state != DLC.INIT:
|
||||
if self.state != DLC.State.INIT:
|
||||
raise InvalidStateError('invalid state')
|
||||
|
||||
pn = RFCOMM_MCC_PN(
|
||||
@@ -530,7 +527,7 @@ class DLC(EventEmitter):
|
||||
mcc = RFCOMM_Frame.make_mcc(mcc_type=RFCOMM_MCC_PN_TYPE, c_r=0, data=bytes(pn))
|
||||
logger.debug(f'>>> PN Response: {pn}')
|
||||
self.send_frame(RFCOMM_Frame.uih(c_r=self.c_r, dlci=0, information=mcc))
|
||||
self.change_state(DLC.CONNECTING)
|
||||
self.change_state(DLC.State.CONNECTING)
|
||||
|
||||
def rx_credits_needed(self) -> int:
|
||||
if self.rx_credits <= self.rx_threshold:
|
||||
@@ -592,34 +589,24 @@ class DLC(EventEmitter):
|
||||
# TODO
|
||||
pass
|
||||
|
||||
def __str__(self):
|
||||
return f'DLC(dlci={self.dlci},state={self.state_name(self.state)})'
|
||||
def __str__(self) -> str:
|
||||
return f'DLC(dlci={self.dlci},state={self.state.name})'
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
class Multiplexer(EventEmitter):
|
||||
# Roles
|
||||
INITIATOR = 0x00
|
||||
RESPONDER = 0x01
|
||||
class Role(enum.IntEnum):
|
||||
INITIATOR = 0x00
|
||||
RESPONDER = 0x01
|
||||
|
||||
# States
|
||||
INIT = 0x00
|
||||
CONNECTING = 0x01
|
||||
CONNECTED = 0x02
|
||||
OPENING = 0x03
|
||||
DISCONNECTING = 0x04
|
||||
DISCONNECTED = 0x05
|
||||
RESET = 0x06
|
||||
|
||||
STATE_NAMES = {
|
||||
INIT: 'INIT',
|
||||
CONNECTING: 'CONNECTING',
|
||||
CONNECTED: 'CONNECTED',
|
||||
OPENING: 'OPENING',
|
||||
DISCONNECTING: 'DISCONNECTING',
|
||||
DISCONNECTED: 'DISCONNECTED',
|
||||
RESET: 'RESET',
|
||||
}
|
||||
class State(enum.IntEnum):
|
||||
INIT = 0x00
|
||||
CONNECTING = 0x01
|
||||
CONNECTED = 0x02
|
||||
OPENING = 0x03
|
||||
DISCONNECTING = 0x04
|
||||
DISCONNECTED = 0x05
|
||||
RESET = 0x06
|
||||
|
||||
connection_result: Optional[asyncio.Future]
|
||||
disconnection_result: Optional[asyncio.Future]
|
||||
@@ -627,11 +614,11 @@ class Multiplexer(EventEmitter):
|
||||
acceptor: Optional[Callable[[int], bool]]
|
||||
dlcs: Dict[int, DLC]
|
||||
|
||||
def __init__(self, l2cap_channel: l2cap.Channel, role: int) -> None:
|
||||
def __init__(self, l2cap_channel: l2cap.Channel, role: Role) -> None:
|
||||
super().__init__()
|
||||
self.role = role
|
||||
self.l2cap_channel = l2cap_channel
|
||||
self.state = Multiplexer.INIT
|
||||
self.state = Multiplexer.State.INIT
|
||||
self.dlcs = {} # DLCs, by DLCI
|
||||
self.connection_result = None
|
||||
self.disconnection_result = None
|
||||
@@ -641,14 +628,8 @@ class Multiplexer(EventEmitter):
|
||||
# Become a sink for the L2CAP channel
|
||||
l2cap_channel.sink = self.on_pdu
|
||||
|
||||
@staticmethod
|
||||
def state_name(state: int):
|
||||
return Multiplexer.STATE_NAMES[state]
|
||||
|
||||
def change_state(self, new_state: int) -> None:
|
||||
logger.debug(
|
||||
f'{self} state change -> {color(self.state_name(new_state), "cyan")}'
|
||||
)
|
||||
def change_state(self, new_state: State) -> None:
|
||||
logger.debug(f'{self} state change -> {color(new_state.name, "cyan")}')
|
||||
self.state = new_state
|
||||
|
||||
def send_frame(self, frame: RFCOMM_Frame) -> None:
|
||||
@@ -679,28 +660,28 @@ class Multiplexer(EventEmitter):
|
||||
handler = getattr(self, f'on_{frame.type_name()}_frame'.lower())
|
||||
handler(frame)
|
||||
|
||||
def on_sabm_frame(self, _frame) -> None:
|
||||
if self.state != Multiplexer.INIT:
|
||||
def on_sabm_frame(self, _frame: RFCOMM_Frame) -> None:
|
||||
if self.state != Multiplexer.State.INIT:
|
||||
logger.debug('not in INIT state, ignoring SABM')
|
||||
return
|
||||
self.change_state(Multiplexer.CONNECTED)
|
||||
self.change_state(Multiplexer.State.CONNECTED)
|
||||
self.send_frame(RFCOMM_Frame.ua(c_r=1, dlci=0))
|
||||
|
||||
def on_ua_frame(self, _frame) -> None:
|
||||
if self.state == Multiplexer.CONNECTING:
|
||||
self.change_state(Multiplexer.CONNECTED)
|
||||
def on_ua_frame(self, _frame: RFCOMM_Frame) -> None:
|
||||
if self.state == Multiplexer.State.CONNECTING:
|
||||
self.change_state(Multiplexer.State.CONNECTED)
|
||||
if self.connection_result:
|
||||
self.connection_result.set_result(0)
|
||||
self.connection_result = None
|
||||
elif self.state == Multiplexer.DISCONNECTING:
|
||||
self.change_state(Multiplexer.DISCONNECTED)
|
||||
elif self.state == Multiplexer.State.DISCONNECTING:
|
||||
self.change_state(Multiplexer.State.DISCONNECTED)
|
||||
if self.disconnection_result:
|
||||
self.disconnection_result.set_result(None)
|
||||
self.disconnection_result = None
|
||||
|
||||
def on_dm_frame(self, _frame) -> None:
|
||||
if self.state == Multiplexer.OPENING:
|
||||
self.change_state(Multiplexer.CONNECTED)
|
||||
def on_dm_frame(self, _frame: RFCOMM_Frame) -> None:
|
||||
if self.state == Multiplexer.State.OPENING:
|
||||
self.change_state(Multiplexer.State.CONNECTED)
|
||||
if self.open_result:
|
||||
self.open_result.set_exception(
|
||||
core.ConnectionError(
|
||||
@@ -713,10 +694,12 @@ class Multiplexer(EventEmitter):
|
||||
else:
|
||||
logger.warning(f'unexpected state for DM: {self}')
|
||||
|
||||
def on_disc_frame(self, _frame) -> None:
|
||||
self.change_state(Multiplexer.DISCONNECTED)
|
||||
def on_disc_frame(self, _frame: RFCOMM_Frame) -> None:
|
||||
self.change_state(Multiplexer.State.DISCONNECTED)
|
||||
self.send_frame(
|
||||
RFCOMM_Frame.ua(c_r=0 if self.role == Multiplexer.INITIATOR else 1, dlci=0)
|
||||
RFCOMM_Frame.ua(
|
||||
c_r=0 if self.role == Multiplexer.Role.INITIATOR else 1, dlci=0
|
||||
)
|
||||
)
|
||||
|
||||
def on_uih_frame(self, frame: RFCOMM_Frame) -> None:
|
||||
@@ -729,11 +712,11 @@ class Multiplexer(EventEmitter):
|
||||
mcs = RFCOMM_MCC_MSC.from_bytes(value)
|
||||
self.on_mcc_msc(c_r, mcs)
|
||||
|
||||
def on_ui_frame(self, frame) -> None:
|
||||
def on_ui_frame(self, frame: RFCOMM_Frame) -> None:
|
||||
pass
|
||||
|
||||
def on_mcc_pn(self, c_r, pn) -> None:
|
||||
if c_r == 1:
|
||||
def on_mcc_pn(self, c_r: bool, pn: RFCOMM_MCC_PN) -> None:
|
||||
if c_r:
|
||||
# Command
|
||||
logger.debug(f'<<< PN Command: {pn}')
|
||||
|
||||
@@ -764,14 +747,14 @@ class Multiplexer(EventEmitter):
|
||||
else:
|
||||
# Response
|
||||
logger.debug(f'>>> PN Response: {pn}')
|
||||
if self.state == Multiplexer.OPENING:
|
||||
if self.state == Multiplexer.State.OPENING:
|
||||
dlc = DLC(self, pn.dlci, pn.max_frame_size, pn.window_size)
|
||||
self.dlcs[pn.dlci] = dlc
|
||||
dlc.connect()
|
||||
else:
|
||||
logger.warning('ignoring PN response')
|
||||
|
||||
def on_mcc_msc(self, c_r, msc) -> None:
|
||||
def on_mcc_msc(self, c_r: bool, msc: RFCOMM_MCC_MSC) -> None:
|
||||
dlc = self.dlcs.get(msc.dlci)
|
||||
if dlc is None:
|
||||
logger.warning(f'no dlc for DLCI {msc.dlci}')
|
||||
@@ -779,30 +762,30 @@ class Multiplexer(EventEmitter):
|
||||
dlc.on_mcc_msc(c_r, msc)
|
||||
|
||||
async def connect(self) -> None:
|
||||
if self.state != Multiplexer.INIT:
|
||||
if self.state != Multiplexer.State.INIT:
|
||||
raise InvalidStateError('invalid state')
|
||||
|
||||
self.change_state(Multiplexer.CONNECTING)
|
||||
self.change_state(Multiplexer.State.CONNECTING)
|
||||
self.connection_result = asyncio.get_running_loop().create_future()
|
||||
self.send_frame(RFCOMM_Frame.sabm(c_r=1, dlci=0))
|
||||
return await self.connection_result
|
||||
|
||||
async def disconnect(self) -> None:
|
||||
if self.state != Multiplexer.CONNECTED:
|
||||
if self.state != Multiplexer.State.CONNECTED:
|
||||
return
|
||||
|
||||
self.disconnection_result = asyncio.get_running_loop().create_future()
|
||||
self.change_state(Multiplexer.DISCONNECTING)
|
||||
self.change_state(Multiplexer.State.DISCONNECTING)
|
||||
self.send_frame(
|
||||
RFCOMM_Frame.disc(
|
||||
c_r=1 if self.role == Multiplexer.INITIATOR else 0, dlci=0
|
||||
c_r=1 if self.role == Multiplexer.Role.INITIATOR else 0, dlci=0
|
||||
)
|
||||
)
|
||||
await self.disconnection_result
|
||||
|
||||
async def open_dlc(self, channel: int) -> DLC:
|
||||
if self.state != Multiplexer.CONNECTED:
|
||||
if self.state == Multiplexer.OPENING:
|
||||
if self.state != Multiplexer.State.CONNECTED:
|
||||
if self.state == Multiplexer.State.OPENING:
|
||||
raise InvalidStateError('open already in progress')
|
||||
|
||||
raise InvalidStateError('not connected')
|
||||
@@ -819,10 +802,10 @@ class Multiplexer(EventEmitter):
|
||||
mcc = RFCOMM_Frame.make_mcc(mcc_type=RFCOMM_MCC_PN_TYPE, c_r=1, data=bytes(pn))
|
||||
logger.debug(f'>>> Sending MCC: {pn}')
|
||||
self.open_result = asyncio.get_running_loop().create_future()
|
||||
self.change_state(Multiplexer.OPENING)
|
||||
self.change_state(Multiplexer.State.OPENING)
|
||||
self.send_frame(
|
||||
RFCOMM_Frame.uih(
|
||||
c_r=1 if self.role == Multiplexer.INITIATOR else 0,
|
||||
c_r=1 if self.role == Multiplexer.Role.INITIATOR else 0,
|
||||
dlci=0,
|
||||
information=mcc,
|
||||
)
|
||||
@@ -831,14 +814,14 @@ class Multiplexer(EventEmitter):
|
||||
self.open_result = None
|
||||
return result
|
||||
|
||||
def on_dlc_open_complete(self, dlc: DLC):
|
||||
def on_dlc_open_complete(self, dlc: DLC) -> None:
|
||||
logger.debug(f'DLC [{dlc.dlci}] open complete')
|
||||
self.change_state(Multiplexer.CONNECTED)
|
||||
self.change_state(Multiplexer.State.CONNECTED)
|
||||
if self.open_result:
|
||||
self.open_result.set_result(dlc)
|
||||
|
||||
def __str__(self):
|
||||
return f'Multiplexer(state={self.state_name(self.state)})'
|
||||
def __str__(self) -> str:
|
||||
return f'Multiplexer(state={self.state.name})'
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
@@ -846,7 +829,7 @@ class Client:
|
||||
multiplexer: Optional[Multiplexer]
|
||||
l2cap_channel: Optional[l2cap.Channel]
|
||||
|
||||
def __init__(self, device, connection) -> None:
|
||||
def __init__(self, device: Device, connection: Connection) -> None:
|
||||
self.device = device
|
||||
self.connection = connection
|
||||
self.l2cap_channel = None
|
||||
@@ -864,7 +847,7 @@ class Client:
|
||||
|
||||
assert self.l2cap_channel is not None
|
||||
# Create a mutliplexer to manage DLCs with the server
|
||||
self.multiplexer = Multiplexer(self.l2cap_channel, Multiplexer.INITIATOR)
|
||||
self.multiplexer = Multiplexer(self.l2cap_channel, Multiplexer.Role.INITIATOR)
|
||||
|
||||
# Connect the multiplexer
|
||||
await self.multiplexer.connect()
|
||||
@@ -886,7 +869,7 @@ class Client:
|
||||
class Server(EventEmitter):
|
||||
acceptors: Dict[int, Callable[[DLC], None]]
|
||||
|
||||
def __init__(self, device) -> None:
|
||||
def __init__(self, device: Device) -> None:
|
||||
super().__init__()
|
||||
self.device = device
|
||||
self.multiplexer = None
|
||||
@@ -925,7 +908,7 @@ class Server(EventEmitter):
|
||||
logger.debug(f'$$$ L2CAP channel open: {l2cap_channel}')
|
||||
|
||||
# Create a new multiplexer for the channel
|
||||
multiplexer = Multiplexer(l2cap_channel, Multiplexer.RESPONDER)
|
||||
multiplexer = Multiplexer(l2cap_channel, Multiplexer.Role.RESPONDER)
|
||||
multiplexer.acceptor = self.accept_dlc
|
||||
multiplexer.on('dlc', self.on_dlc)
|
||||
|
||||
|
||||
115
bumble/sdp.py
115
bumble/sdp.py
@@ -18,13 +18,16 @@
|
||||
from __future__ import annotations
|
||||
import logging
|
||||
import struct
|
||||
from typing import Dict, List, Type
|
||||
from typing import Dict, List, Type, Optional, Tuple, Union, NewType, TYPE_CHECKING
|
||||
|
||||
from . import core
|
||||
from . import core, l2cap
|
||||
from .colors import color
|
||||
from .core import InvalidStateError
|
||||
from .hci import HCI_Object, name_or_number, key_with_value
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from .device import Device, Connection
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Logging
|
||||
# -----------------------------------------------------------------------------
|
||||
@@ -94,6 +97,10 @@ 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',
|
||||
@@ -462,7 +469,7 @@ class ServiceAttribute:
|
||||
self.value = value
|
||||
|
||||
@staticmethod
|
||||
def list_from_data_elements(elements):
|
||||
def list_from_data_elements(elements: List[DataElement]) -> List[ServiceAttribute]:
|
||||
attribute_list = []
|
||||
for i in range(0, len(elements) // 2):
|
||||
attribute_id, attribute_value = elements[2 * i : 2 * (i + 1)]
|
||||
@@ -474,7 +481,9 @@ class ServiceAttribute:
|
||||
return attribute_list
|
||||
|
||||
@staticmethod
|
||||
def find_attribute_in_list(attribute_list, attribute_id):
|
||||
def find_attribute_in_list(
|
||||
attribute_list: List[ServiceAttribute], attribute_id: int
|
||||
) -> Optional[DataElement]:
|
||||
return next(
|
||||
(
|
||||
attribute.value
|
||||
@@ -489,7 +498,7 @@ class ServiceAttribute:
|
||||
return name_or_number(SDP_ATTRIBUTE_ID_NAMES, id_code)
|
||||
|
||||
@staticmethod
|
||||
def is_uuid_in_value(uuid, value):
|
||||
def is_uuid_in_value(uuid: core.UUID, value: DataElement) -> bool:
|
||||
# Find if a uuid matches a value, either directly or recursing into sequences
|
||||
if value.type == DataElement.UUID:
|
||||
return value.value == uuid
|
||||
@@ -543,7 +552,9 @@ class SDP_PDU:
|
||||
return self
|
||||
|
||||
@staticmethod
|
||||
def parse_service_record_handle_list_preceded_by_count(data, offset):
|
||||
def parse_service_record_handle_list_preceded_by_count(
|
||||
data: bytes, offset: int
|
||||
) -> Tuple[int, List[int]]:
|
||||
count = struct.unpack_from('>H', data, offset - 2)[0]
|
||||
handle_list = [
|
||||
struct.unpack_from('>I', data, offset + x * 4)[0] for x in range(count)
|
||||
@@ -641,6 +652,10 @@ class SDP_ServiceSearchRequest(SDP_PDU):
|
||||
See Bluetooth spec @ Vol 3, Part B - 4.5.1 SDP_ServiceSearchRequest PDU
|
||||
'''
|
||||
|
||||
service_search_pattern: DataElement
|
||||
maximum_service_record_count: int
|
||||
continuation_state: bytes
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
@SDP_PDU.subclass(
|
||||
@@ -659,6 +674,11 @@ class SDP_ServiceSearchResponse(SDP_PDU):
|
||||
See Bluetooth spec @ Vol 3, Part B - 4.5.2 SDP_ServiceSearchResponse PDU
|
||||
'''
|
||||
|
||||
service_record_handle_list: List[int]
|
||||
total_service_record_count: int
|
||||
current_service_record_count: int
|
||||
continuation_state: bytes
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
@SDP_PDU.subclass(
|
||||
@@ -674,6 +694,11 @@ class SDP_ServiceAttributeRequest(SDP_PDU):
|
||||
See Bluetooth spec @ Vol 3, Part B - 4.6.1 SDP_ServiceAttributeRequest PDU
|
||||
'''
|
||||
|
||||
service_record_handle: int
|
||||
maximum_attribute_byte_count: int
|
||||
attribute_id_list: DataElement
|
||||
continuation_state: bytes
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
@SDP_PDU.subclass(
|
||||
@@ -688,6 +713,10 @@ class SDP_ServiceAttributeResponse(SDP_PDU):
|
||||
See Bluetooth spec @ Vol 3, Part B - 4.6.2 SDP_ServiceAttributeResponse PDU
|
||||
'''
|
||||
|
||||
attribute_list_byte_count: int
|
||||
attribute_list: bytes
|
||||
continuation_state: bytes
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
@SDP_PDU.subclass(
|
||||
@@ -703,6 +732,11 @@ class SDP_ServiceSearchAttributeRequest(SDP_PDU):
|
||||
See Bluetooth spec @ Vol 3, Part B - 4.7.1 SDP_ServiceSearchAttributeRequest PDU
|
||||
'''
|
||||
|
||||
service_search_pattern: DataElement
|
||||
maximum_attribute_byte_count: int
|
||||
attribute_id_list: DataElement
|
||||
continuation_state: bytes
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
@SDP_PDU.subclass(
|
||||
@@ -717,26 +751,34 @@ class SDP_ServiceSearchAttributeResponse(SDP_PDU):
|
||||
See Bluetooth spec @ Vol 3, Part B - 4.7.2 SDP_ServiceSearchAttributeResponse PDU
|
||||
'''
|
||||
|
||||
attribute_list_byte_count: int
|
||||
attribute_list: bytes
|
||||
continuation_state: bytes
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
class Client:
|
||||
def __init__(self, device):
|
||||
channel: Optional[l2cap.Channel]
|
||||
|
||||
def __init__(self, device: Device) -> None:
|
||||
self.device = device
|
||||
self.pending_request = None
|
||||
self.channel = None
|
||||
|
||||
async def connect(self, connection):
|
||||
async def connect(self, connection: Connection) -> None:
|
||||
result = await self.device.l2cap_channel_manager.connect(connection, SDP_PSM)
|
||||
self.channel = result
|
||||
|
||||
async def disconnect(self):
|
||||
async def disconnect(self) -> None:
|
||||
if self.channel:
|
||||
await self.channel.disconnect()
|
||||
self.channel = None
|
||||
|
||||
async def search_services(self, uuids):
|
||||
async def search_services(self, uuids: List[core.UUID]) -> List[int]:
|
||||
if self.pending_request is not None:
|
||||
raise InvalidStateError('request already pending')
|
||||
if self.channel is None:
|
||||
raise InvalidStateError('L2CAP not connected')
|
||||
|
||||
service_search_pattern = DataElement.sequence(
|
||||
[DataElement.uuid(uuid) for uuid in uuids]
|
||||
@@ -766,9 +808,13 @@ class Client:
|
||||
|
||||
return service_record_handle_list
|
||||
|
||||
async def search_attributes(self, uuids, attribute_ids):
|
||||
async def search_attributes(
|
||||
self, uuids: List[core.UUID], attribute_ids: List[Union[int, Tuple[int, int]]]
|
||||
) -> List[List[ServiceAttribute]]:
|
||||
if self.pending_request is not None:
|
||||
raise InvalidStateError('request already pending')
|
||||
if self.channel is None:
|
||||
raise InvalidStateError('L2CAP not connected')
|
||||
|
||||
service_search_pattern = DataElement.sequence(
|
||||
[DataElement.uuid(uuid) for uuid in uuids]
|
||||
@@ -819,9 +865,15 @@ class Client:
|
||||
if sequence.type == DataElement.SEQUENCE
|
||||
]
|
||||
|
||||
async def get_attributes(self, service_record_handle, attribute_ids):
|
||||
async def get_attributes(
|
||||
self,
|
||||
service_record_handle: int,
|
||||
attribute_ids: List[Union[int, Tuple[int, int]]],
|
||||
) -> List[ServiceAttribute]:
|
||||
if self.pending_request is not None:
|
||||
raise InvalidStateError('request already pending')
|
||||
if self.channel is None:
|
||||
raise InvalidStateError('L2CAP not connected')
|
||||
|
||||
attribute_id_list = DataElement.sequence(
|
||||
[
|
||||
@@ -869,21 +921,25 @@ class Client:
|
||||
# -----------------------------------------------------------------------------
|
||||
class Server:
|
||||
CONTINUATION_STATE = bytes([0x01, 0x43])
|
||||
channel: Optional[l2cap.Channel]
|
||||
Service = NewType('Service', List[ServiceAttribute])
|
||||
service_records: Dict[int, Service]
|
||||
current_response: Union[None, bytes, Tuple[int, List[int]]]
|
||||
|
||||
def __init__(self, device):
|
||||
def __init__(self, device: Device) -> None:
|
||||
self.device = device
|
||||
self.service_records = {} # Service records maps, by record handle
|
||||
self.channel = None
|
||||
self.current_response = None
|
||||
|
||||
def register(self, l2cap_channel_manager):
|
||||
def register(self, l2cap_channel_manager: l2cap.ChannelManager) -> None:
|
||||
l2cap_channel_manager.register_server(SDP_PSM, self.on_connection)
|
||||
|
||||
def send_response(self, response):
|
||||
logger.debug(f'{color(">>> Sending SDP Response", "blue")}: {response}')
|
||||
self.channel.send_pdu(response)
|
||||
|
||||
def match_services(self, search_pattern):
|
||||
def match_services(self, search_pattern: DataElement) -> Dict[int, Service]:
|
||||
# Find the services for which the attributes in the pattern is a subset of the
|
||||
# service's attribute values (NOTE: the value search recurses into sequences)
|
||||
matching_services = {}
|
||||
@@ -953,7 +1009,9 @@ class Server:
|
||||
return (payload, continuation_state)
|
||||
|
||||
@staticmethod
|
||||
def get_service_attributes(service, attribute_ids):
|
||||
def get_service_attributes(
|
||||
service: Service, attribute_ids: List[DataElement]
|
||||
) -> DataElement:
|
||||
attributes = []
|
||||
for attribute_id in attribute_ids:
|
||||
if attribute_id.value_size == 4:
|
||||
@@ -978,10 +1036,10 @@ class Server:
|
||||
|
||||
return attribute_list
|
||||
|
||||
def on_sdp_service_search_request(self, request):
|
||||
def on_sdp_service_search_request(self, request: SDP_ServiceSearchRequest) -> None:
|
||||
# Check if this is a continuation
|
||||
if len(request.continuation_state) > 1:
|
||||
if not self.current_response:
|
||||
if self.current_response is None:
|
||||
self.send_response(
|
||||
SDP_ErrorResponse(
|
||||
transaction_id=request.transaction_id,
|
||||
@@ -1010,6 +1068,7 @@ class Server:
|
||||
)
|
||||
|
||||
# Respond, keeping any unsent handles for later
|
||||
assert isinstance(self.current_response, tuple)
|
||||
service_record_handles = self.current_response[1][
|
||||
: request.maximum_service_record_count
|
||||
]
|
||||
@@ -1033,10 +1092,12 @@ class Server:
|
||||
)
|
||||
)
|
||||
|
||||
def on_sdp_service_attribute_request(self, request):
|
||||
def on_sdp_service_attribute_request(
|
||||
self, request: SDP_ServiceAttributeRequest
|
||||
) -> None:
|
||||
# Check if this is a continuation
|
||||
if len(request.continuation_state) > 1:
|
||||
if not self.current_response:
|
||||
if self.current_response is None:
|
||||
self.send_response(
|
||||
SDP_ErrorResponse(
|
||||
transaction_id=request.transaction_id,
|
||||
@@ -1069,22 +1130,24 @@ class Server:
|
||||
self.current_response = bytes(attribute_list)
|
||||
|
||||
# Respond, keeping any pending chunks for later
|
||||
attribute_list, continuation_state = self.get_next_response_payload(
|
||||
attribute_list_response, continuation_state = self.get_next_response_payload(
|
||||
request.maximum_attribute_byte_count
|
||||
)
|
||||
self.send_response(
|
||||
SDP_ServiceAttributeResponse(
|
||||
transaction_id=request.transaction_id,
|
||||
attribute_list_byte_count=len(attribute_list),
|
||||
attribute_list_byte_count=len(attribute_list_response),
|
||||
attribute_list=attribute_list,
|
||||
continuation_state=continuation_state,
|
||||
)
|
||||
)
|
||||
|
||||
def on_sdp_service_search_attribute_request(self, request):
|
||||
def on_sdp_service_search_attribute_request(
|
||||
self, request: SDP_ServiceSearchAttributeRequest
|
||||
) -> None:
|
||||
# Check if this is a continuation
|
||||
if len(request.continuation_state) > 1:
|
||||
if not self.current_response:
|
||||
if self.current_response is None:
|
||||
self.send_response(
|
||||
SDP_ErrorResponse(
|
||||
transaction_id=request.transaction_id,
|
||||
@@ -1114,13 +1177,13 @@ class Server:
|
||||
self.current_response = bytes(attribute_lists)
|
||||
|
||||
# Respond, keeping any pending chunks for later
|
||||
attribute_lists, continuation_state = self.get_next_response_payload(
|
||||
attribute_lists_response, continuation_state = self.get_next_response_payload(
|
||||
request.maximum_attribute_byte_count
|
||||
)
|
||||
self.send_response(
|
||||
SDP_ServiceSearchAttributeResponse(
|
||||
transaction_id=request.transaction_id,
|
||||
attribute_lists_byte_count=len(attribute_lists),
|
||||
attribute_lists_byte_count=len(attribute_lists_response),
|
||||
attribute_lists=attribute_lists,
|
||||
continuation_state=continuation_state,
|
||||
)
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -44,11 +44,18 @@ HCI_PACKET_INFO = {
|
||||
}
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
class TransportLostError(Exception):
|
||||
"""
|
||||
The Transport has been lost/disconnected.
|
||||
"""
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
class PacketPump:
|
||||
'''
|
||||
Pump HCI packets from a reader to a sink
|
||||
'''
|
||||
"""
|
||||
Pump HCI packets from a reader to a sink.
|
||||
"""
|
||||
|
||||
def __init__(self, reader, sink):
|
||||
self.reader = reader
|
||||
@@ -68,10 +75,10 @@ class PacketPump:
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
class PacketParser:
|
||||
'''
|
||||
"""
|
||||
In-line parser that accepts data and emits 'on_packet' when a full packet has been
|
||||
parsed
|
||||
'''
|
||||
parsed.
|
||||
"""
|
||||
|
||||
# pylint: disable=attribute-defined-outside-init
|
||||
|
||||
@@ -134,9 +141,9 @@ class PacketParser:
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
class PacketReader:
|
||||
'''
|
||||
Reader that reads HCI packets from a sync source
|
||||
'''
|
||||
"""
|
||||
Reader that reads HCI packets from a sync source.
|
||||
"""
|
||||
|
||||
def __init__(self, source):
|
||||
self.source = source
|
||||
@@ -169,9 +176,9 @@ class PacketReader:
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
class AsyncPacketReader:
|
||||
'''
|
||||
Reader that reads HCI packets from an async source
|
||||
'''
|
||||
"""
|
||||
Reader that reads HCI packets from an async source.
|
||||
"""
|
||||
|
||||
def __init__(self, source):
|
||||
self.source = source
|
||||
@@ -198,9 +205,9 @@ class AsyncPacketReader:
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
class AsyncPipeSink:
|
||||
'''
|
||||
Sink that forwards packets asynchronously to another sink
|
||||
'''
|
||||
"""
|
||||
Sink that forwards packets asynchronously to another sink.
|
||||
"""
|
||||
|
||||
def __init__(self, sink):
|
||||
self.sink = sink
|
||||
@@ -216,6 +223,9 @@ class ParserSource:
|
||||
Base class designed to be subclassed by transport-specific source classes
|
||||
"""
|
||||
|
||||
terminated: asyncio.Future
|
||||
parser: PacketParser
|
||||
|
||||
def __init__(self):
|
||||
self.parser = PacketParser()
|
||||
self.terminated = asyncio.get_running_loop().create_future()
|
||||
@@ -223,7 +233,19 @@ class ParserSource:
|
||||
def set_packet_sink(self, sink):
|
||||
self.parser.set_packet_sink(sink)
|
||||
|
||||
def on_transport_lost(self):
|
||||
self.terminated.set_result(None)
|
||||
if self.parser.sink:
|
||||
try:
|
||||
self.parser.sink.on_transport_lost()
|
||||
except AttributeError:
|
||||
pass
|
||||
|
||||
async def wait_for_termination(self):
|
||||
"""
|
||||
Convenience method for backward compatibility. Prefer using the `terminated`
|
||||
attribute instead.
|
||||
"""
|
||||
return await self.terminated
|
||||
|
||||
def close(self):
|
||||
|
||||
@@ -39,7 +39,7 @@ async def open_tcp_client_transport(spec):
|
||||
class TcpPacketSource(StreamPacketSource):
|
||||
def connection_lost(self, exc):
|
||||
logger.debug(f'connection lost: {exc}')
|
||||
self.terminated.set_result(exc)
|
||||
self.on_transport_lost()
|
||||
|
||||
remote_host, remote_port = spec.split(':')
|
||||
tcp_transport, packet_source = await asyncio.get_running_loop().create_connection(
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -3,7 +3,7 @@ channels:
|
||||
- defaults
|
||||
- conda-forge
|
||||
dependencies:
|
||||
- pip=20
|
||||
- pip=23
|
||||
- python=3.8
|
||||
- pip:
|
||||
- --editable .[development,documentation,test]
|
||||
|
||||
@@ -16,9 +16,11 @@
|
||||
# Imports
|
||||
# -----------------------------------------------------------------------------
|
||||
import asyncio
|
||||
import collections
|
||||
import sys
|
||||
import os
|
||||
import logging
|
||||
from typing import Union
|
||||
|
||||
from bumble.colors import color
|
||||
|
||||
@@ -30,6 +32,7 @@ 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,
|
||||
@@ -39,7 +42,64 @@ from bumble.sdp import (
|
||||
SDP_SERVICE_CLASS_ID_LIST_ATTRIBUTE_ID,
|
||||
SDP_BLUETOOTH_PROFILE_DESCRIPTOR_LIST_ATTRIBUTE_ID,
|
||||
)
|
||||
from bumble.hfp import HfpProtocol
|
||||
|
||||
|
||||
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
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
@@ -21,82 +21,22 @@ 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.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),
|
||||
]
|
||||
)
|
||||
]
|
||||
),
|
||||
),
|
||||
]
|
||||
}
|
||||
from bumble import hfp
|
||||
from bumble.hfp import HfProtocol
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
class UiServer:
|
||||
protocol = None
|
||||
protocol: Optional[HfProtocol] = 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:
|
||||
@@ -107,7 +47,7 @@ class UiServer:
|
||||
message_type = parsed['type']
|
||||
if message_type == 'at_command':
|
||||
if self.protocol is not None:
|
||||
self.protocol.send_command_line(parsed['command'])
|
||||
await self.protocol.execute_command(parsed['command'])
|
||||
|
||||
except websockets.exceptions.ConnectionClosedOK:
|
||||
pass
|
||||
@@ -117,19 +57,11 @@ class UiServer:
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
async def protocol_loop(protocol):
|
||||
await protocol.initialize_service()
|
||||
|
||||
while True:
|
||||
await (protocol.next_line())
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
def on_dlc(dlc):
|
||||
def on_dlc(dlc, configuration: hfp.Configuration):
|
||||
print('*** DLC connected', dlc)
|
||||
protocol = HfpProtocol(dlc)
|
||||
protocol = HfProtocol(dlc, configuration)
|
||||
UiServer.protocol = protocol
|
||||
asyncio.create_task(protocol_loop(protocol))
|
||||
asyncio.create_task(protocol.run())
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
@@ -143,6 +75,27 @@ 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
|
||||
@@ -151,11 +104,13 @@ async def main():
|
||||
rfcomm_server = RfcommServer(device)
|
||||
|
||||
# Listen for incoming DLC connections
|
||||
channel_number = rfcomm_server.listen(on_dlc)
|
||||
channel_number = rfcomm_server.listen(lambda dlc: on_dlc(dlc, configuration))
|
||||
print(f'### Listening for connection on channel {channel_number}')
|
||||
|
||||
# Advertise the HFP RFComm channel in the SDP
|
||||
device.sdp_service_records = make_sdp_records(channel_number)
|
||||
device.sdp_service_records = {
|
||||
0x00010001: hfp.sdp_records(0x00010001, channel_number, configuration)
|
||||
}
|
||||
|
||||
# Let's go!
|
||||
await device.power_on()
|
||||
|
||||
833
rust/Cargo.lock
generated
833
rust/Cargo.lock
generated
File diff suppressed because it is too large
Load Diff
@@ -10,12 +10,12 @@ documentation = "https://docs.rs/crate/bumble"
|
||||
authors = ["Marshall Pierce <marshallpierce@google.com>"]
|
||||
keywords = ["bluetooth", "ble"]
|
||||
categories = ["api-bindings", "network-programming"]
|
||||
rust-version = "1.69.0"
|
||||
rust-version = "1.70.0"
|
||||
|
||||
[dependencies]
|
||||
pyo3 = { version = "0.18.3", features = ["macros"] }
|
||||
pyo3-asyncio = { version = "0.18.0", features = ["tokio-runtime"] }
|
||||
tokio = { version = "1.28.2" }
|
||||
tokio = { version = "1.28.2", features = ["macros", "signal"] }
|
||||
nom = "7.1.3"
|
||||
strum = "0.25.0"
|
||||
strum_macros = "0.25.0"
|
||||
@@ -23,7 +23,17 @@ hex = "0.4.3"
|
||||
itertools = "0.11.0"
|
||||
lazy_static = "1.4.0"
|
||||
thiserror = "1.0.41"
|
||||
|
||||
# CLI
|
||||
anyhow = { version = "1.0.71", optional = true }
|
||||
clap = { version = "4.3.3", features = ["derive"], optional = true }
|
||||
directories = { version = "5.0.1", optional = true }
|
||||
env_logger = { version = "0.10.0", optional = true }
|
||||
futures = { version = "0.3.28", optional = true }
|
||||
log = { version = "0.4.19", optional = true }
|
||||
owo-colors = { version = "3.5.0", optional = true }
|
||||
reqwest = { version = "0.11.20", features = ["blocking"], optional = true }
|
||||
rusb = { version = "0.9.2", optional = true }
|
||||
|
||||
[dev-dependencies]
|
||||
tokio = { version = "1.28.2", features = ["full"] }
|
||||
@@ -32,17 +42,25 @@ nix = "0.26.2"
|
||||
anyhow = "1.0.71"
|
||||
pyo3 = { version = "0.18.3", features = ["macros", "anyhow"] }
|
||||
pyo3-asyncio = { version = "0.18.0", features = ["tokio-runtime", "attributes", "testing"] }
|
||||
rusb = "0.9.2"
|
||||
rand = "0.8.5"
|
||||
clap = { version = "4.3.3", features = ["derive"] }
|
||||
owo-colors = "3.5.0"
|
||||
log = "0.4.19"
|
||||
env_logger = "0.10.0"
|
||||
rusb = "0.9.2"
|
||||
rand = "0.8.5"
|
||||
|
||||
[package.metadata.docs.rs]
|
||||
rustdoc-args = ["--generate-link-to-definition"]
|
||||
|
||||
[[bin]]
|
||||
name = "gen-assigned-numbers"
|
||||
path = "tools/gen_assigned_numbers.rs"
|
||||
required-features = ["bumble-dev-tools"]
|
||||
required-features = ["bumble-codegen"]
|
||||
|
||||
[[bin]]
|
||||
name = "bumble"
|
||||
path = "src/main.rs"
|
||||
required-features = ["bumble-tools"]
|
||||
|
||||
# test entry point that uses pyo3_asyncio's test harness
|
||||
[[test]]
|
||||
@@ -53,4 +71,7 @@ harness = false
|
||||
[features]
|
||||
anyhow = ["pyo3/anyhow"]
|
||||
pyo3-asyncio-attributes = ["pyo3-asyncio/attributes"]
|
||||
bumble-dev-tools = ["dep:anyhow"]
|
||||
bumble-codegen = ["dep:anyhow"]
|
||||
# separate feature for CLI so that dependencies don't spend time building these
|
||||
bumble-tools = ["dep:clap", "anyhow", "dep:anyhow", "dep:directories", "pyo3-asyncio-attributes", "dep:owo-colors", "dep:reqwest", "dep:rusb", "dep:log", "dep:env_logger", "dep:futures"]
|
||||
default = []
|
||||
|
||||
@@ -5,7 +5,8 @@ Rust wrappers around the [Bumble](https://github.com/google/bumble) Python API.
|
||||
Method calls are mapped to the equivalent Python, and return types adapted where
|
||||
relevant.
|
||||
|
||||
See the `examples` directory for usage.
|
||||
See the CLI in `src/main.rs` or the `examples` directory for how to use the
|
||||
Bumble API.
|
||||
|
||||
# Usage
|
||||
|
||||
@@ -27,6 +28,15 @@ PYTHONPATH=..:~/.virtualenvs/bumble/lib/python3.10/site-packages/ \
|
||||
Run the corresponding `battery_server` Python example, and launch an emulator in
|
||||
Android Studio (currently, Canary is required) to run netsim.
|
||||
|
||||
# CLI
|
||||
|
||||
Explore the available subcommands:
|
||||
|
||||
```
|
||||
PYTHONPATH=..:[virtualenv site-packages] \
|
||||
cargo run --features bumble-tools --bin bumble -- --help
|
||||
```
|
||||
|
||||
# Development
|
||||
|
||||
Run the tests:
|
||||
@@ -43,7 +53,7 @@ cargo clippy --all-targets
|
||||
|
||||
## Code gen
|
||||
|
||||
To have the fastest startup while keeping the build simple, code gen for
|
||||
To have the fastest startup while keeping the build simple, code gen for
|
||||
assigned numbers is done with the `gen_assigned_numbers` tool. It should
|
||||
be re-run whenever the Python assigned numbers are changed. To ensure that the
|
||||
generated code is kept up to date, the Rust data is compared to the Python
|
||||
@@ -52,5 +62,5 @@ in tests at `pytests/assigned_numbers.rs`.
|
||||
To regenerate the assigned number tables based on the Python codebase:
|
||||
|
||||
```
|
||||
PYTHONPATH=.. cargo run --bin gen-assigned-numbers --features bumble-dev-tools
|
||||
PYTHONPATH=.. cargo run --bin gen-assigned-numbers --features bumble-codegen
|
||||
```
|
||||
@@ -12,7 +12,7 @@
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
use bumble::wrapper::transport::Transport;
|
||||
use bumble::wrapper::{drivers::rtk::DriverInfo, transport::Transport};
|
||||
use nix::sys::stat::Mode;
|
||||
use pyo3::PyResult;
|
||||
|
||||
@@ -29,3 +29,9 @@ async fn fifo_transport_can_open() -> PyResult<()> {
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[pyo3_asyncio::tokio::test]
|
||||
async fn realtek_driver_info_all_drivers() -> PyResult<()> {
|
||||
assert_eq!(12, DriverInfo::all_drivers()?.len());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
4
rust/resources/test/firmware/realtek/README.md
Normal file
4
rust/resources/test/firmware/realtek/README.md
Normal file
@@ -0,0 +1,4 @@
|
||||
This dir contains samples firmware images in the format used for Realtek chips,
|
||||
but with repetitions of the length of the section as a little-endian 32-bit int
|
||||
for the patch data instead of actual firmware, since we only need the structure
|
||||
to test parsing.
|
||||
BIN
rust/resources/test/firmware/realtek/rtl8723b_fw_structure.bin
Normal file
BIN
rust/resources/test/firmware/realtek/rtl8723b_fw_structure.bin
Normal file
Binary file not shown.
BIN
rust/resources/test/firmware/realtek/rtl8761bu_fw_structure.bin
Normal file
BIN
rust/resources/test/firmware/realtek/rtl8761bu_fw_structure.bin
Normal file
Binary file not shown.
15
rust/src/cli/firmware/mod.rs
Normal file
15
rust/src/cli/firmware/mod.rs
Normal file
@@ -0,0 +1,15 @@
|
||||
// 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
|
||||
//
|
||||
// http://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.
|
||||
|
||||
pub(crate) mod rtk;
|
||||
265
rust/src/cli/firmware/rtk.rs
Normal file
265
rust/src/cli/firmware/rtk.rs
Normal file
@@ -0,0 +1,265 @@
|
||||
// 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
|
||||
//
|
||||
// http://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.
|
||||
|
||||
//! Realtek firmware tools
|
||||
|
||||
use crate::{Download, Source};
|
||||
use anyhow::anyhow;
|
||||
use bumble::wrapper::{
|
||||
drivers::rtk::{Driver, DriverInfo, Firmware},
|
||||
host::{DriverFactory, Host},
|
||||
transport::Transport,
|
||||
};
|
||||
use owo_colors::{colors::css, OwoColorize};
|
||||
use pyo3::PyResult;
|
||||
use std::{fs, path};
|
||||
|
||||
pub(crate) async fn download(dl: Download) -> PyResult<()> {
|
||||
let data_dir = dl
|
||||
.output_dir
|
||||
.or_else(|| {
|
||||
directories::ProjectDirs::from("com", "google", "bumble")
|
||||
.map(|pd| pd.data_local_dir().join("firmware").join("realtek"))
|
||||
})
|
||||
.unwrap_or_else(|| {
|
||||
eprintln!("Could not determine standard data directory");
|
||||
path::PathBuf::from(".")
|
||||
});
|
||||
fs::create_dir_all(&data_dir)?;
|
||||
|
||||
let (base_url, uses_bin_suffix) = match dl.source {
|
||||
Source::LinuxKernel => ("https://git.kernel.org/pub/scm/linux/kernel/git/firmware/linux-firmware.git/plain/rtl_bt", true),
|
||||
Source::RealtekOpensource => ("https://github.com/Realtek-OpenSource/android_hardware_realtek/raw/rtk1395/bt/rtkbt/Firmware/BT", false),
|
||||
Source::LinuxFromScratch => ("https://anduin.linuxfromscratch.org/sources/linux-firmware/rtl_bt", true),
|
||||
};
|
||||
|
||||
println!("Downloading");
|
||||
println!("{} {}", "FROM:".green(), base_url);
|
||||
println!("{} {}", "TO:".green(), data_dir.to_string_lossy());
|
||||
|
||||
let url_for_file = |file_name: &str| {
|
||||
let url_suffix = if uses_bin_suffix {
|
||||
file_name
|
||||
} else {
|
||||
file_name.trim_end_matches(".bin")
|
||||
};
|
||||
|
||||
let mut url = base_url.to_string();
|
||||
url.push('/');
|
||||
url.push_str(url_suffix);
|
||||
url
|
||||
};
|
||||
|
||||
let to_download = if let Some(single) = dl.single {
|
||||
vec![(
|
||||
format!("{single}_fw.bin"),
|
||||
Some(format!("{single}_config.bin")),
|
||||
false,
|
||||
)]
|
||||
} else {
|
||||
DriverInfo::all_drivers()?
|
||||
.iter()
|
||||
.map(|di| Ok((di.firmware_name()?, di.config_name()?, di.config_needed()?)))
|
||||
.collect::<PyResult<Vec<_>>>()?
|
||||
};
|
||||
|
||||
let client = SimpleClient::new();
|
||||
|
||||
for (fw_filename, config_filename, config_needed) in to_download {
|
||||
println!("{}", "---".yellow());
|
||||
let fw_path = data_dir.join(&fw_filename);
|
||||
let config_path = config_filename.as_ref().map(|f| data_dir.join(f));
|
||||
|
||||
if fw_path.exists() && !dl.overwrite {
|
||||
println!(
|
||||
"{}",
|
||||
format!("{} already exists, skipping", fw_path.to_string_lossy())
|
||||
.fg::<css::Orange>()
|
||||
);
|
||||
continue;
|
||||
}
|
||||
if let Some(cp) = config_path.as_ref() {
|
||||
if cp.exists() && !dl.overwrite {
|
||||
println!(
|
||||
"{}",
|
||||
format!("{} already exists, skipping", cp.to_string_lossy())
|
||||
.fg::<css::Orange>()
|
||||
);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
let fw_contents = match client.get(&url_for_file(&fw_filename)).await {
|
||||
Ok(data) => {
|
||||
println!("Downloaded {}: {} bytes", fw_filename, data.len());
|
||||
data
|
||||
}
|
||||
Err(e) => {
|
||||
eprintln!(
|
||||
"{} {} {:?}",
|
||||
"Failed to download".red(),
|
||||
fw_filename.red(),
|
||||
e
|
||||
);
|
||||
continue;
|
||||
}
|
||||
};
|
||||
|
||||
let config_contents = if let Some(cn) = &config_filename {
|
||||
match client.get(&url_for_file(cn)).await {
|
||||
Ok(data) => {
|
||||
println!("Downloaded {}: {} bytes", cn, data.len());
|
||||
Some(data)
|
||||
}
|
||||
Err(e) => {
|
||||
if config_needed {
|
||||
eprintln!("{} {} {:?}", "Failed to download".red(), cn.red(), e);
|
||||
continue;
|
||||
} else {
|
||||
eprintln!(
|
||||
"{}",
|
||||
format!("No config available as {cn}").fg::<css::Orange>()
|
||||
);
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
fs::write(&fw_path, &fw_contents)?;
|
||||
if !dl.no_parse && config_filename.is_some() {
|
||||
println!("{} {}", "Parsing:".cyan(), &fw_filename);
|
||||
match Firmware::parse(&fw_contents).map_err(|e| anyhow!("Parse error: {:?}", e)) {
|
||||
Ok(fw) => dump_firmware_desc(&fw),
|
||||
Err(e) => {
|
||||
eprintln!(
|
||||
"{} {:?}",
|
||||
"Could not parse firmware:".fg::<css::Orange>(),
|
||||
e
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Some((cp, cd)) = config_path
|
||||
.as_ref()
|
||||
.and_then(|p| config_contents.map(|c| (p, c)))
|
||||
{
|
||||
fs::write(cp, &cd)?;
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) fn parse(firmware_path: &path::Path) -> PyResult<()> {
|
||||
let contents = fs::read(firmware_path)?;
|
||||
let fw = Firmware::parse(&contents)
|
||||
// squish the error into a string to avoid the error type requiring that the input be
|
||||
// 'static
|
||||
.map_err(|e| anyhow!("Parse error: {:?}", e))?;
|
||||
|
||||
dump_firmware_desc(&fw);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) async fn info(transport: &str, force: bool) -> PyResult<()> {
|
||||
let transport = Transport::open(transport).await?;
|
||||
|
||||
let mut host = Host::new(transport.source()?, transport.sink()?)?;
|
||||
host.reset(DriverFactory::None).await?;
|
||||
|
||||
if !force && !Driver::check(&host).await? {
|
||||
println!("USB device not supported by this RTK driver");
|
||||
} else if let Some(driver_info) = Driver::driver_info_for_host(&host).await? {
|
||||
println!("Driver:");
|
||||
println!(" {:10} {:04X}", "ROM:", driver_info.rom()?);
|
||||
println!(" {:10} {}", "Firmware:", driver_info.firmware_name()?);
|
||||
println!(
|
||||
" {:10} {}",
|
||||
"Config:",
|
||||
driver_info.config_name()?.unwrap_or_default()
|
||||
);
|
||||
} else {
|
||||
println!("Firmware already loaded or no supported driver for this device.")
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) async fn load(transport: &str, force: bool) -> PyResult<()> {
|
||||
let transport = Transport::open(transport).await?;
|
||||
|
||||
let mut host = Host::new(transport.source()?, transport.sink()?)?;
|
||||
host.reset(DriverFactory::None).await?;
|
||||
|
||||
match Driver::for_host(&host, force).await? {
|
||||
None => {
|
||||
eprintln!("Firmware already loaded or no supported driver for this device.");
|
||||
}
|
||||
Some(mut d) => d.download_firmware().await?,
|
||||
};
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) async fn drop(transport: &str) -> PyResult<()> {
|
||||
let transport = Transport::open(transport).await?;
|
||||
|
||||
let mut host = Host::new(transport.source()?, transport.sink()?)?;
|
||||
host.reset(DriverFactory::None).await?;
|
||||
|
||||
Driver::drop_firmware(&mut host).await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn dump_firmware_desc(fw: &Firmware) {
|
||||
println!(
|
||||
"Firmware: version=0x{:08X} project_id=0x{:04X}",
|
||||
fw.version(),
|
||||
fw.project_id()
|
||||
);
|
||||
for p in fw.patches() {
|
||||
println!(
|
||||
" Patch: chip_id=0x{:04X}, {} bytes, SVN Version={:08X}",
|
||||
p.chip_id(),
|
||||
p.contents().len(),
|
||||
p.svn_version()
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
struct SimpleClient {
|
||||
client: reqwest::Client,
|
||||
}
|
||||
|
||||
impl SimpleClient {
|
||||
fn new() -> Self {
|
||||
Self {
|
||||
client: reqwest::Client::new(),
|
||||
}
|
||||
}
|
||||
|
||||
async fn get(&self, url: &str) -> anyhow::Result<Vec<u8>> {
|
||||
let resp = self.client.get(url).send().await?;
|
||||
if !resp.status().is_success() {
|
||||
return Err(anyhow!("Bad status: {}", resp.status()));
|
||||
}
|
||||
let bytes = resp.bytes().await?;
|
||||
Ok(bytes.as_ref().to_vec())
|
||||
}
|
||||
}
|
||||
191
rust/src/cli/l2cap/client_bridge.rs
Normal file
191
rust/src/cli/l2cap/client_bridge.rs
Normal file
@@ -0,0 +1,191 @@
|
||||
// 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
|
||||
//
|
||||
// http://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.
|
||||
|
||||
/// L2CAP CoC client bridge: connects to a BLE device, then waits for an inbound
|
||||
/// TCP connection on a specified port number. When a TCP client connects, an
|
||||
/// L2CAP CoC channel connection to the BLE device is established, and the data
|
||||
/// is bridged in both directions, with flow control.
|
||||
/// When the TCP connection is closed by the client, the L2CAP CoC channel is
|
||||
/// disconnected, but the connection to the BLE device remains, ready for a new
|
||||
/// TCP client to connect.
|
||||
/// When the L2CAP CoC channel is closed, the TCP connection is closed as well.
|
||||
use crate::cli::l2cap::{
|
||||
proxy_l2cap_rx_to_tcp_tx, proxy_tcp_rx_to_l2cap_tx, run_future_with_current_task_locals,
|
||||
BridgeData,
|
||||
};
|
||||
use bumble::wrapper::{
|
||||
device::{Connection, Device},
|
||||
hci::HciConstant,
|
||||
};
|
||||
use futures::executor::block_on;
|
||||
use owo_colors::OwoColorize;
|
||||
use pyo3::{PyResult, Python};
|
||||
use std::{net::SocketAddr, sync::Arc};
|
||||
use tokio::{
|
||||
join,
|
||||
net::{TcpListener, TcpStream},
|
||||
sync::{mpsc, Mutex},
|
||||
};
|
||||
|
||||
pub struct Args {
|
||||
pub psm: u16,
|
||||
pub max_credits: Option<u16>,
|
||||
pub mtu: Option<u16>,
|
||||
pub mps: Option<u16>,
|
||||
pub bluetooth_address: String,
|
||||
pub tcp_host: String,
|
||||
pub tcp_port: u16,
|
||||
}
|
||||
|
||||
pub async fn start(args: &Args, device: &mut Device) -> PyResult<()> {
|
||||
println!(
|
||||
"{}",
|
||||
format!("### Connecting to {}...", args.bluetooth_address).yellow()
|
||||
);
|
||||
let mut ble_connection = device.connect(&args.bluetooth_address).await?;
|
||||
ble_connection.on_disconnection(|_py, reason| {
|
||||
let disconnection_info = match HciConstant::error_name(reason) {
|
||||
Ok(info_string) => info_string,
|
||||
Err(py_err) => format!("failed to get disconnection error name ({})", py_err),
|
||||
};
|
||||
println!(
|
||||
"{} {}",
|
||||
"@@@ Bluetooth disconnection: ".red(),
|
||||
disconnection_info,
|
||||
);
|
||||
Ok(())
|
||||
})?;
|
||||
|
||||
// Start the TCP server.
|
||||
let listener = TcpListener::bind(format!("{}:{}", args.tcp_host, args.tcp_port))
|
||||
.await
|
||||
.expect("failed to bind tcp to address");
|
||||
println!(
|
||||
"{}",
|
||||
format!(
|
||||
"### Listening for TCP connections on port {}",
|
||||
args.tcp_port
|
||||
)
|
||||
.magenta()
|
||||
);
|
||||
|
||||
let psm = args.psm;
|
||||
let max_credits = args.max_credits;
|
||||
let mtu = args.mtu;
|
||||
let mps = args.mps;
|
||||
let ble_connection = Arc::new(Mutex::new(ble_connection));
|
||||
// Ensure Python event loop is available to l2cap `disconnect`
|
||||
let _ = run_future_with_current_task_locals(async move {
|
||||
while let Ok((tcp_stream, addr)) = listener.accept().await {
|
||||
let ble_connection = ble_connection.clone();
|
||||
let _ = run_future_with_current_task_locals(proxy_data_between_tcp_and_l2cap(
|
||||
ble_connection,
|
||||
tcp_stream,
|
||||
addr,
|
||||
psm,
|
||||
max_credits,
|
||||
mtu,
|
||||
mps,
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
});
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn proxy_data_between_tcp_and_l2cap(
|
||||
ble_connection: Arc<Mutex<Connection>>,
|
||||
tcp_stream: TcpStream,
|
||||
addr: SocketAddr,
|
||||
psm: u16,
|
||||
max_credits: Option<u16>,
|
||||
mtu: Option<u16>,
|
||||
mps: Option<u16>,
|
||||
) -> PyResult<()> {
|
||||
println!("{}", format!("<<< TCP connection from {}", addr).magenta());
|
||||
println!(
|
||||
"{}",
|
||||
format!(">>> Opening L2CAP channel on PSM = {}", psm).yellow()
|
||||
);
|
||||
|
||||
let mut l2cap_channel = match ble_connection
|
||||
.lock()
|
||||
.await
|
||||
.open_l2cap_channel(psm, max_credits, mtu, mps)
|
||||
.await
|
||||
{
|
||||
Ok(channel) => channel,
|
||||
Err(e) => {
|
||||
println!("{}", format!("!!! Connection failed: {e}").red());
|
||||
// TCP stream will get dropped after returning, automatically shutting it down.
|
||||
return Err(e);
|
||||
}
|
||||
};
|
||||
let channel_info = l2cap_channel
|
||||
.debug_string()
|
||||
.unwrap_or_else(|e| format!("failed to get l2cap channel info ({e})"));
|
||||
|
||||
println!("{}{}", "*** L2CAP channel: ".cyan(), channel_info);
|
||||
|
||||
let (l2cap_to_tcp_tx, l2cap_to_tcp_rx) = mpsc::channel::<BridgeData>(10);
|
||||
|
||||
// Set l2cap callback (`set_sink`) for when data is received.
|
||||
let l2cap_to_tcp_tx_clone = l2cap_to_tcp_tx.clone();
|
||||
l2cap_channel
|
||||
.set_sink(move |_py, sdu| {
|
||||
block_on(l2cap_to_tcp_tx_clone.send(BridgeData::Data(sdu.into())))
|
||||
.expect("failed to channel data to tcp");
|
||||
Ok(())
|
||||
})
|
||||
.expect("failed to set sink for l2cap connection");
|
||||
|
||||
// Set l2cap callback for when the channel is closed.
|
||||
l2cap_channel
|
||||
.on_close(move |_py| {
|
||||
println!("{}", "*** L2CAP channel closed".red());
|
||||
block_on(l2cap_to_tcp_tx.send(BridgeData::CloseSignal))
|
||||
.expect("failed to channel close signal to tcp");
|
||||
Ok(())
|
||||
})
|
||||
.expect("failed to set on_close callback for l2cap channel");
|
||||
|
||||
let l2cap_channel = Arc::new(Mutex::new(Some(l2cap_channel)));
|
||||
let (tcp_reader, tcp_writer) = tcp_stream.into_split();
|
||||
|
||||
// Do tcp stuff when something happens on the l2cap channel.
|
||||
let handle_l2cap_data_future =
|
||||
proxy_l2cap_rx_to_tcp_tx(l2cap_to_tcp_rx, tcp_writer, l2cap_channel.clone());
|
||||
|
||||
// Do l2cap stuff when something happens on tcp.
|
||||
let handle_tcp_data_future = proxy_tcp_rx_to_l2cap_tx(tcp_reader, l2cap_channel.clone(), true);
|
||||
|
||||
let (handle_l2cap_result, handle_tcp_result) =
|
||||
join!(handle_l2cap_data_future, handle_tcp_data_future);
|
||||
|
||||
if let Err(e) = handle_l2cap_result {
|
||||
println!("!!! Error: {e}");
|
||||
}
|
||||
|
||||
if let Err(e) = handle_tcp_result {
|
||||
println!("!!! Error: {e}");
|
||||
}
|
||||
|
||||
Python::with_gil(|_| {
|
||||
// Must hold GIL at least once while/after dropping for Python heap object to ensure
|
||||
// de-allocation.
|
||||
drop(l2cap_channel);
|
||||
});
|
||||
|
||||
Ok(())
|
||||
}
|
||||
190
rust/src/cli/l2cap/mod.rs
Normal file
190
rust/src/cli/l2cap/mod.rs
Normal file
@@ -0,0 +1,190 @@
|
||||
// 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
|
||||
//
|
||||
// http://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.
|
||||
|
||||
//! Rust version of the Python `l2cap_bridge.py` found under the `apps` folder.
|
||||
|
||||
use crate::L2cap;
|
||||
use anyhow::anyhow;
|
||||
use bumble::wrapper::{device::Device, l2cap::LeConnectionOrientedChannel, transport::Transport};
|
||||
use owo_colors::{colors::css::Orange, OwoColorize};
|
||||
use pyo3::{PyObject, PyResult, Python};
|
||||
use std::{future::Future, path::PathBuf, sync::Arc};
|
||||
use tokio::{
|
||||
io::{AsyncReadExt, AsyncWriteExt},
|
||||
net::tcp::{OwnedReadHalf, OwnedWriteHalf},
|
||||
sync::{mpsc::Receiver, Mutex},
|
||||
};
|
||||
|
||||
mod client_bridge;
|
||||
mod server_bridge;
|
||||
|
||||
pub(crate) async fn run(
|
||||
command: L2cap,
|
||||
device_config: PathBuf,
|
||||
transport: String,
|
||||
psm: u16,
|
||||
max_credits: Option<u16>,
|
||||
mtu: Option<u16>,
|
||||
mps: Option<u16>,
|
||||
) -> PyResult<()> {
|
||||
println!("<<< connecting to HCI...");
|
||||
let transport = Transport::open(transport).await?;
|
||||
println!("<<< connected");
|
||||
|
||||
let mut device =
|
||||
Device::from_config_file_with_hci(&device_config, transport.source()?, transport.sink()?)?;
|
||||
|
||||
device.power_on().await?;
|
||||
|
||||
match command {
|
||||
L2cap::Server { tcp_host, tcp_port } => {
|
||||
let args = server_bridge::Args {
|
||||
psm,
|
||||
max_credits,
|
||||
mtu,
|
||||
mps,
|
||||
tcp_host,
|
||||
tcp_port,
|
||||
};
|
||||
|
||||
server_bridge::start(&args, &mut device).await?
|
||||
}
|
||||
L2cap::Client {
|
||||
bluetooth_address,
|
||||
tcp_host,
|
||||
tcp_port,
|
||||
} => {
|
||||
let args = client_bridge::Args {
|
||||
psm,
|
||||
max_credits,
|
||||
mtu,
|
||||
mps,
|
||||
bluetooth_address,
|
||||
tcp_host,
|
||||
tcp_port,
|
||||
};
|
||||
|
||||
client_bridge::start(&args, &mut device).await?
|
||||
}
|
||||
};
|
||||
|
||||
// wait until user kills the process
|
||||
tokio::signal::ctrl_c().await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Used for channeling data from Python callbacks to a Rust consumer.
|
||||
enum BridgeData {
|
||||
Data(Vec<u8>),
|
||||
CloseSignal,
|
||||
}
|
||||
|
||||
async fn proxy_l2cap_rx_to_tcp_tx(
|
||||
mut l2cap_data_receiver: Receiver<BridgeData>,
|
||||
mut tcp_writer: OwnedWriteHalf,
|
||||
l2cap_channel: Arc<Mutex<Option<LeConnectionOrientedChannel>>>,
|
||||
) -> anyhow::Result<()> {
|
||||
while let Some(bridge_data) = l2cap_data_receiver.recv().await {
|
||||
match bridge_data {
|
||||
BridgeData::Data(sdu) => {
|
||||
println!("{}", format!("<<< [L2CAP SDU]: {} bytes", sdu.len()).cyan());
|
||||
tcp_writer
|
||||
.write_all(sdu.as_ref())
|
||||
.await
|
||||
.map_err(|_| anyhow!("Failed to write to tcp stream"))?;
|
||||
tcp_writer
|
||||
.flush()
|
||||
.await
|
||||
.map_err(|_| anyhow!("Failed to flush tcp stream"))?;
|
||||
}
|
||||
BridgeData::CloseSignal => {
|
||||
l2cap_channel.lock().await.take();
|
||||
tcp_writer
|
||||
.shutdown()
|
||||
.await
|
||||
.map_err(|_| anyhow!("Failed to shut down write half of tcp stream"))?;
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn proxy_tcp_rx_to_l2cap_tx(
|
||||
mut tcp_reader: OwnedReadHalf,
|
||||
l2cap_channel: Arc<Mutex<Option<LeConnectionOrientedChannel>>>,
|
||||
drain_l2cap_after_write: bool,
|
||||
) -> PyResult<()> {
|
||||
let mut buf = [0; 4096];
|
||||
loop {
|
||||
match tcp_reader.read(&mut buf).await {
|
||||
Ok(len) => {
|
||||
if len == 0 {
|
||||
println!("{}", "!!! End of stream".fg::<Orange>());
|
||||
|
||||
if let Some(mut channel) = l2cap_channel.lock().await.take() {
|
||||
channel.disconnect().await.map_err(|e| {
|
||||
eprintln!("Failed to call disconnect on l2cap channel: {e}");
|
||||
e
|
||||
})?;
|
||||
}
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
println!("{}", format!("<<< [TCP DATA]: {len} bytes").blue());
|
||||
match l2cap_channel.lock().await.as_mut() {
|
||||
None => {
|
||||
println!("{}", "!!! L2CAP channel not connected, dropping".red());
|
||||
return Ok(());
|
||||
}
|
||||
Some(channel) => {
|
||||
channel.write(&buf[..len])?;
|
||||
if drain_l2cap_after_write {
|
||||
channel.drain().await?;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
println!("{}", format!("!!! TCP connection lost: {}", e).red());
|
||||
if let Some(mut channel) = l2cap_channel.lock().await.take() {
|
||||
let _ = channel.disconnect().await.map_err(|e| {
|
||||
eprintln!("Failed to call disconnect on l2cap channel: {e}");
|
||||
});
|
||||
}
|
||||
return Err(e.into());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Copies the current thread's TaskLocals into a Python "awaitable" and encapsulates it in a Rust
|
||||
/// future, running it as a Python Task.
|
||||
/// `TaskLocals` stores the current event loop, and allows the user to copy the current Python
|
||||
/// context if necessary. In this case, the python event loop is used when calling `disconnect` on
|
||||
/// an l2cap connection, or else the call will fail.
|
||||
pub fn run_future_with_current_task_locals<F>(
|
||||
fut: F,
|
||||
) -> PyResult<impl Future<Output = PyResult<PyObject>> + Send>
|
||||
where
|
||||
F: Future<Output = PyResult<()>> + Send + 'static,
|
||||
{
|
||||
Python::with_gil(|py| {
|
||||
let locals = pyo3_asyncio::tokio::get_current_locals(py)?;
|
||||
let future = pyo3_asyncio::tokio::scope(locals.clone(), fut);
|
||||
pyo3_asyncio::tokio::future_into_py_with_locals(py, locals, future)
|
||||
.and_then(pyo3_asyncio::tokio::into_future)
|
||||
})
|
||||
}
|
||||
205
rust/src/cli/l2cap/server_bridge.rs
Normal file
205
rust/src/cli/l2cap/server_bridge.rs
Normal file
@@ -0,0 +1,205 @@
|
||||
// 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
|
||||
//
|
||||
// http://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.
|
||||
|
||||
/// L2CAP CoC server bridge: waits for a peer to connect an L2CAP CoC channel
|
||||
/// on a specified PSM. When the connection is made, the bridge connects a TCP
|
||||
/// socket to a remote host and bridges the data in both directions, with flow
|
||||
/// control.
|
||||
/// When the L2CAP CoC channel is closed, the bridge disconnects the TCP socket
|
||||
/// and waits for a new L2CAP CoC channel to be connected.
|
||||
/// When the TCP connection is closed by the TCP server, the L2CAP connection is closed as well.
|
||||
use crate::cli::l2cap::{
|
||||
proxy_l2cap_rx_to_tcp_tx, proxy_tcp_rx_to_l2cap_tx, run_future_with_current_task_locals,
|
||||
BridgeData,
|
||||
};
|
||||
use bumble::wrapper::{device::Device, hci::HciConstant, l2cap::LeConnectionOrientedChannel};
|
||||
use futures::executor::block_on;
|
||||
use owo_colors::OwoColorize;
|
||||
use pyo3::{PyResult, Python};
|
||||
use std::{sync::Arc, time::Duration};
|
||||
use tokio::{
|
||||
join,
|
||||
net::TcpStream,
|
||||
select,
|
||||
sync::{mpsc, Mutex},
|
||||
};
|
||||
|
||||
pub struct Args {
|
||||
pub psm: u16,
|
||||
pub max_credits: Option<u16>,
|
||||
pub mtu: Option<u16>,
|
||||
pub mps: Option<u16>,
|
||||
pub tcp_host: String,
|
||||
pub tcp_port: u16,
|
||||
}
|
||||
|
||||
pub async fn start(args: &Args, device: &mut Device) -> PyResult<()> {
|
||||
let host = args.tcp_host.clone();
|
||||
let port = args.tcp_port;
|
||||
device.register_l2cap_channel_server(
|
||||
args.psm,
|
||||
move |_py, l2cap_channel| {
|
||||
let channel_info = l2cap_channel
|
||||
.debug_string()
|
||||
.unwrap_or_else(|e| format!("failed to get l2cap channel info ({e})"));
|
||||
println!("{} {channel_info}", "*** L2CAP channel:".cyan());
|
||||
|
||||
let host = host.clone();
|
||||
// Ensure Python event loop is available to l2cap `disconnect`
|
||||
let _ = run_future_with_current_task_locals(proxy_data_between_l2cap_and_tcp(
|
||||
l2cap_channel,
|
||||
host,
|
||||
port,
|
||||
));
|
||||
Ok(())
|
||||
},
|
||||
args.max_credits,
|
||||
args.mtu,
|
||||
args.mps,
|
||||
)?;
|
||||
|
||||
println!(
|
||||
"{}",
|
||||
format!("### Listening for CoC connection on PSM {}", args.psm).yellow()
|
||||
);
|
||||
|
||||
device.on_connection(|_py, mut connection| {
|
||||
let connection_info = connection
|
||||
.debug_string()
|
||||
.unwrap_or_else(|e| format!("failed to get connection info ({e})"));
|
||||
println!(
|
||||
"{} {}",
|
||||
"@@@ Bluetooth connection: ".green(),
|
||||
connection_info,
|
||||
);
|
||||
connection.on_disconnection(|_py, reason| {
|
||||
let disconnection_info = match HciConstant::error_name(reason) {
|
||||
Ok(info_string) => info_string,
|
||||
Err(py_err) => format!("failed to get disconnection error name ({})", py_err),
|
||||
};
|
||||
println!(
|
||||
"{} {}",
|
||||
"@@@ Bluetooth disconnection: ".red(),
|
||||
disconnection_info,
|
||||
);
|
||||
Ok(())
|
||||
})?;
|
||||
Ok(())
|
||||
})?;
|
||||
|
||||
device.start_advertising(false).await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn proxy_data_between_l2cap_and_tcp(
|
||||
mut l2cap_channel: LeConnectionOrientedChannel,
|
||||
tcp_host: String,
|
||||
tcp_port: u16,
|
||||
) -> PyResult<()> {
|
||||
let (l2cap_to_tcp_tx, mut l2cap_to_tcp_rx) = mpsc::channel::<BridgeData>(10);
|
||||
|
||||
// Set callback (`set_sink`) for when l2cap data is received.
|
||||
let l2cap_to_tcp_tx_clone = l2cap_to_tcp_tx.clone();
|
||||
l2cap_channel
|
||||
.set_sink(move |_py, sdu| {
|
||||
block_on(l2cap_to_tcp_tx_clone.send(BridgeData::Data(sdu.into())))
|
||||
.expect("failed to channel data to tcp");
|
||||
Ok(())
|
||||
})
|
||||
.expect("failed to set sink for l2cap connection");
|
||||
|
||||
// Set l2cap callback for when the channel is closed.
|
||||
l2cap_channel
|
||||
.on_close(move |_py| {
|
||||
println!("{}", "*** L2CAP channel closed".red());
|
||||
block_on(l2cap_to_tcp_tx.send(BridgeData::CloseSignal))
|
||||
.expect("failed to channel close signal to tcp");
|
||||
Ok(())
|
||||
})
|
||||
.expect("failed to set on_close callback for l2cap channel");
|
||||
|
||||
println!(
|
||||
"{}",
|
||||
format!("### Connecting to TCP {tcp_host}:{tcp_port}...").yellow()
|
||||
);
|
||||
|
||||
let l2cap_channel = Arc::new(Mutex::new(Some(l2cap_channel)));
|
||||
let tcp_stream = match TcpStream::connect(format!("{tcp_host}:{tcp_port}")).await {
|
||||
Ok(stream) => {
|
||||
println!("{}", "### Connected".green());
|
||||
Some(stream)
|
||||
}
|
||||
Err(err) => {
|
||||
println!("{}", format!("!!! Connection failed: {err}").red());
|
||||
if let Some(mut channel) = l2cap_channel.lock().await.take() {
|
||||
// Bumble might enter an invalid state if disconnection request is received from
|
||||
// l2cap client before receiving a disconnection response from the same client,
|
||||
// blocking this async call from returning.
|
||||
// See: https://github.com/google/bumble/issues/257
|
||||
select! {
|
||||
res = channel.disconnect() => {
|
||||
let _ = res.map_err(|e| eprintln!("Failed to call disconnect on l2cap channel: {e}"));
|
||||
},
|
||||
_ = tokio::time::sleep(Duration::from_secs(1)) => eprintln!("Timed out while calling disconnect on l2cap channel."),
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
};
|
||||
|
||||
match tcp_stream {
|
||||
None => {
|
||||
while let Some(bridge_data) = l2cap_to_tcp_rx.recv().await {
|
||||
match bridge_data {
|
||||
BridgeData::Data(sdu) => {
|
||||
println!("{}", format!("<<< [L2CAP SDU]: {} bytes", sdu.len()).cyan());
|
||||
println!("{}", "!!! TCP socket not open, dropping".red())
|
||||
}
|
||||
BridgeData::CloseSignal => break,
|
||||
}
|
||||
}
|
||||
}
|
||||
Some(tcp_stream) => {
|
||||
let (tcp_reader, tcp_writer) = tcp_stream.into_split();
|
||||
|
||||
// Do tcp stuff when something happens on the l2cap channel.
|
||||
let handle_l2cap_data_future =
|
||||
proxy_l2cap_rx_to_tcp_tx(l2cap_to_tcp_rx, tcp_writer, l2cap_channel.clone());
|
||||
|
||||
// Do l2cap stuff when something happens on tcp.
|
||||
let handle_tcp_data_future =
|
||||
proxy_tcp_rx_to_l2cap_tx(tcp_reader, l2cap_channel.clone(), false);
|
||||
|
||||
let (handle_l2cap_result, handle_tcp_result) =
|
||||
join!(handle_l2cap_data_future, handle_tcp_data_future);
|
||||
|
||||
if let Err(e) = handle_l2cap_result {
|
||||
println!("!!! Error: {e}");
|
||||
}
|
||||
|
||||
if let Err(e) = handle_tcp_result {
|
||||
println!("!!! Error: {e}");
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
Python::with_gil(|_| {
|
||||
// Must hold GIL at least once while/after dropping for Python heap object to ensure
|
||||
// de-allocation.
|
||||
drop(l2cap_channel);
|
||||
});
|
||||
|
||||
Ok(())
|
||||
}
|
||||
19
rust/src/cli/mod.rs
Normal file
19
rust/src/cli/mod.rs
Normal file
@@ -0,0 +1,19 @@
|
||||
// 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
|
||||
//
|
||||
// http://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.
|
||||
|
||||
pub(crate) mod firmware;
|
||||
|
||||
pub(crate) mod usb;
|
||||
|
||||
pub(crate) mod l2cap;
|
||||
@@ -23,7 +23,6 @@
|
||||
//! whether it is a Bluetooth device that uses a non-standard Class, or some other
|
||||
//! type of device (there's no way to tell).
|
||||
|
||||
use clap::Parser as _;
|
||||
use itertools::Itertools as _;
|
||||
use owo_colors::{OwoColorize, Style};
|
||||
use rusb::{Device, DeviceDescriptor, Direction, TransferType, UsbContext};
|
||||
@@ -31,15 +30,12 @@ use std::{
|
||||
collections::{HashMap, HashSet},
|
||||
time::Duration,
|
||||
};
|
||||
|
||||
const USB_DEVICE_CLASS_DEVICE: u8 = 0x00;
|
||||
const USB_DEVICE_CLASS_WIRELESS_CONTROLLER: u8 = 0xE0;
|
||||
const USB_DEVICE_SUBCLASS_RF_CONTROLLER: u8 = 0x01;
|
||||
const USB_DEVICE_PROTOCOL_BLUETOOTH_PRIMARY_CONTROLLER: u8 = 0x01;
|
||||
|
||||
fn main() -> anyhow::Result<()> {
|
||||
let cli = Cli::parse();
|
||||
|
||||
pub(crate) fn probe(verbose: bool) -> anyhow::Result<()> {
|
||||
let mut bt_dev_count = 0;
|
||||
let mut device_serials_by_id: HashMap<(u16, u16), HashSet<String>> = HashMap::new();
|
||||
for device in rusb::devices()?.iter() {
|
||||
@@ -159,7 +155,7 @@ fn main() -> anyhow::Result<()> {
|
||||
println!("{:26}{}", " Product:".green(), p);
|
||||
}
|
||||
|
||||
if cli.verbose {
|
||||
if verbose {
|
||||
print_device_details(&device, &device_desc)?;
|
||||
}
|
||||
|
||||
@@ -332,11 +328,3 @@ impl From<&DeviceDescriptor> for ClassInfo {
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(clap::Parser)]
|
||||
#[command(author, version, about, long_about = None)]
|
||||
struct Cli {
|
||||
/// Show additional info for each USB device
|
||||
#[arg(long, default_value_t = false)]
|
||||
verbose: bool,
|
||||
}
|
||||
17
rust/src/internal/drivers/mod.rs
Normal file
17
rust/src/internal/drivers/mod.rs
Normal file
@@ -0,0 +1,17 @@
|
||||
// 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
|
||||
//
|
||||
// http://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.
|
||||
|
||||
//! Device drivers
|
||||
|
||||
pub(crate) mod rtk;
|
||||
253
rust/src/internal/drivers/rtk.rs
Normal file
253
rust/src/internal/drivers/rtk.rs
Normal file
@@ -0,0 +1,253 @@
|
||||
// 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
|
||||
//
|
||||
// http://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.
|
||||
|
||||
//! Drivers for Realtek controllers
|
||||
|
||||
use nom::{bytes, combinator, error, multi, number, sequence};
|
||||
|
||||
/// Realtek firmware file contents
|
||||
pub struct Firmware {
|
||||
version: u32,
|
||||
project_id: u8,
|
||||
patches: Vec<Patch>,
|
||||
}
|
||||
|
||||
impl Firmware {
|
||||
/// Parse a `*_fw.bin` file
|
||||
pub fn parse(input: &[u8]) -> Result<Self, nom::Err<error::Error<&[u8]>>> {
|
||||
let extension_sig = [0x51, 0x04, 0xFD, 0x77];
|
||||
|
||||
let (_rem, (_tag, fw_version, patch_count, payload)) =
|
||||
combinator::all_consuming(combinator::map_parser(
|
||||
// ignore the sig suffix
|
||||
sequence::terminated(
|
||||
bytes::complete::take(
|
||||
// underflow will show up as parse failure
|
||||
input.len().saturating_sub(extension_sig.len()),
|
||||
),
|
||||
bytes::complete::tag(extension_sig.as_slice()),
|
||||
),
|
||||
sequence::tuple((
|
||||
bytes::complete::tag(b"Realtech"),
|
||||
// version
|
||||
number::complete::le_u32,
|
||||
// patch count
|
||||
combinator::map(number::complete::le_u16, |c| c as usize),
|
||||
// everything else except suffix
|
||||
combinator::rest,
|
||||
)),
|
||||
))(input)?;
|
||||
|
||||
// ignore remaining input, since patch offsets are relative to the complete input
|
||||
let (_rem, (chip_ids, patch_lengths, patch_offsets)) = sequence::tuple((
|
||||
// chip id
|
||||
multi::many_m_n(patch_count, patch_count, number::complete::le_u16),
|
||||
// patch length
|
||||
multi::many_m_n(patch_count, patch_count, number::complete::le_u16),
|
||||
// patch offset
|
||||
multi::many_m_n(patch_count, patch_count, number::complete::le_u32),
|
||||
))(payload)?;
|
||||
|
||||
let patches = chip_ids
|
||||
.into_iter()
|
||||
.zip(patch_lengths.into_iter())
|
||||
.zip(patch_offsets.into_iter())
|
||||
.map(|((chip_id, patch_length), patch_offset)| {
|
||||
combinator::map(
|
||||
sequence::preceded(
|
||||
bytes::complete::take(patch_offset),
|
||||
// ignore trailing 4-byte suffix
|
||||
sequence::terminated(
|
||||
// patch including svn version, but not suffix
|
||||
combinator::consumed(sequence::preceded(
|
||||
// patch before svn version or version suffix
|
||||
// prefix length underflow will show up as parse failure
|
||||
bytes::complete::take(patch_length.saturating_sub(8)),
|
||||
// svn version
|
||||
number::complete::le_u32,
|
||||
)),
|
||||
// dummy suffix, overwritten with firmware version
|
||||
bytes::complete::take(4_usize),
|
||||
),
|
||||
),
|
||||
|(patch_contents_before_version, svn_version): (&[u8], u32)| {
|
||||
let mut contents = patch_contents_before_version.to_vec();
|
||||
// replace what would have been the trailing dummy suffix with fw version
|
||||
contents.extend_from_slice(&fw_version.to_le_bytes());
|
||||
|
||||
Patch {
|
||||
contents,
|
||||
svn_version,
|
||||
chip_id,
|
||||
}
|
||||
},
|
||||
)(input)
|
||||
.map(|(_rem, output)| output)
|
||||
})
|
||||
.collect::<Result<Vec<_>, _>>()?;
|
||||
|
||||
// look for project id from the end
|
||||
let mut offset = payload.len();
|
||||
let mut project_id: Option<u8> = None;
|
||||
while offset >= 2 {
|
||||
// Won't panic, since offset >= 2
|
||||
let chunk = &payload[offset - 2..offset];
|
||||
let length: usize = chunk[0].into();
|
||||
let opcode = chunk[1];
|
||||
offset -= 2;
|
||||
|
||||
if opcode == 0xFF {
|
||||
break;
|
||||
}
|
||||
if length == 0 {
|
||||
// report what nom likely would have done, if nom was good at parsing backwards
|
||||
return Err(nom::Err::Error(error::Error::new(
|
||||
chunk,
|
||||
error::ErrorKind::Verify,
|
||||
)));
|
||||
}
|
||||
if opcode == 0 && length == 1 {
|
||||
project_id = offset
|
||||
.checked_sub(1)
|
||||
.and_then(|index| payload.get(index))
|
||||
.copied();
|
||||
break;
|
||||
}
|
||||
|
||||
offset -= length;
|
||||
}
|
||||
|
||||
match project_id {
|
||||
Some(project_id) => Ok(Firmware {
|
||||
project_id,
|
||||
version: fw_version,
|
||||
patches,
|
||||
}),
|
||||
None => {
|
||||
// we ran out of file without finding a project id
|
||||
Err(nom::Err::Error(error::Error::new(
|
||||
payload,
|
||||
error::ErrorKind::Eof,
|
||||
)))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Patch version
|
||||
pub fn version(&self) -> u32 {
|
||||
self.version
|
||||
}
|
||||
|
||||
/// Project id
|
||||
pub fn project_id(&self) -> u8 {
|
||||
self.project_id
|
||||
}
|
||||
|
||||
/// Patches
|
||||
pub fn patches(&self) -> &[Patch] {
|
||||
&self.patches
|
||||
}
|
||||
}
|
||||
|
||||
/// Patch in a [Firmware}
|
||||
pub struct Patch {
|
||||
chip_id: u16,
|
||||
contents: Vec<u8>,
|
||||
svn_version: u32,
|
||||
}
|
||||
|
||||
impl Patch {
|
||||
/// Chip id
|
||||
pub fn chip_id(&self) -> u16 {
|
||||
self.chip_id
|
||||
}
|
||||
/// Contents of the patch, including the 4-byte firmware version suffix
|
||||
pub fn contents(&self) -> &[u8] {
|
||||
&self.contents
|
||||
}
|
||||
/// SVN version
|
||||
pub fn svn_version(&self) -> u32 {
|
||||
self.svn_version
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use anyhow::anyhow;
|
||||
use std::{fs, io, path};
|
||||
|
||||
#[test]
|
||||
fn parse_firmware_rtl8723b() -> anyhow::Result<()> {
|
||||
let fw = Firmware::parse(&firmware_contents("rtl8723b_fw_structure.bin")?)
|
||||
.map_err(|e| anyhow!("{:?}", e))?;
|
||||
|
||||
let fw_version = 0x0E2F9F73;
|
||||
assert_eq!(fw_version, fw.version());
|
||||
assert_eq!(0x0001, fw.project_id());
|
||||
assert_eq!(
|
||||
vec![(0x0001, 0x00002BBF, 22368,), (0x0002, 0x00002BBF, 22496,),],
|
||||
patch_summaries(fw, fw_version)
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_firmware_rtl8761bu() -> anyhow::Result<()> {
|
||||
let fw = Firmware::parse(&firmware_contents("rtl8761bu_fw_structure.bin")?)
|
||||
.map_err(|e| anyhow!("{:?}", e))?;
|
||||
|
||||
let fw_version = 0xDFC6D922;
|
||||
assert_eq!(fw_version, fw.version());
|
||||
assert_eq!(0x000E, fw.project_id());
|
||||
assert_eq!(
|
||||
vec![(0x0001, 0x00005060, 14048,), (0x0002, 0xD6D525A4, 30204,),],
|
||||
patch_summaries(fw, fw_version)
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn firmware_contents(filename: &str) -> io::Result<Vec<u8>> {
|
||||
fs::read(
|
||||
path::PathBuf::from(env!("CARGO_MANIFEST_DIR"))
|
||||
.join("resources/test/firmware/realtek")
|
||||
.join(filename),
|
||||
)
|
||||
}
|
||||
|
||||
/// Return a tuple of (chip id, svn version, contents len, contents sha256)
|
||||
fn patch_summaries(fw: Firmware, fw_version: u32) -> Vec<(u16, u32, usize)> {
|
||||
fw.patches()
|
||||
.iter()
|
||||
.map(|p| {
|
||||
let contents = p.contents();
|
||||
let mut dummy_contents = dummy_contents(contents.len());
|
||||
dummy_contents.extend_from_slice(&p.svn_version().to_le_bytes());
|
||||
dummy_contents.extend_from_slice(&fw_version.to_le_bytes());
|
||||
assert_eq!(&dummy_contents, contents);
|
||||
(p.chip_id(), p.svn_version(), contents.len())
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
}
|
||||
|
||||
fn dummy_contents(len: usize) -> Vec<u8> {
|
||||
let mut vec = (len as u32).to_le_bytes().as_slice().repeat(len / 4 + 1);
|
||||
assert!(vec.len() >= len);
|
||||
// leave room for svn version and firmware version
|
||||
vec.truncate(len - 8);
|
||||
vec
|
||||
}
|
||||
}
|
||||
20
rust/src/internal/mod.rs
Normal file
20
rust/src/internal/mod.rs
Normal file
@@ -0,0 +1,20 @@
|
||||
// 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
|
||||
//
|
||||
// http://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.
|
||||
|
||||
//! It's not clear where to put Rust code that isn't simply a wrapper around Python. Until we have
|
||||
//! a good answer for what to do there, the idea is to put it in this (non-public) module, and
|
||||
//! `pub use` it into the relevant areas of the `wrapper` module so that it's still easy for users
|
||||
//! to discover.
|
||||
|
||||
pub(crate) mod drivers;
|
||||
@@ -29,3 +29,5 @@
|
||||
pub mod wrapper;
|
||||
|
||||
pub mod adv;
|
||||
|
||||
pub(crate) mod internal;
|
||||
|
||||
271
rust/src/main.rs
Normal file
271
rust/src/main.rs
Normal file
@@ -0,0 +1,271 @@
|
||||
// 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
|
||||
//
|
||||
// http://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.
|
||||
|
||||
//! CLI tools for Bumble
|
||||
|
||||
#![deny(missing_docs, unsafe_code)]
|
||||
|
||||
use bumble::wrapper::logging::{bumble_env_logging_level, py_logging_basic_config};
|
||||
use clap::Parser as _;
|
||||
use pyo3::PyResult;
|
||||
use std::{fmt, path};
|
||||
|
||||
mod cli;
|
||||
|
||||
#[pyo3_asyncio::tokio::main]
|
||||
async fn main() -> PyResult<()> {
|
||||
env_logger::builder()
|
||||
.filter_level(log::LevelFilter::Info)
|
||||
.init();
|
||||
|
||||
py_logging_basic_config(bumble_env_logging_level("INFO"))?;
|
||||
|
||||
let cli: Cli = Cli::parse();
|
||||
|
||||
match cli.subcommand {
|
||||
Subcommand::Firmware { subcommand: fw } => match fw {
|
||||
Firmware::Realtek { subcommand: rtk } => match rtk {
|
||||
Realtek::Download(dl) => {
|
||||
cli::firmware::rtk::download(dl).await?;
|
||||
}
|
||||
Realtek::Drop { transport } => cli::firmware::rtk::drop(&transport).await?,
|
||||
Realtek::Info { transport, force } => {
|
||||
cli::firmware::rtk::info(&transport, force).await?;
|
||||
}
|
||||
Realtek::Load { transport, force } => {
|
||||
cli::firmware::rtk::load(&transport, force).await?
|
||||
}
|
||||
Realtek::Parse { firmware_path } => cli::firmware::rtk::parse(&firmware_path)?,
|
||||
},
|
||||
},
|
||||
Subcommand::L2cap {
|
||||
subcommand,
|
||||
device_config,
|
||||
transport,
|
||||
psm,
|
||||
l2cap_coc_max_credits,
|
||||
l2cap_coc_mtu,
|
||||
l2cap_coc_mps,
|
||||
} => {
|
||||
cli::l2cap::run(
|
||||
subcommand,
|
||||
device_config,
|
||||
transport,
|
||||
psm,
|
||||
l2cap_coc_max_credits,
|
||||
l2cap_coc_mtu,
|
||||
l2cap_coc_mps,
|
||||
)
|
||||
.await?
|
||||
}
|
||||
Subcommand::Usb { subcommand } => match subcommand {
|
||||
Usb::Probe(probe) => cli::usb::probe(probe.verbose)?,
|
||||
},
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[derive(clap::Parser)]
|
||||
struct Cli {
|
||||
#[clap(subcommand)]
|
||||
subcommand: Subcommand,
|
||||
}
|
||||
|
||||
#[derive(clap::Subcommand, Debug, Clone)]
|
||||
enum Subcommand {
|
||||
/// Manage device firmware
|
||||
Firmware {
|
||||
#[clap(subcommand)]
|
||||
subcommand: Firmware,
|
||||
},
|
||||
/// L2cap client/server operations
|
||||
L2cap {
|
||||
#[command(subcommand)]
|
||||
subcommand: L2cap,
|
||||
|
||||
/// Device configuration file.
|
||||
///
|
||||
/// See, for instance, `examples/device1.json` in the Python project.
|
||||
#[arg(long)]
|
||||
device_config: path::PathBuf,
|
||||
/// Bumble transport spec.
|
||||
///
|
||||
/// <https://google.github.io/bumble/transports/index.html>
|
||||
#[arg(long)]
|
||||
transport: String,
|
||||
|
||||
/// PSM for L2CAP Connection-oriented Channel.
|
||||
///
|
||||
/// Must be in the range [0, 65535].
|
||||
#[arg(long)]
|
||||
psm: u16,
|
||||
|
||||
/// Maximum L2CAP CoC Credits. When not specified, lets Bumble set the default.
|
||||
///
|
||||
/// Must be in the range [1, 65535].
|
||||
#[arg(long, value_parser = clap::value_parser!(u16).range(1..))]
|
||||
l2cap_coc_max_credits: Option<u16>,
|
||||
|
||||
/// L2CAP CoC MTU. When not specified, lets Bumble set the default.
|
||||
///
|
||||
/// Must be in the range [23, 65535].
|
||||
#[arg(long, value_parser = clap::value_parser!(u16).range(23..))]
|
||||
l2cap_coc_mtu: Option<u16>,
|
||||
|
||||
/// L2CAP CoC MPS. When not specified, lets Bumble set the default.
|
||||
///
|
||||
/// Must be in the range [23, 65535].
|
||||
#[arg(long, value_parser = clap::value_parser!(u16).range(23..))]
|
||||
l2cap_coc_mps: Option<u16>,
|
||||
},
|
||||
/// USB operations
|
||||
Usb {
|
||||
#[clap(subcommand)]
|
||||
subcommand: Usb,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(clap::Subcommand, Debug, Clone)]
|
||||
enum Firmware {
|
||||
/// Manage Realtek chipset firmware
|
||||
Realtek {
|
||||
#[clap(subcommand)]
|
||||
subcommand: Realtek,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(clap::Subcommand, Debug, Clone)]
|
||||
|
||||
enum Realtek {
|
||||
/// Download Realtek firmware
|
||||
Download(Download),
|
||||
/// Drop firmware from a USB device
|
||||
Drop {
|
||||
/// Bumble transport spec. Must be for a USB device.
|
||||
///
|
||||
/// <https://google.github.io/bumble/transports/index.html>
|
||||
#[arg(long)]
|
||||
transport: String,
|
||||
},
|
||||
/// Show driver info for a USB device
|
||||
Info {
|
||||
/// Bumble transport spec. Must be for a USB device.
|
||||
///
|
||||
/// <https://google.github.io/bumble/transports/index.html>
|
||||
#[arg(long)]
|
||||
transport: String,
|
||||
/// Try to resolve driver info even if USB info is not available, or if the USB
|
||||
/// (vendor,product) tuple is not in the list of known compatible RTK USB dongles.
|
||||
#[arg(long, default_value_t = false)]
|
||||
force: bool,
|
||||
},
|
||||
/// Load firmware onto a USB device
|
||||
Load {
|
||||
/// Bumble transport spec. Must be for a USB device.
|
||||
///
|
||||
/// <https://google.github.io/bumble/transports/index.html>
|
||||
#[arg(long)]
|
||||
transport: String,
|
||||
/// Load firmware even if the USB info doesn't match.
|
||||
#[arg(long, default_value_t = false)]
|
||||
force: bool,
|
||||
},
|
||||
/// Parse a firmware file
|
||||
Parse {
|
||||
/// Firmware file to parse
|
||||
firmware_path: path::PathBuf,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(clap::Args, Debug, Clone)]
|
||||
struct Download {
|
||||
/// Directory to download to. Defaults to an OS-specific path specific to the Bumble tool.
|
||||
#[arg(long)]
|
||||
output_dir: Option<path::PathBuf>,
|
||||
/// Source to download from
|
||||
#[arg(long, default_value_t = Source::LinuxKernel)]
|
||||
source: Source,
|
||||
/// Only download a single image
|
||||
#[arg(long, value_name = "base name")]
|
||||
single: Option<String>,
|
||||
/// Overwrite existing files
|
||||
#[arg(long, default_value_t = false)]
|
||||
overwrite: bool,
|
||||
/// Don't print the parse results for the downloaded file names
|
||||
#[arg(long)]
|
||||
no_parse: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, clap::ValueEnum)]
|
||||
enum Source {
|
||||
LinuxKernel,
|
||||
RealtekOpensource,
|
||||
LinuxFromScratch,
|
||||
}
|
||||
|
||||
impl fmt::Display for Source {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
match self {
|
||||
Source::LinuxKernel => write!(f, "linux-kernel"),
|
||||
Source::RealtekOpensource => write!(f, "realtek-opensource"),
|
||||
Source::LinuxFromScratch => write!(f, "linux-from-scratch"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(clap::Subcommand, Debug, Clone)]
|
||||
enum L2cap {
|
||||
/// Starts an L2CAP server
|
||||
Server {
|
||||
/// TCP host that the l2cap server will connect to.
|
||||
/// Data is bridged like so:
|
||||
/// TCP server <-> (TCP client / **L2CAP server**) <-> (L2CAP client / TCP server) <-> TCP client
|
||||
#[arg(long, default_value = "localhost")]
|
||||
tcp_host: String,
|
||||
/// TCP port that the server will connect to.
|
||||
///
|
||||
/// Must be in the range [1, 65535].
|
||||
#[arg(long, default_value_t = 9544)]
|
||||
tcp_port: u16,
|
||||
},
|
||||
/// Starts an L2CAP client
|
||||
Client {
|
||||
/// L2cap server address that this l2cap client will connect to.
|
||||
bluetooth_address: String,
|
||||
/// TCP host that the l2cap client will bind to and listen for incoming TCP connections.
|
||||
/// Data is bridged like so:
|
||||
/// TCP client <-> (TCP server / **L2CAP client**) <-> (L2CAP server / TCP client) <-> TCP server
|
||||
#[arg(long, default_value = "localhost")]
|
||||
tcp_host: String,
|
||||
/// TCP port that the client will connect to.
|
||||
///
|
||||
/// Must be in the range [1, 65535].
|
||||
#[arg(long, default_value_t = 9543)]
|
||||
tcp_port: u16,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(clap::Subcommand, Debug, Clone)]
|
||||
enum Usb {
|
||||
/// Probe the USB bus for Bluetooth devices
|
||||
Probe(Probe),
|
||||
}
|
||||
|
||||
#[derive(clap::Args, Debug, Clone)]
|
||||
struct Probe {
|
||||
/// Show additional info for each USB device
|
||||
#[arg(long, default_value_t = false)]
|
||||
verbose: bool,
|
||||
}
|
||||
@@ -19,13 +19,19 @@ use crate::{
|
||||
wrapper::{
|
||||
core::AdvertisingData,
|
||||
gatt_client::{ProfileServiceProxy, ServiceProxy},
|
||||
hci::Address,
|
||||
hci::{Address, HciErrorCode},
|
||||
host::Host,
|
||||
l2cap::LeConnectionOrientedChannel,
|
||||
transport::{Sink, Source},
|
||||
ClosureCallback,
|
||||
ClosureCallback, PyDictExt, PyObjectExt,
|
||||
},
|
||||
};
|
||||
use pyo3::types::PyDict;
|
||||
use pyo3::{intern, types::PyModule, PyObject, PyResult, Python, ToPyObject};
|
||||
use pyo3::{
|
||||
intern,
|
||||
types::{PyDict, PyModule},
|
||||
IntoPy, PyObject, PyResult, Python, ToPyObject,
|
||||
};
|
||||
use pyo3_asyncio::tokio::into_future;
|
||||
use std::path;
|
||||
|
||||
/// A device that can send/receive HCI frames.
|
||||
@@ -65,7 +71,7 @@ impl Device {
|
||||
Python::with_gil(|py| {
|
||||
self.0
|
||||
.call_method0(py, intern!(py, "power_on"))
|
||||
.and_then(|coroutine| pyo3_asyncio::tokio::into_future(coroutine.as_ref(py)))
|
||||
.and_then(|coroutine| into_future(coroutine.as_ref(py)))
|
||||
})?
|
||||
.await
|
||||
.map(|_| ())
|
||||
@@ -76,12 +82,28 @@ impl Device {
|
||||
Python::with_gil(|py| {
|
||||
self.0
|
||||
.call_method1(py, intern!(py, "connect"), (peer_addr,))
|
||||
.and_then(|coroutine| pyo3_asyncio::tokio::into_future(coroutine.as_ref(py)))
|
||||
.and_then(|coroutine| into_future(coroutine.as_ref(py)))
|
||||
})?
|
||||
.await
|
||||
.map(Connection)
|
||||
}
|
||||
|
||||
/// Register a callback to be called for each incoming connection.
|
||||
pub fn on_connection(
|
||||
&mut self,
|
||||
callback: impl Fn(Python, Connection) -> PyResult<()> + Send + 'static,
|
||||
) -> PyResult<()> {
|
||||
let boxed = ClosureCallback::new(move |py, args, _kwargs| {
|
||||
callback(py, Connection(args.get_item(0)?.into()))
|
||||
});
|
||||
|
||||
Python::with_gil(|py| {
|
||||
self.0
|
||||
.call_method1(py, intern!(py, "add_listener"), ("connection", boxed))
|
||||
})
|
||||
.map(|_| ())
|
||||
}
|
||||
|
||||
/// Start scanning
|
||||
pub async fn start_scanning(&self, filter_duplicates: bool) -> PyResult<()> {
|
||||
Python::with_gil(|py| {
|
||||
@@ -89,7 +111,7 @@ impl Device {
|
||||
kwargs.set_item("filter_duplicates", filter_duplicates)?;
|
||||
self.0
|
||||
.call_method(py, intern!(py, "start_scanning"), (), Some(kwargs))
|
||||
.and_then(|coroutine| pyo3_asyncio::tokio::into_future(coroutine.as_ref(py)))
|
||||
.and_then(|coroutine| into_future(coroutine.as_ref(py)))
|
||||
})?
|
||||
.await
|
||||
.map(|_| ())
|
||||
@@ -123,6 +145,15 @@ impl Device {
|
||||
.map(|_| ())
|
||||
}
|
||||
|
||||
/// Returns the host used by the device, if any
|
||||
pub fn host(&mut self) -> PyResult<Option<Host>> {
|
||||
Python::with_gil(|py| {
|
||||
self.0
|
||||
.getattr(py, intern!(py, "host"))
|
||||
.map(|obj| obj.into_option(Host::from))
|
||||
})
|
||||
}
|
||||
|
||||
/// Start advertising the data set with [Device.set_advertisement].
|
||||
pub async fn start_advertising(&mut self, auto_restart: bool) -> PyResult<()> {
|
||||
Python::with_gil(|py| {
|
||||
@@ -131,7 +162,7 @@ impl Device {
|
||||
|
||||
self.0
|
||||
.call_method(py, intern!(py, "start_advertising"), (), Some(kwargs))
|
||||
.and_then(|coroutine| pyo3_asyncio::tokio::into_future(coroutine.as_ref(py)))
|
||||
.and_then(|coroutine| into_future(coroutine.as_ref(py)))
|
||||
})?
|
||||
.await
|
||||
.map(|_| ())
|
||||
@@ -142,16 +173,114 @@ impl Device {
|
||||
Python::with_gil(|py| {
|
||||
self.0
|
||||
.call_method0(py, intern!(py, "stop_advertising"))
|
||||
.and_then(|coroutine| pyo3_asyncio::tokio::into_future(coroutine.as_ref(py)))
|
||||
.and_then(|coroutine| into_future(coroutine.as_ref(py)))
|
||||
})?
|
||||
.await
|
||||
.map(|_| ())
|
||||
}
|
||||
|
||||
/// Registers an L2CAP connection oriented channel server. When a client connects to the server,
|
||||
/// the `server` callback is passed a handle to the established channel. When optional arguments
|
||||
/// are not specified, the Python module specifies the defaults.
|
||||
pub fn register_l2cap_channel_server(
|
||||
&mut self,
|
||||
psm: u16,
|
||||
server: impl Fn(Python, LeConnectionOrientedChannel) -> PyResult<()> + Send + 'static,
|
||||
max_credits: Option<u16>,
|
||||
mtu: Option<u16>,
|
||||
mps: Option<u16>,
|
||||
) -> PyResult<()> {
|
||||
Python::with_gil(|py| {
|
||||
let boxed = ClosureCallback::new(move |py, args, _kwargs| {
|
||||
server(
|
||||
py,
|
||||
LeConnectionOrientedChannel::from(args.get_item(0)?.into()),
|
||||
)
|
||||
});
|
||||
|
||||
let kwargs = PyDict::new(py);
|
||||
kwargs.set_item("psm", psm)?;
|
||||
kwargs.set_item("server", boxed.into_py(py))?;
|
||||
kwargs.set_opt_item("max_credits", max_credits)?;
|
||||
kwargs.set_opt_item("mtu", mtu)?;
|
||||
kwargs.set_opt_item("mps", mps)?;
|
||||
self.0.call_method(
|
||||
py,
|
||||
intern!(py, "register_l2cap_channel_server"),
|
||||
(),
|
||||
Some(kwargs),
|
||||
)
|
||||
})?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// A connection to a remote device.
|
||||
pub struct Connection(PyObject);
|
||||
|
||||
impl Connection {
|
||||
/// Open an L2CAP channel using this connection. When optional arguments are not specified, the
|
||||
/// Python module specifies the defaults.
|
||||
pub async fn open_l2cap_channel(
|
||||
&mut self,
|
||||
psm: u16,
|
||||
max_credits: Option<u16>,
|
||||
mtu: Option<u16>,
|
||||
mps: Option<u16>,
|
||||
) -> PyResult<LeConnectionOrientedChannel> {
|
||||
Python::with_gil(|py| {
|
||||
let kwargs = PyDict::new(py);
|
||||
kwargs.set_item("psm", psm)?;
|
||||
kwargs.set_opt_item("max_credits", max_credits)?;
|
||||
kwargs.set_opt_item("mtu", mtu)?;
|
||||
kwargs.set_opt_item("mps", mps)?;
|
||||
self.0
|
||||
.call_method(py, intern!(py, "open_l2cap_channel"), (), Some(kwargs))
|
||||
.and_then(|coroutine| pyo3_asyncio::tokio::into_future(coroutine.as_ref(py)))
|
||||
})?
|
||||
.await
|
||||
.map(LeConnectionOrientedChannel::from)
|
||||
}
|
||||
|
||||
/// Disconnect from device with provided reason. When optional arguments are not specified, the
|
||||
/// Python module specifies the defaults.
|
||||
pub async fn disconnect(&mut self, reason: Option<HciErrorCode>) -> PyResult<()> {
|
||||
Python::with_gil(|py| {
|
||||
let kwargs = PyDict::new(py);
|
||||
kwargs.set_opt_item("reason", reason)?;
|
||||
self.0
|
||||
.call_method(py, intern!(py, "disconnect"), (), Some(kwargs))
|
||||
.and_then(|coroutine| pyo3_asyncio::tokio::into_future(coroutine.as_ref(py)))
|
||||
})?
|
||||
.await
|
||||
.map(|_| ())
|
||||
}
|
||||
|
||||
/// Register a callback to be called on disconnection.
|
||||
pub fn on_disconnection(
|
||||
&mut self,
|
||||
callback: impl Fn(Python, HciErrorCode) -> PyResult<()> + Send + 'static,
|
||||
) -> PyResult<()> {
|
||||
let boxed = ClosureCallback::new(move |py, args, _kwargs| {
|
||||
callback(py, args.get_item(0)?.extract()?)
|
||||
});
|
||||
|
||||
Python::with_gil(|py| {
|
||||
self.0
|
||||
.call_method1(py, intern!(py, "add_listener"), ("disconnection", boxed))
|
||||
})
|
||||
.map(|_| ())
|
||||
}
|
||||
|
||||
/// Returns some information about the connection as a [String].
|
||||
pub fn debug_string(&self) -> PyResult<String> {
|
||||
Python::with_gil(|py| {
|
||||
let str_obj = self.0.call_method0(py, intern!(py, "__str__"))?;
|
||||
str_obj.gil_ref(py).extract()
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// The other end of a connection
|
||||
pub struct Peer(PyObject);
|
||||
|
||||
@@ -173,7 +302,7 @@ impl Peer {
|
||||
Python::with_gil(|py| {
|
||||
self.0
|
||||
.call_method0(py, intern!(py, "discover_services"))
|
||||
.and_then(|coroutine| pyo3_asyncio::tokio::into_future(coroutine.as_ref(py)))
|
||||
.and_then(|coroutine| into_future(coroutine.as_ref(py)))
|
||||
})?
|
||||
.await
|
||||
.and_then(|list| {
|
||||
@@ -207,13 +336,7 @@ impl Peer {
|
||||
let class = module.getattr(P::PROXY_CLASS_NAME)?;
|
||||
self.0
|
||||
.call_method1(py, intern!(py, "create_service_proxy"), (class,))
|
||||
.map(|obj| {
|
||||
if obj.is_none(py) {
|
||||
None
|
||||
} else {
|
||||
Some(P::wrap(obj))
|
||||
}
|
||||
})
|
||||
.map(|obj| obj.into_option(P::wrap))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
17
rust/src/wrapper/drivers/mod.rs
Normal file
17
rust/src/wrapper/drivers/mod.rs
Normal file
@@ -0,0 +1,17 @@
|
||||
// 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
|
||||
//
|
||||
// http://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.
|
||||
|
||||
//! Device drivers
|
||||
|
||||
pub mod rtk;
|
||||
141
rust/src/wrapper/drivers/rtk.rs
Normal file
141
rust/src/wrapper/drivers/rtk.rs
Normal file
@@ -0,0 +1,141 @@
|
||||
// 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
|
||||
//
|
||||
// http://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.
|
||||
|
||||
//! Drivers for Realtek controllers
|
||||
|
||||
use crate::wrapper::{host::Host, PyObjectExt};
|
||||
use pyo3::{intern, types::PyModule, PyObject, PyResult, Python, ToPyObject};
|
||||
use pyo3_asyncio::tokio::into_future;
|
||||
|
||||
pub use crate::internal::drivers::rtk::{Firmware, Patch};
|
||||
|
||||
/// Driver for a Realtek controller
|
||||
pub struct Driver(PyObject);
|
||||
|
||||
impl Driver {
|
||||
/// Locate the driver for the provided host.
|
||||
pub async fn for_host(host: &Host, force: bool) -> PyResult<Option<Self>> {
|
||||
Python::with_gil(|py| {
|
||||
PyModule::import(py, intern!(py, "bumble.drivers.rtk"))?
|
||||
.getattr(intern!(py, "Driver"))?
|
||||
.call_method1(intern!(py, "for_host"), (&host.obj, force))
|
||||
.and_then(into_future)
|
||||
})?
|
||||
.await
|
||||
.map(|obj| obj.into_option(Self))
|
||||
}
|
||||
|
||||
/// Check if the host has a known driver.
|
||||
pub async fn check(host: &Host) -> PyResult<bool> {
|
||||
Python::with_gil(|py| {
|
||||
PyModule::import(py, intern!(py, "bumble.drivers.rtk"))?
|
||||
.getattr(intern!(py, "Driver"))?
|
||||
.call_method1(intern!(py, "check"), (&host.obj,))
|
||||
.and_then(|obj| obj.extract::<bool>())
|
||||
})
|
||||
}
|
||||
|
||||
/// Find the [DriverInfo] for the host, if one matches
|
||||
pub async fn driver_info_for_host(host: &Host) -> PyResult<Option<DriverInfo>> {
|
||||
Python::with_gil(|py| {
|
||||
PyModule::import(py, intern!(py, "bumble.drivers.rtk"))?
|
||||
.getattr(intern!(py, "Driver"))?
|
||||
.call_method1(intern!(py, "driver_info_for_host"), (&host.obj,))
|
||||
.and_then(into_future)
|
||||
})?
|
||||
.await
|
||||
.map(|obj| obj.into_option(DriverInfo))
|
||||
}
|
||||
|
||||
/// Send a command to the device to drop firmware
|
||||
pub async fn drop_firmware(host: &mut Host) -> PyResult<()> {
|
||||
Python::with_gil(|py| {
|
||||
PyModule::import(py, intern!(py, "bumble.drivers.rtk"))?
|
||||
.getattr(intern!(py, "Driver"))?
|
||||
.call_method1(intern!(py, "drop_firmware"), (&host.obj,))
|
||||
.and_then(into_future)
|
||||
})?
|
||||
.await
|
||||
.map(|_| ())
|
||||
}
|
||||
|
||||
/// Load firmware onto the device.
|
||||
pub async fn download_firmware(&mut self) -> PyResult<()> {
|
||||
Python::with_gil(|py| {
|
||||
self.0
|
||||
.call_method0(py, intern!(py, "download_firmware"))
|
||||
.and_then(|coroutine| into_future(coroutine.as_ref(py)))
|
||||
})?
|
||||
.await
|
||||
.map(|_| ())
|
||||
}
|
||||
}
|
||||
|
||||
/// Metadata about a known driver & applicable device
|
||||
pub struct DriverInfo(PyObject);
|
||||
|
||||
impl DriverInfo {
|
||||
/// Returns a list of all drivers that Bumble knows how to handle.
|
||||
pub fn all_drivers() -> PyResult<Vec<DriverInfo>> {
|
||||
Python::with_gil(|py| {
|
||||
PyModule::import(py, intern!(py, "bumble.drivers.rtk"))?
|
||||
.getattr(intern!(py, "Driver"))?
|
||||
.getattr(intern!(py, "DRIVER_INFOS"))?
|
||||
.iter()?
|
||||
.map(|r| r.map(|h| DriverInfo(h.to_object(py))))
|
||||
.collect::<PyResult<Vec<_>>>()
|
||||
})
|
||||
}
|
||||
|
||||
/// The firmware file name to load from the filesystem, e.g. `foo_fw.bin`.
|
||||
pub fn firmware_name(&self) -> PyResult<String> {
|
||||
Python::with_gil(|py| {
|
||||
self.0
|
||||
.getattr(py, intern!(py, "fw_name"))?
|
||||
.as_ref(py)
|
||||
.extract::<String>()
|
||||
})
|
||||
}
|
||||
|
||||
/// The config file name, if any, to load from the filesystem, e.g. `foo_config.bin`.
|
||||
pub fn config_name(&self) -> PyResult<Option<String>> {
|
||||
Python::with_gil(|py| {
|
||||
let obj = self.0.getattr(py, intern!(py, "config_name"))?;
|
||||
let handle = obj.as_ref(py);
|
||||
|
||||
if handle.is_none() {
|
||||
Ok(None)
|
||||
} else {
|
||||
handle
|
||||
.extract::<String>()
|
||||
.map(|s| if s.is_empty() { None } else { Some(s) })
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/// Whether or not config is required.
|
||||
pub fn config_needed(&self) -> PyResult<bool> {
|
||||
Python::with_gil(|py| {
|
||||
self.0
|
||||
.getattr(py, intern!(py, "config_needed"))?
|
||||
.as_ref(py)
|
||||
.extract::<bool>()
|
||||
})
|
||||
}
|
||||
|
||||
/// ROM id
|
||||
pub fn rom(&self) -> PyResult<u32> {
|
||||
Python::with_gil(|py| self.0.getattr(py, intern!(py, "rom"))?.as_ref(py).extract())
|
||||
}
|
||||
}
|
||||
@@ -15,7 +15,40 @@
|
||||
//! HCI
|
||||
|
||||
use itertools::Itertools as _;
|
||||
use pyo3::{exceptions::PyException, intern, types::PyModule, PyErr, PyObject, PyResult, Python};
|
||||
use pyo3::{
|
||||
exceptions::PyException, intern, types::PyModule, FromPyObject, PyAny, PyErr, PyObject,
|
||||
PyResult, Python, ToPyObject,
|
||||
};
|
||||
|
||||
/// HCI error code.
|
||||
pub struct HciErrorCode(u8);
|
||||
|
||||
impl<'source> FromPyObject<'source> for HciErrorCode {
|
||||
fn extract(ob: &'source PyAny) -> PyResult<Self> {
|
||||
Ok(HciErrorCode(ob.extract()?))
|
||||
}
|
||||
}
|
||||
|
||||
impl ToPyObject for HciErrorCode {
|
||||
fn to_object(&self, py: Python<'_>) -> PyObject {
|
||||
self.0.to_object(py)
|
||||
}
|
||||
}
|
||||
|
||||
/// Provides helpers for interacting with HCI
|
||||
pub struct HciConstant;
|
||||
|
||||
impl HciConstant {
|
||||
/// Human-readable error name
|
||||
pub fn error_name(status: HciErrorCode) -> PyResult<String> {
|
||||
Python::with_gil(|py| {
|
||||
PyModule::import(py, intern!(py, "bumble.hci"))?
|
||||
.getattr(intern!(py, "HCI_Constant"))?
|
||||
.call_method1(intern!(py, "error_name"), (status.0,))?
|
||||
.extract()
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// A Bluetooth address
|
||||
pub struct Address(pub(crate) PyObject);
|
||||
|
||||
71
rust/src/wrapper/host.rs
Normal file
71
rust/src/wrapper/host.rs
Normal file
@@ -0,0 +1,71 @@
|
||||
// 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
|
||||
//
|
||||
// http://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.
|
||||
|
||||
//! Host-side types
|
||||
|
||||
use crate::wrapper::transport::{Sink, Source};
|
||||
use pyo3::{intern, prelude::PyModule, types::PyDict, PyObject, PyResult, Python};
|
||||
|
||||
/// Host HCI commands
|
||||
pub struct Host {
|
||||
pub(crate) obj: PyObject,
|
||||
}
|
||||
|
||||
impl Host {
|
||||
/// Create a Host that wraps the provided obj
|
||||
pub(crate) fn from(obj: PyObject) -> Self {
|
||||
Self { obj }
|
||||
}
|
||||
|
||||
/// Create a new Host
|
||||
pub fn new(source: Source, sink: Sink) -> PyResult<Self> {
|
||||
Python::with_gil(|py| {
|
||||
PyModule::import(py, intern!(py, "bumble.host"))?
|
||||
.getattr(intern!(py, "Host"))?
|
||||
.call((source.0, sink.0), None)
|
||||
.map(|any| Self { obj: any.into() })
|
||||
})
|
||||
}
|
||||
|
||||
/// Send a reset command and perform other reset tasks.
|
||||
pub async fn reset(&mut self, driver_factory: DriverFactory) -> PyResult<()> {
|
||||
Python::with_gil(|py| {
|
||||
let kwargs = match driver_factory {
|
||||
DriverFactory::None => {
|
||||
let kw = PyDict::new(py);
|
||||
kw.set_item("driver_factory", py.None())?;
|
||||
Some(kw)
|
||||
}
|
||||
DriverFactory::Auto => {
|
||||
// leave the default in place
|
||||
None
|
||||
}
|
||||
};
|
||||
self.obj
|
||||
.call_method(py, intern!(py, "reset"), (), kwargs)
|
||||
.and_then(|coroutine| pyo3_asyncio::tokio::into_future(coroutine.as_ref(py)))
|
||||
})?
|
||||
.await
|
||||
.map(|_| ())
|
||||
}
|
||||
}
|
||||
|
||||
/// Driver factory to use when initializing a host
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum DriverFactory {
|
||||
/// Do not load drivers
|
||||
None,
|
||||
/// Load appropriate driver, if any is found
|
||||
Auto,
|
||||
}
|
||||
92
rust/src/wrapper/l2cap.rs
Normal file
92
rust/src/wrapper/l2cap.rs
Normal file
@@ -0,0 +1,92 @@
|
||||
// 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
|
||||
//
|
||||
// http://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.
|
||||
|
||||
//! L2CAP
|
||||
|
||||
use crate::wrapper::{ClosureCallback, PyObjectExt};
|
||||
use pyo3::{intern, PyObject, PyResult, Python};
|
||||
|
||||
/// L2CAP connection-oriented channel
|
||||
pub struct LeConnectionOrientedChannel(PyObject);
|
||||
|
||||
impl LeConnectionOrientedChannel {
|
||||
/// Create a LeConnectionOrientedChannel that wraps the provided obj.
|
||||
pub(crate) fn from(obj: PyObject) -> Self {
|
||||
Self(obj)
|
||||
}
|
||||
|
||||
/// Queues data to be automatically sent across this channel.
|
||||
pub fn write(&mut self, data: &[u8]) -> PyResult<()> {
|
||||
Python::with_gil(|py| self.0.call_method1(py, intern!(py, "write"), (data,))).map(|_| ())
|
||||
}
|
||||
|
||||
/// Wait for queued data to be sent on this channel.
|
||||
pub async fn drain(&mut self) -> PyResult<()> {
|
||||
Python::with_gil(|py| {
|
||||
self.0
|
||||
.call_method0(py, intern!(py, "drain"))
|
||||
.and_then(|coroutine| pyo3_asyncio::tokio::into_future(coroutine.as_ref(py)))
|
||||
})?
|
||||
.await
|
||||
.map(|_| ())
|
||||
}
|
||||
|
||||
/// Register a callback to be called when the channel is closed.
|
||||
pub fn on_close(
|
||||
&mut self,
|
||||
callback: impl Fn(Python) -> PyResult<()> + Send + 'static,
|
||||
) -> PyResult<()> {
|
||||
let boxed = ClosureCallback::new(move |py, _args, _kwargs| callback(py));
|
||||
|
||||
Python::with_gil(|py| {
|
||||
self.0
|
||||
.call_method1(py, intern!(py, "add_listener"), ("close", boxed))
|
||||
})
|
||||
.map(|_| ())
|
||||
}
|
||||
|
||||
/// Register a callback to be called when the channel receives data.
|
||||
pub fn set_sink(
|
||||
&mut self,
|
||||
callback: impl Fn(Python, &[u8]) -> PyResult<()> + Send + 'static,
|
||||
) -> PyResult<()> {
|
||||
let boxed = ClosureCallback::new(move |py, args, _kwargs| {
|
||||
callback(py, args.get_item(0)?.extract()?)
|
||||
});
|
||||
Python::with_gil(|py| self.0.setattr(py, intern!(py, "sink"), boxed)).map(|_| ())
|
||||
}
|
||||
|
||||
/// Disconnect the l2cap channel.
|
||||
/// Must be called from a thread with a Python event loop, which should be true on
|
||||
/// `tokio::main` and `async_std::main`.
|
||||
///
|
||||
/// For more info, see https://awestlake87.github.io/pyo3-asyncio/master/doc/pyo3_asyncio/#event-loop-references-and-contextvars.
|
||||
pub async fn disconnect(&mut self) -> PyResult<()> {
|
||||
Python::with_gil(|py| {
|
||||
self.0
|
||||
.call_method0(py, intern!(py, "disconnect"))
|
||||
.and_then(|coroutine| pyo3_asyncio::tokio::into_future(coroutine.as_ref(py)))
|
||||
})?
|
||||
.await
|
||||
.map(|_| ())
|
||||
}
|
||||
|
||||
/// Returns some information about the channel as a [String].
|
||||
pub fn debug_string(&self) -> PyResult<String> {
|
||||
Python::with_gil(|py| {
|
||||
let str_obj = self.0.call_method0(py, intern!(py, "__str__"))?;
|
||||
str_obj.gil_ref(py).extract()
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -31,14 +31,17 @@ pub use pyo3_asyncio;
|
||||
pub mod assigned_numbers;
|
||||
pub mod core;
|
||||
pub mod device;
|
||||
pub mod drivers;
|
||||
pub mod gatt_client;
|
||||
pub mod hci;
|
||||
pub mod host;
|
||||
pub mod l2cap;
|
||||
pub mod logging;
|
||||
pub mod profile;
|
||||
pub mod transport;
|
||||
|
||||
/// Convenience extensions to [PyObject]
|
||||
pub trait PyObjectExt {
|
||||
pub trait PyObjectExt: Sized {
|
||||
/// Get a GIL-bound reference
|
||||
fn gil_ref<'py>(&'py self, py: Python<'py>) -> &'py PyAny;
|
||||
|
||||
@@ -49,6 +52,17 @@ pub trait PyObjectExt {
|
||||
{
|
||||
Python::with_gil(|py| self.gil_ref(py).extract::<T>())
|
||||
}
|
||||
|
||||
/// If the Python object is a Python `None`, return a Rust `None`, otherwise `Some` with the mapped type
|
||||
fn into_option<T>(self, map_obj: impl Fn(Self) -> T) -> Option<T> {
|
||||
Python::with_gil(|py| {
|
||||
if self.gil_ref(py).is_none() {
|
||||
None
|
||||
} else {
|
||||
Some(map_obj(self))
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl PyObjectExt for PyObject {
|
||||
@@ -57,6 +71,21 @@ impl PyObjectExt for PyObject {
|
||||
}
|
||||
}
|
||||
|
||||
/// Convenience extensions to [PyDict]
|
||||
pub trait PyDictExt {
|
||||
/// Set item in dict only if value is Some, otherwise do nothing.
|
||||
fn set_opt_item<K: ToPyObject, V: ToPyObject>(&self, key: K, value: Option<V>) -> PyResult<()>;
|
||||
}
|
||||
|
||||
impl PyDictExt for PyDict {
|
||||
fn set_opt_item<K: ToPyObject, V: ToPyObject>(&self, key: K, value: Option<V>) -> PyResult<()> {
|
||||
if let Some(value) = value {
|
||||
self.set_item(key, value)?
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// Wrapper to make Rust closures ([Fn] implementations) callable from Python.
|
||||
///
|
||||
/// The Python callable form returns a Python `None`.
|
||||
|
||||
@@ -14,7 +14,10 @@
|
||||
|
||||
//! GATT profiles
|
||||
|
||||
use crate::wrapper::gatt_client::{CharacteristicProxy, ProfileServiceProxy};
|
||||
use crate::wrapper::{
|
||||
gatt_client::{CharacteristicProxy, ProfileServiceProxy},
|
||||
PyObjectExt,
|
||||
};
|
||||
use pyo3::{intern, PyObject, PyResult, Python};
|
||||
|
||||
/// Exposes the battery GATT service
|
||||
@@ -26,13 +29,7 @@ impl BatteryServiceProxy {
|
||||
Python::with_gil(|py| {
|
||||
self.0
|
||||
.getattr(py, intern!(py, "battery_level"))
|
||||
.map(|level| {
|
||||
if level.is_none(py) {
|
||||
None
|
||||
} else {
|
||||
Some(CharacteristicProxy(level))
|
||||
}
|
||||
})
|
||||
.map(|level| level.into_option(CharacteristicProxy))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
17
setup.cfg
17
setup.cfg
@@ -32,17 +32,18 @@ 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'
|
||||
grpcio == 1.51.1; platform_system!='Emscripten'
|
||||
humanize >= 4.6.0
|
||||
cryptography == 39; platform_system!='Emscripten'
|
||||
grpcio == 1.57.0; platform_system!='Emscripten'
|
||||
humanize >= 4.6.0; platform_system!='Emscripten'
|
||||
libusb1 >= 2.0.1; platform_system!='Emscripten'
|
||||
libusb-package == 1.0.26.1; platform_system!='Emscripten'
|
||||
platformdirs == 3.10.0; 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'
|
||||
@@ -81,7 +82,7 @@ test =
|
||||
coverage >= 6.4
|
||||
development =
|
||||
black == 22.10
|
||||
grpcio-tools >= 1.51.1
|
||||
grpcio-tools >= 1.57.0
|
||||
invoke >= 1.7.3
|
||||
mypy == 1.2.0
|
||||
nox >= 2022
|
||||
|
||||
28
speaker.html
28
speaker.html
@@ -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>
|
||||
30
tasks.py
30
tasks.py
@@ -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)
|
||||
|
||||
13
tests/__init__.py
Normal file
13
tests/__init__.py
Normal file
@@ -0,0 +1,13 @@
|
||||
# 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.
|
||||
35
tests/at_test.py
Normal file
35
tests/at_test.py
Normal file
@@ -0,0 +1,35 @@
|
||||
# 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()
|
||||
@@ -21,13 +21,9 @@ import os
|
||||
import random
|
||||
import pytest
|
||||
|
||||
from bumble.controller import Controller
|
||||
from bumble.link import LocalLink
|
||||
from bumble.device import Device
|
||||
from bumble.host import Host
|
||||
from bumble.transport import AsyncPipeSink
|
||||
from bumble.core import ProtocolError
|
||||
from bumble.l2cap import L2CAP_Connection_Request
|
||||
from .test_utils import TwoDevices
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
@@ -37,60 +33,6 @@ logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
class TwoDevices:
|
||||
def __init__(self):
|
||||
self.connections = [None, None]
|
||||
|
||||
self.link = LocalLink()
|
||||
self.controllers = [
|
||||
Controller('C1', link=self.link),
|
||||
Controller('C2', link=self.link),
|
||||
]
|
||||
self.devices = [
|
||||
Device(
|
||||
address='F0:F1:F2:F3:F4:F5',
|
||||
host=Host(self.controllers[0], AsyncPipeSink(self.controllers[0])),
|
||||
),
|
||||
Device(
|
||||
address='F5:F4:F3:F2:F1:F0',
|
||||
host=Host(self.controllers[1], AsyncPipeSink(self.controllers[1])),
|
||||
),
|
||||
]
|
||||
|
||||
self.paired = [None, None]
|
||||
|
||||
def on_connection(self, which, connection):
|
||||
self.connections[which] = connection
|
||||
|
||||
def on_paired(self, which, keys):
|
||||
self.paired[which] = keys
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
async def setup_connection():
|
||||
# Create two devices, each with a controller, attached to the same link
|
||||
two_devices = TwoDevices()
|
||||
|
||||
# Attach listeners
|
||||
two_devices.devices[0].on(
|
||||
'connection', lambda connection: two_devices.on_connection(0, connection)
|
||||
)
|
||||
two_devices.devices[1].on(
|
||||
'connection', lambda connection: two_devices.on_connection(1, connection)
|
||||
)
|
||||
|
||||
# Start
|
||||
await two_devices.devices[0].power_on()
|
||||
await two_devices.devices[1].power_on()
|
||||
|
||||
# Connect the two devices
|
||||
await two_devices.devices[0].connect(two_devices.devices[1].random_address)
|
||||
|
||||
# Check the post conditions
|
||||
assert two_devices.connections[0] is not None
|
||||
assert two_devices.connections[1] is not None
|
||||
|
||||
return two_devices
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
@@ -132,7 +74,8 @@ def test_helpers():
|
||||
# -----------------------------------------------------------------------------
|
||||
@pytest.mark.asyncio
|
||||
async def test_basic_connection():
|
||||
devices = await setup_connection()
|
||||
devices = TwoDevices()
|
||||
await devices.setup_connection()
|
||||
psm = 1234
|
||||
|
||||
# Check that if there's no one listening, we can't connect
|
||||
@@ -184,7 +127,8 @@ async def test_basic_connection():
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
async def transfer_payload(max_credits, mtu, mps):
|
||||
devices = await setup_connection()
|
||||
devices = TwoDevices()
|
||||
await devices.setup_connection()
|
||||
|
||||
received = []
|
||||
|
||||
@@ -226,7 +170,8 @@ async def test_transfer():
|
||||
# -----------------------------------------------------------------------------
|
||||
@pytest.mark.asyncio
|
||||
async def test_bidirectional_transfer():
|
||||
devices = await setup_connection()
|
||||
devices = TwoDevices()
|
||||
await devices.setup_connection()
|
||||
|
||||
client_received = []
|
||||
server_received = []
|
||||
|
||||
@@ -15,15 +15,30 @@
|
||||
# -----------------------------------------------------------------------------
|
||||
# Imports
|
||||
# -----------------------------------------------------------------------------
|
||||
from bumble.core import UUID
|
||||
from bumble.sdp import DataElement
|
||||
import asyncio
|
||||
import logging
|
||||
import os
|
||||
|
||||
from bumble.core import UUID, BT_L2CAP_PROTOCOL_ID, BT_RFCOMM_PROTOCOL_ID
|
||||
from bumble.sdp import (
|
||||
DataElement,
|
||||
ServiceAttribute,
|
||||
Client,
|
||||
Server,
|
||||
SDP_SERVICE_RECORD_HANDLE_ATTRIBUTE_ID,
|
||||
SDP_BROWSE_GROUP_LIST_ATTRIBUTE_ID,
|
||||
SDP_PUBLIC_BROWSE_ROOT,
|
||||
SDP_SERVICE_CLASS_ID_LIST_ATTRIBUTE_ID,
|
||||
SDP_PROTOCOL_DESCRIPTOR_LIST_ATTRIBUTE_ID,
|
||||
)
|
||||
from .test_utils import TwoDevices
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# pylint: disable=invalid-name
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
def basic_check(x):
|
||||
def basic_check(x: DataElement) -> None:
|
||||
serialized = bytes(x)
|
||||
if len(serialized) < 500:
|
||||
print('Original:', x)
|
||||
@@ -41,7 +56,7 @@ def basic_check(x):
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
def test_data_elements():
|
||||
def test_data_elements() -> None:
|
||||
e = DataElement(DataElement.NIL, None)
|
||||
basic_check(e)
|
||||
|
||||
@@ -157,5 +172,108 @@ def test_data_elements():
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
if __name__ == '__main__':
|
||||
def sdp_records():
|
||||
return {
|
||||
0x00010001: [
|
||||
ServiceAttribute(
|
||||
SDP_SERVICE_RECORD_HANDLE_ATTRIBUTE_ID,
|
||||
DataElement.unsigned_integer_32(0x00010001),
|
||||
),
|
||||
ServiceAttribute(
|
||||
SDP_BROWSE_GROUP_LIST_ATTRIBUTE_ID,
|
||||
DataElement.sequence([DataElement.uuid(SDP_PUBLIC_BROWSE_ROOT)]),
|
||||
),
|
||||
ServiceAttribute(
|
||||
SDP_SERVICE_CLASS_ID_LIST_ATTRIBUTE_ID,
|
||||
DataElement.sequence(
|
||||
[DataElement.uuid(UUID('E6D55659-C8B4-4B85-96BB-B1143AF6D3AE'))]
|
||||
),
|
||||
),
|
||||
ServiceAttribute(
|
||||
SDP_PROTOCOL_DESCRIPTOR_LIST_ATTRIBUTE_ID,
|
||||
DataElement.sequence(
|
||||
[
|
||||
DataElement.sequence([DataElement.uuid(BT_L2CAP_PROTOCOL_ID)]),
|
||||
]
|
||||
),
|
||||
),
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
async def test_service_search():
|
||||
# Setup connections
|
||||
devices = TwoDevices()
|
||||
await devices.setup_connection()
|
||||
assert devices.connections[0]
|
||||
assert devices.connections[1]
|
||||
|
||||
# Register SDP service
|
||||
devices.devices[0].sdp_server.service_records.update(sdp_records())
|
||||
|
||||
# Search for service
|
||||
client = Client(devices.devices[1])
|
||||
await client.connect(devices.connections[1])
|
||||
services = await client.search_services(
|
||||
[UUID('E6D55659-C8B4-4B85-96BB-B1143AF6D3AE')]
|
||||
)
|
||||
|
||||
# Then
|
||||
assert services[0] == 0x00010001
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
async def test_service_attribute():
|
||||
# Setup connections
|
||||
devices = TwoDevices()
|
||||
await devices.setup_connection()
|
||||
|
||||
# Register SDP service
|
||||
devices.devices[0].sdp_server.service_records.update(sdp_records())
|
||||
|
||||
# Search for service
|
||||
client = Client(devices.devices[1])
|
||||
await client.connect(devices.connections[1])
|
||||
attributes = await client.get_attributes(
|
||||
0x00010001, [SDP_SERVICE_RECORD_HANDLE_ATTRIBUTE_ID]
|
||||
)
|
||||
|
||||
# Then
|
||||
assert attributes[0].value.value == sdp_records()[0x00010001][0].value.value
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
async def test_service_search_attribute():
|
||||
# Setup connections
|
||||
devices = TwoDevices()
|
||||
await devices.setup_connection()
|
||||
|
||||
# Register SDP service
|
||||
devices.devices[0].sdp_server.service_records.update(sdp_records())
|
||||
|
||||
# Search for service
|
||||
client = Client(devices.devices[1])
|
||||
await client.connect(devices.connections[1])
|
||||
attributes = await client.search_attributes(
|
||||
[UUID('E6D55659-C8B4-4B85-96BB-B1143AF6D3AE')], [(0x0000FFFF, 8)]
|
||||
)
|
||||
|
||||
# Then
|
||||
for expect, actual in zip(attributes, sdp_records().values()):
|
||||
assert expect.id == actual.id
|
||||
assert expect.value == actual.value
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
async def run():
|
||||
test_data_elements()
|
||||
await test_service_attribute()
|
||||
await test_service_search()
|
||||
await test_service_search_attribute()
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
if __name__ == '__main__':
|
||||
logging.basicConfig(level=os.environ.get('BUMBLE_LOGLEVEL', 'INFO').upper())
|
||||
asyncio.run(run())
|
||||
|
||||
73
tests/test_utils.py
Normal file
73
tests/test_utils.py
Normal file
@@ -0,0 +1,73 @@
|
||||
# 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, Optional
|
||||
|
||||
from bumble.controller import Controller
|
||||
from bumble.link import LocalLink
|
||||
from bumble.device import Device, Connection
|
||||
from bumble.host import Host
|
||||
from bumble.transport import AsyncPipeSink
|
||||
from bumble.hci import Address
|
||||
|
||||
|
||||
class TwoDevices:
|
||||
connections: List[Optional[Connection]]
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.connections = [None, None]
|
||||
|
||||
self.link = LocalLink()
|
||||
self.controllers = [
|
||||
Controller('C1', link=self.link),
|
||||
Controller('C2', link=self.link),
|
||||
]
|
||||
self.devices = [
|
||||
Device(
|
||||
address=Address('F0:F1:F2:F3:F4:F5'),
|
||||
host=Host(self.controllers[0], AsyncPipeSink(self.controllers[0])),
|
||||
),
|
||||
Device(
|
||||
address=Address('F5:F4:F3:F2:F1:F0'),
|
||||
host=Host(self.controllers[1], AsyncPipeSink(self.controllers[1])),
|
||||
),
|
||||
]
|
||||
|
||||
self.paired = [None, None]
|
||||
|
||||
def on_connection(self, which, connection):
|
||||
self.connections[which] = connection
|
||||
|
||||
def on_paired(self, which, keys):
|
||||
self.paired[which] = keys
|
||||
|
||||
async def setup_connection(self) -> None:
|
||||
# Attach listeners
|
||||
self.devices[0].on(
|
||||
'connection', lambda connection: self.on_connection(0, connection)
|
||||
)
|
||||
self.devices[1].on(
|
||||
'connection', lambda connection: self.on_connection(1, connection)
|
||||
)
|
||||
|
||||
# Start
|
||||
await self.devices[0].power_on()
|
||||
await self.devices[1].power_on()
|
||||
|
||||
# Connect the two devices
|
||||
await self.devices[0].connect(self.devices[1].random_address)
|
||||
|
||||
# Check the post conditions
|
||||
assert self.connections[0] is not None
|
||||
assert self.connections[1] is not None
|
||||
@@ -67,8 +67,9 @@ def download_file(base_url, name, remove_suffix):
|
||||
@click.command
|
||||
@click.option(
|
||||
"--output-dir",
|
||||
default=".",
|
||||
help="Output directory where the files will be saved",
|
||||
default="",
|
||||
help="Output directory where the files will be saved. Defaults to the OS-specific"
|
||||
"app data dir, which the driver will check when trying to find firmware",
|
||||
show_default=True,
|
||||
)
|
||||
@click.option(
|
||||
@@ -84,7 +85,10 @@ def main(output_dir, source, single, force, parse):
|
||||
"""Download RTK firmware images and configs."""
|
||||
|
||||
# Check that the output dir exists
|
||||
output_dir = pathlib.Path(output_dir)
|
||||
if output_dir == '':
|
||||
output_dir = rtk.rtk_firmware_dir()
|
||||
else:
|
||||
output_dir = pathlib.Path(output_dir)
|
||||
if not output_dir.is_dir():
|
||||
print("Output dir does not exist or is not a directory")
|
||||
return
|
||||
|
||||
@@ -61,9 +61,8 @@ async def do_load(usb_transport, force):
|
||||
# Get the driver.
|
||||
driver = await rtk.Driver.for_host(host, force)
|
||||
if driver is None:
|
||||
if not force:
|
||||
print("Firmware already loaded or no supported driver for this device.")
|
||||
return
|
||||
print("Firmware already loaded or no supported driver for this device.")
|
||||
return
|
||||
|
||||
await driver.download_firmware()
|
||||
|
||||
|
||||
48
web/README.md
Normal file
48
web/README.md
Normal file
@@ -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.
|
||||
92
web/bumble.js
Normal file
92
web/bumble.js
Normal file
@@ -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
web/index.html
131
web/index.html
@@ -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>
|
||||
129
web/scanner/scanner.html
Normal file
129
web/scanner/scanner.html
Normal file
@@ -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()
|
||||
|
||||
42
web/speaker/logo.svg
Normal file
42
web/speaker/logo.svg
Normal file
@@ -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 |
76
web/speaker/speaker.css
Normal file
76
web/speaker/speaker.css
Normal file
@@ -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; }
|
||||
34
web/speaker/speaker.html
Normal file
34
web/speaker/speaker.html
Normal file
@@ -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>
|
||||
289
web/speaker/speaker.js
Normal file
289
web/speaker/speaker.js
Normal file
@@ -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();
|
||||
}
|
||||
|
||||
}());
|
||||
321
web/speaker/speaker.py
Normal file
321
web/speaker/speaker.py
Normal file
@@ -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