Compare commits

...

26 Commits

Author SHA1 Message Date
Gilles Boccon-Gibod
859bb0609f fix support for float32 2025-02-08 18:12:45 -05:00
Gilles Boccon-Gibod
5f2d24570e larger queue size 2025-02-06 21:24:58 -05:00
Gilles Boccon-Gibod
dbf94c8f3e print encoding params 2025-02-06 18:23:31 -05:00
Gilles Boccon-Gibod
b6adc29365 python 3.9 compat 2025-02-06 18:17:13 -05:00
Gilles Boccon-Gibod
5caa7bfa90 fix type checker and linter errors 2025-02-06 17:05:56 -05:00
Gilles Boccon-Gibod
f39d706fa0 remove obsolete code 2025-02-06 16:45:37 -05:00
Gilles Boccon-Gibod
33435c2980 better docs and GATT fixes 2025-02-06 15:48:39 -05:00
Gilles Boccon-Gibod
26e87f09fe better error message 2025-02-05 22:28:05 -05:00
Gilles Boccon-Gibod
7f5e0d190e fix import checking 2025-02-05 22:19:39 -05:00
Gilles Boccon-Gibod
efae307b3d wip 2025-02-05 16:23:47 -05:00
Gilles Boccon-Gibod
9756572c93 add audio module 2025-02-04 17:58:54 -05:00
Gilles Boccon-Gibod
70141c0439 improvements 2025-02-03 17:58:09 -05:00
Gilles Boccon-Gibod
55d3fd90f5 wip 2025-01-25 21:04:59 -05:00
Gilles Boccon-Gibod
afee659ca6 Merge pull request #630 from google/gbg/iso-packet-queue
add support for ACL and ISO HCI packet queues
2025-01-24 15:59:19 -05:00
Gilles Boccon-Gibod
6fe7931d7d rename drain event to flow 2025-01-24 11:05:02 -05:00
Gilles Boccon-Gibod
cbd46adbcf add support for ACL and ISO HCI packet queues 2025-01-22 13:42:29 -05:00
Gilles Boccon-Gibod
af466c2970 Merge pull request #629 from google/gbg/sdp-enforce-mtu
SDP: enforce MTU limits
2025-01-21 12:29:18 -05:00
Gilles Boccon-Gibod
931e2de854 address PR comments 2025-01-21 12:18:06 -05:00
Gilles Boccon-Gibod
55eb7eb237 enforce MTU limits 2025-01-21 10:31:10 -05:00
zxzxwu
bade4502f9 Merge pull request #628 from zxzxwu/cs-hci
Channel Sounding HCI packet definitions
2025-01-19 16:14:08 +08:00
Josh Wu
9f952f202f Channel Sounding HCI packet definitions 2025-01-16 14:33:34 +08:00
Gilles Boccon-Gibod
5a477eb391 Merge pull request #626 from markusjellitsch/fix/set-ext-scan-param-cmd
Update device.py - Fix scan_interval param in hci.HCI_LE_Set_Extended_Scan_Parameters_Command
2025-01-14 11:04:15 -05:00
Markus Jellitsch
86cda8771d Update device.py 2025-01-14 10:43:49 +01:00
zxzxwu
c1ea0ddd35 Merge pull request #622 from markusjellitsch/main
Fix: _IsoLink.write() struct.exception
2025-01-13 16:21:41 +08:00
Markus Jellitsch
f567711a6c avoid struct.error exception when packet_sequence_number > 0xFFFF 2025-01-10 01:33:43 +01:00
Gilles Boccon-Gibod
509df4c676 Merge pull request #618 from google/gbg/hci-event-multi-vendor
support multiple event factories
2025-01-07 15:00:20 -05:00
37 changed files with 2829 additions and 666 deletions

View File

@@ -1,4 +1,4 @@
# Copyright 2024 Google LLC
# Copyright 2025 Google LLC
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
@@ -18,14 +18,23 @@
from __future__ import annotations
import asyncio
import asyncio.subprocess
import collections
import contextlib
import dataclasses
import functools
import logging
import os
import wave
import itertools
from typing import cast, Any, AsyncGenerator, Coroutine, Dict, Optional, Tuple
import struct
from typing import (
cast,
Any,
AsyncGenerator,
Coroutine,
Deque,
Optional,
Tuple,
)
import click
import pyee
@@ -33,8 +42,11 @@ import pyee
try:
import lc3 # type: ignore # pylint: disable=E0401
except ImportError as e:
raise ImportError("Try `python -m pip install \".[lc3]\"`.") from e
raise ImportError(
"Try `python -m pip install \"git+https://github.com/google/liblc3.git\"`."
) from e
from bumble.audio import io as audio_io
from bumble.colors import color
from bumble import company_ids
from bumble import core
@@ -48,7 +60,6 @@ import bumble.device
import bumble.transport
import bumble.utils
# -----------------------------------------------------------------------------
# Logging
# -----------------------------------------------------------------------------
@@ -62,6 +73,31 @@ AURACAST_DEFAULT_DEVICE_NAME = 'Bumble Auracast'
AURACAST_DEFAULT_DEVICE_ADDRESS = hci.Address('F0:F1:F2:F3:F4:F5')
AURACAST_DEFAULT_SYNC_TIMEOUT = 5.0
AURACAST_DEFAULT_ATT_MTU = 256
AURACAST_DEFAULT_FRAME_DURATION = 10000
AURACAST_DEFAULT_SAMPLE_RATE = 48000
AURACAST_DEFAULT_TRANSMIT_BITRATE = 80000
# -----------------------------------------------------------------------------
# Utils
# -----------------------------------------------------------------------------
def codec_config_string(
codec_config: bap.CodecSpecificConfiguration, indent: str
) -> str:
lines = []
if codec_config.sampling_frequency is not None:
lines.append(f'Sampling Frequency: {codec_config.sampling_frequency.hz} hz')
if codec_config.frame_duration is not None:
lines.append(f'Frame Duration: {codec_config.frame_duration.us} µs')
if codec_config.octets_per_codec_frame is not None:
lines.append(f'Frame Size: {codec_config.octets_per_codec_frame} bytes')
if codec_config.codec_frames_per_sdu is not None:
lines.append(f'Frames Per SDU: {codec_config.codec_frames_per_sdu}')
if codec_config.audio_channel_allocation is not None:
lines.append(
f'Audio Location: {codec_config.audio_channel_allocation.name}'
)
return '\n'.join(indent + line for line in lines)
# -----------------------------------------------------------------------------
@@ -156,18 +192,17 @@ class BroadcastScanner(pyee.EventEmitter):
if self.public_broadcast_announcement:
print(
f' {color("Features", "cyan")}: '
f'{self.public_broadcast_announcement.features}'
)
print(
f' {color("Metadata", "cyan")}: '
f'{self.public_broadcast_announcement.metadata}'
f'{self.public_broadcast_announcement.features.name}'
)
print(f' {color("Metadata", "cyan")}:')
print(self.public_broadcast_announcement.metadata.pretty_print(' '))
if self.basic_audio_announcement:
print(color(' Audio:', 'cyan'))
print(
color(' Presentation Delay:', 'magenta'),
self.basic_audio_announcement.presentation_delay,
"µs",
)
for subgroup in self.basic_audio_announcement.subgroups:
print(color(' Subgroup:', 'magenta'))
@@ -184,17 +219,22 @@ class BroadcastScanner(pyee.EventEmitter):
color(' Vendor Specific Codec ID:', 'green'),
subgroup.codec_id.vendor_specific_codec_id,
)
print(color(' Codec Config:', 'yellow'))
print(
color(' Codec Config:', 'yellow'),
subgroup.codec_specific_configuration,
codec_config_string(
subgroup.codec_specific_configuration, ' '
),
)
print(color(' Metadata: ', 'yellow'), subgroup.metadata)
print(color(' Metadata: ', 'yellow'))
print(subgroup.metadata.pretty_print(' '))
for bis in subgroup.bis:
print(color(f' BIS [{bis.index}]:', 'yellow'))
print(color(' Codec Config:', 'green'))
print(
color(' Codec Config:', 'green'),
bis.codec_specific_configuration,
codec_config_string(
bis.codec_specific_configuration, ' '
),
)
if self.biginfo:
@@ -494,7 +534,7 @@ async def run_assist(
except core.ProtocolError as error:
print(
color(
f'!!! Failed to subscribe to Broadcast Receive State characteristic:',
'!!! Failed to subscribe to Broadcast Receive State characteristic',
'red',
),
error,
@@ -625,11 +665,20 @@ async def run_pair(transport: str, address: str) -> None:
async def run_receive(
transport: str,
broadcast_id: int,
broadcast_id: Optional[int],
output: str,
broadcast_code: str | None,
sync_timeout: float,
subgroup_index: int,
) -> None:
# Run a pre-flight check for the output.
try:
if not audio_io.check_audio_output(output):
return
except ValueError as error:
print(error)
return
async with create_device(transport) as device:
if not device.supports_le_periodic_advertising:
print(color('Periodic advertising not supported', 'red'))
@@ -643,7 +692,7 @@ async def run_receive(
def on_new_broadcast(broadcast: BroadcastScanner.Broadcast) -> None:
if scan_result.done():
return
if broadcast.broadcast_id == broadcast_id:
if broadcast_id is None or broadcast.broadcast_id == broadcast_id:
scan_result.set_result(broadcast)
scanner.on('new_broadcast', on_new_broadcast)
@@ -694,57 +743,95 @@ async def run_receive(
sample_rate_hz=sampling_frequency.hz,
num_channels=num_bis,
)
sdus = [b''] * num_bis
subprocess = await asyncio.create_subprocess_shell(
f'stdbuf -i0 ffplay -ar {sampling_frequency.hz} -ac {num_bis} -f f32le pipe:0',
stdin=asyncio.subprocess.PIPE,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
)
for i, bis_link in enumerate(big_sync.bis_links):
print(f'Setup ISO for BIS {bis_link.handle}')
lc3_queues: list[Deque[bytes]] = [collections.deque() for i in range(num_bis)]
packet_stats = [0, 0]
def sink(index: int, packet: hci.HCI_IsoDataPacket):
nonlocal sdus
sdus[index] = packet.iso_sdu_fragment
if all(sdus) and subprocess.stdin:
subprocess.stdin.write(decoder.decode(b''.join(sdus)).tobytes())
sdus = [b''] * num_bis
bis_link.sink = functools.partial(sink, i)
await bis_link.setup_data_path(
direction=bis_link.Direction.CONTROLLER_TO_HOST
audio_output = await audio_io.create_audio_output(output)
# This try should be replaced with contextlib.aclosing() when python 3.9 is no
# longer needed.
try:
await audio_output.open(
audio_io.PcmFormat(
audio_io.PcmFormat.Endianness.LITTLE,
audio_io.PcmFormat.SampleType.FLOAT32,
sampling_frequency.hz,
num_bis,
)
)
terminated = asyncio.Event()
big_sync.on(big_sync.Event.TERMINATION, lambda _: terminated.set())
await terminated.wait()
def sink(queue: Deque[bytes], packet: hci.HCI_IsoDataPacket):
# TODO: re-assemble fragments and detect errors
queue.append(packet.iso_sdu_fragment)
while all(lc3_queues):
# This assumes SDUs contain one LC3 frame each, which may not
# be correct for all cases. TODO: revisit this assumption.
frame = b''.join([lc3_queue.popleft() for lc3_queue in lc3_queues])
if not frame:
print(color('!!! received empty frame', 'red'))
continue
packet_stats[0] += len(frame)
packet_stats[1] += 1
print(
f'\rRECEIVED: {packet_stats[0]} bytes in '
f'{packet_stats[1]} packets',
end='',
)
try:
pcm = decoder.decode(frame).tobytes()
except lc3.BaseError as error:
print(color(f'!!! LC3 decoding error: {error}'))
continue
audio_output.write(pcm)
for i, bis_link in enumerate(big_sync.bis_links):
print(f'Setup ISO for BIS {bis_link.handle}')
bis_link.sink = functools.partial(sink, lc3_queues[i])
await device.send_command(
hci.HCI_LE_Setup_ISO_Data_Path_Command(
connection_handle=bis_link.handle,
data_path_direction=hci.HCI_LE_Setup_ISO_Data_Path_Command.Direction.CONTROLLER_TO_HOST,
data_path_id=0,
codec_id=hci.CodingFormat(codec_id=hci.CodecID.TRANSPARENT),
controller_delay=0,
codec_configuration=b'',
),
check_result=True,
)
terminated = asyncio.Event()
big_sync.on(big_sync.Event.TERMINATION, lambda _: terminated.set())
await terminated.wait()
finally:
await audio_output.aclose()
async def run_broadcast(
transport: str, broadcast_id: int, broadcast_code: str | None, wav_file_path: str
async def run_transmit(
transport: str,
broadcast_id: int,
broadcast_code: str | None,
broadcast_name: str,
bitrate: int,
manufacturer_data: tuple[int, bytes] | None,
input: str,
input_format: str,
) -> None:
# Run a pre-flight check for the input.
try:
if not audio_io.check_audio_input(input):
return
except ValueError as error:
print(error)
return
async with create_device(transport) as device:
if not device.supports_le_periodic_advertising:
print(color('Periodic advertising not supported', 'red'))
return
with wave.open(wav_file_path, 'rb') as wav:
print('Encoding wav file into lc3...')
encoder = lc3.Encoder(
frame_duration_us=10000,
sample_rate_hz=48000,
num_channels=2,
input_sample_rate_hz=wav.getframerate(),
)
frames = list[bytes]()
while pcm := wav.readframes(encoder.get_frame_samples()):
frames.append(
encoder.encode(pcm, num_bytes=200, bit_depth=wav.getsampwidth() * 8)
)
del encoder
print('Encoding complete.')
basic_audio_announcement = bap.BasicAudioAnnouncement(
presentation_delay=40000,
subgroups=[
@@ -783,7 +870,23 @@ async def run_broadcast(
],
)
broadcast_audio_announcement = bap.BroadcastAudioAnnouncement(broadcast_id)
print('Start Advertising')
advertising_manufacturer_data = (
b''
if manufacturer_data is None
else bytes(
core.AdvertisingData(
[
(
core.AdvertisingData.MANUFACTURER_SPECIFIC_DATA,
struct.pack('<H', manufacturer_data[0])
+ manufacturer_data[1],
)
]
)
)
)
advertising_set = await device.create_advertising_set(
advertising_parameters=bumble.device.AdvertisingParameters(
advertising_event_properties=bumble.device.AdvertisingEventProperties(
@@ -796,9 +899,10 @@ async def run_broadcast(
broadcast_audio_announcement.get_advertising_data()
+ bytes(
core.AdvertisingData(
[(core.AdvertisingData.BROADCAST_NAME, b'Bumble Auracast')]
[(core.AdvertisingData.BROADCAST_NAME, broadcast_name.encode())]
)
)
+ advertising_manufacturer_data
),
periodic_advertising_parameters=bumble.device.PeriodicAdvertisingParameters(
periodic_advertising_interval_min=80,
@@ -808,33 +912,83 @@ async def run_broadcast(
auto_restart=True,
auto_start=True,
)
print('Start Periodic Advertising')
await advertising_set.start_periodic()
print('Setup BIG')
big = await device.create_big(
advertising_set,
parameters=bumble.device.BigParameters(
num_bis=2,
sdu_interval=10000,
max_sdu=100,
max_transport_latency=65,
rtn=4,
broadcast_code=(
bytes.fromhex(broadcast_code) if broadcast_code else None
),
),
)
print('Setup ISO Data Path')
for bis_link in big.bis_links:
await bis_link.setup_data_path(
direction=bis_link.Direction.HOST_TO_CONTROLLER
audio_input = await audio_io.create_audio_input(input, input_format)
pcm_format = await audio_input.open()
# This try should be replaced with contextlib.aclosing() when python 3.9 is no
# longer needed.
try:
if pcm_format.channels != 2:
print("Only 2 channels PCM configurations are supported")
return
if pcm_format.sample_type == audio_io.PcmFormat.SampleType.INT16:
pcm_bit_depth = 16
elif pcm_format.sample_type == audio_io.PcmFormat.SampleType.FLOAT32:
pcm_bit_depth = None
else:
print("Only INT16 and FLOAT32 sample types are supported")
return
encoder = lc3.Encoder(
frame_duration_us=AURACAST_DEFAULT_FRAME_DURATION,
sample_rate_hz=AURACAST_DEFAULT_SAMPLE_RATE,
num_channels=pcm_format.channels,
input_sample_rate_hz=pcm_format.sample_rate,
)
lc3_frame_samples = encoder.get_frame_samples()
lc3_frame_size = encoder.get_frame_bytes(bitrate)
print(
f'Encoding with {lc3_frame_samples} '
f'PCM samples per {lc3_frame_size} byte frame'
)
for frame in itertools.cycle(frames):
mid = len(frame) // 2
big.bis_links[0].write(frame[:mid])
big.bis_links[1].write(frame[mid:])
await asyncio.sleep(0.009)
print('Setup BIG')
big = await device.create_big(
advertising_set,
parameters=bumble.device.BigParameters(
num_bis=pcm_format.channels,
sdu_interval=AURACAST_DEFAULT_FRAME_DURATION,
max_sdu=lc3_frame_size,
max_transport_latency=65,
rtn=4,
broadcast_code=(
bytes.fromhex(broadcast_code) if broadcast_code else None
),
),
)
iso_queues = [
bumble.device.IsoPacketStream(big.bis_links[0], 64),
bumble.device.IsoPacketStream(big.bis_links[1], 64),
]
def on_flow():
data_packet_queue = iso_queues[0].data_packet_queue
print(
f'\rPACKETS: pending={data_packet_queue.pending}, '
f'queued={data_packet_queue.queued}, '
f'completed={data_packet_queue.completed}',
end='',
)
iso_queues[0].data_packet_queue.on('flow', on_flow)
frame_count = 0
async for pcm_frame in audio_input.frames(lc3_frame_samples):
lc3_frame = encoder.encode(
pcm_frame, num_bytes=2 * lc3_frame_size, bit_depth=pcm_bit_depth
)
mid = len(lc3_frame) // 2
await iso_queues[0].write(lc3_frame[:mid])
await iso_queues[1].write(lc3_frame[mid:])
frame_count += 1
finally:
await audio_input.aclose()
def run_async(async_command: Coroutine) -> None:
@@ -903,7 +1057,7 @@ def scan(ctx, filter_duplicates, sync_timeout, transport):
@click.argument('address')
@click.pass_context
def assist(ctx, broadcast_name, source_id, command, transport, address):
"""Scan for broadcasts on behalf of a audio server"""
"""Scan for broadcasts on behalf of an audio server"""
run_async(run_assist(broadcast_name, source_id, command, transport, address))
@@ -918,7 +1072,24 @@ def pair(ctx, transport, address):
@auracast.command('receive')
@click.argument('transport')
@click.argument('broadcast_id', type=int)
@click.argument(
'broadcast_id',
type=int,
required=False,
)
@click.option(
'--output',
default='device',
help=(
"Audio output. "
"'device' -> use the host's default sound output device, "
"'device:<DEVICE_ID>' -> use one of the host's sound output device "
"(specify 'device:?' to get a list of available sound output devices), "
"'stdout' -> send audio to stdout, "
"'file:<filename> -> write audio to a raw float32 PCM file, "
"'ffplay' -> pipe the audio to ffplay"
),
)
@click.option(
'--broadcast-code',
metavar='BROADCAST_CODE',
@@ -940,16 +1111,57 @@ def pair(ctx, transport, address):
help='Index of Subgroup',
)
@click.pass_context
def receive(ctx, transport, broadcast_id, broadcast_code, sync_timeout, subgroup):
def receive(
ctx,
transport,
broadcast_id,
output,
broadcast_code,
sync_timeout,
subgroup,
):
"""Receive a broadcast source"""
run_async(
run_receive(transport, broadcast_id, broadcast_code, sync_timeout, subgroup)
run_receive(
transport,
broadcast_id,
output,
broadcast_code,
sync_timeout,
subgroup,
)
)
@auracast.command('broadcast')
@auracast.command('transmit')
@click.argument('transport')
@click.argument('wav_file_path', type=str)
@click.option(
'--input',
required=True,
help=(
"Audio input. "
"'device' -> use the host's default sound input device, "
"'device:<DEVICE_ID>' -> use one of the host's sound input devices "
"(specify 'device:?' to get a list of available sound input devices), "
"'stdin' -> receive audio from stdin as int16 PCM, "
"'file:<filename> -> read audio from a .wav or raw int16 PCM file. "
"(The file: prefix may be omitted if the file path does not start with "
"the substring 'device:' or 'file:' and is not 'stdin')"
),
)
@click.option(
'--input-format',
metavar="FORMAT",
default='auto',
help=(
"Audio input format. "
"Use 'auto' for .wav files, or for the default setting with the devices. "
"For other inputs, the format is specified as "
"<sample-type>,<sample-rate>,<channels> (supported <sample-type>: 'int16le' "
"for 16-bit signed integers with little-endian byte order or 'float32le' for "
"32-bit floating point with little-endian byte order)"
),
)
@click.option(
'--broadcast-id',
metavar='BROADCAST_ID',
@@ -960,18 +1172,60 @@ def receive(ctx, transport, broadcast_id, broadcast_code, sync_timeout, subgroup
@click.option(
'--broadcast-code',
metavar='BROADCAST_CODE',
type=str,
help='Broadcast encryption code in hex format',
)
@click.option(
'--broadcast-name',
metavar='BROADCAST_NAME',
default='Bumble Auracast',
help='Broadcast name',
)
@click.option(
'--bitrate',
type=int,
default=AURACAST_DEFAULT_TRANSMIT_BITRATE,
help='Bitrate, per channel, in bps',
)
@click.option(
'--manufacturer-data',
metavar='VENDOR-ID:DATA-HEX',
help='Manufacturer data (specify as <vendor-id>:<data-hex>)',
)
@click.pass_context
def broadcast(ctx, transport, broadcast_id, broadcast_code, wav_file_path):
"""Start a broadcast as a source."""
def transmit(
ctx,
transport,
broadcast_id,
broadcast_code,
manufacturer_data,
broadcast_name,
bitrate,
input,
input_format,
):
"""Transmit a broadcast source"""
if manufacturer_data:
vendor_id_str, data_hex = manufacturer_data.split(':')
vendor_id = int(vendor_id_str)
data = bytes.fromhex(data_hex)
manufacturer_data_tuple = (vendor_id, data)
else:
manufacturer_data_tuple = None
if (input == 'device' or input.startswith('device:')) and input_format == 'auto':
# Use a default format for device inputs
input_format = 'int16le,48000,1'
run_async(
run_broadcast(
run_transmit(
transport=transport,
broadcast_id=broadcast_id,
broadcast_code=broadcast_code,
wav_file_path=wav_file_path,
broadcast_name=broadcast_name,
bitrate=bitrate,
manufacturer_data=manufacturer_data_tuple,
input=input,
input_format=input_format,
)
)

View File

@@ -37,6 +37,8 @@ from bumble.hci import (
HCI_Command_Status_Event,
HCI_READ_BUFFER_SIZE_COMMAND,
HCI_Read_Buffer_Size_Command,
HCI_LE_READ_BUFFER_SIZE_V2_COMMAND,
HCI_LE_Read_Buffer_Size_V2_Command,
HCI_READ_BD_ADDR_COMMAND,
HCI_Read_BD_ADDR_Command,
HCI_READ_LOCAL_NAME_COMMAND,
@@ -147,7 +149,7 @@ async def get_le_info(host: Host) -> None:
# -----------------------------------------------------------------------------
async def get_acl_flow_control_info(host: Host) -> None:
async def get_flow_control_info(host: Host) -> None:
print()
if host.supports_command(HCI_READ_BUFFER_SIZE_COMMAND):
@@ -160,14 +162,28 @@ async def get_acl_flow_control_info(host: Host) -> None:
f'packets of size {response.return_parameters.hc_acl_data_packet_length}',
)
if host.supports_command(HCI_LE_READ_BUFFER_SIZE_COMMAND):
if host.supports_command(HCI_LE_READ_BUFFER_SIZE_V2_COMMAND):
response = await host.send_command(
HCI_LE_Read_Buffer_Size_V2_Command(), check_result=True
)
print(
color('LE ACL Flow Control:', 'yellow'),
f'{response.return_parameters.total_num_le_acl_data_packets} '
f'packets of size {response.return_parameters.le_acl_data_packet_length}',
)
print(
color('LE ISO Flow Control:', 'yellow'),
f'{response.return_parameters.total_num_iso_data_packets} '
f'packets of size {response.return_parameters.iso_data_packet_length}',
)
elif host.supports_command(HCI_LE_READ_BUFFER_SIZE_COMMAND):
response = await host.send_command(
HCI_LE_Read_Buffer_Size_Command(), check_result=True
)
print(
color('LE ACL Flow Control:', 'yellow'),
f'{response.return_parameters.hc_total_num_le_acl_data_packets} '
f'packets of size {response.return_parameters.hc_le_acl_data_packet_length}',
f'{response.return_parameters.total_num_le_acl_data_packets} '
f'packets of size {response.return_parameters.le_acl_data_packet_length}',
)
@@ -274,8 +290,8 @@ async def async_main(latency_probes, transport):
# Get the LE info
await get_le_info(host)
# Print the ACL flow control info
await get_acl_flow_control_info(host)
# Print the flow control info
await get_flow_control_info(host)
# Get codec info
await get_codecs_info(host)

View File

@@ -29,7 +29,9 @@ from bumble.gatt import Service
from bumble.profiles.device_information_service import DeviceInformationServiceProxy
from bumble.profiles.battery_service import BatteryServiceProxy
from bumble.profiles.gap import GenericAccessServiceProxy
from bumble.profiles.pacs import PublishedAudioCapabilitiesServiceProxy
from bumble.profiles.tmap import TelephonyAndMediaAudioServiceProxy
from bumble.profiles.vcs import VolumeControlServiceProxy
from bumble.transport import open_transport_or_link
@@ -126,14 +128,52 @@ async def show_tmas(
print(color('### Telephony And Media Audio Service', 'yellow'))
if tmas.role:
print(
color(' Role:', 'green'),
await tmas.role.read_value(),
)
role = await tmas.role.read_value()
print(color(' Role:', 'green'), role)
print()
# -----------------------------------------------------------------------------
async def show_pacs(pacs: PublishedAudioCapabilitiesServiceProxy) -> None:
print(color('### Published Audio Capabilities Service', 'yellow'))
contexts = await pacs.available_audio_contexts.read_value()
print(color(' Available Audio Contexts:', 'green'), contexts)
contexts = await pacs.supported_audio_contexts.read_value()
print(color(' Supported Audio Contexts:', 'green'), contexts)
if pacs.sink_pac:
pac = await pacs.sink_pac.read_value()
print(color(' Sink PAC: ', 'green'), pac)
if pacs.sink_audio_locations:
audio_locations = await pacs.sink_audio_locations.read_value()
print(color(' Sink Audio Locations: ', 'green'), audio_locations)
if pacs.source_pac:
pac = await pacs.source_pac.read_value()
print(color(' Source PAC: ', 'green'), pac)
if pacs.source_audio_locations:
audio_locations = await pacs.source_audio_locations.read_value()
print(color(' Source Audio Locations: ', 'green'), audio_locations)
print()
# -----------------------------------------------------------------------------
async def show_vcs(vcs: VolumeControlServiceProxy) -> None:
print(color('### Volume Control Service', 'yellow'))
volume_state = await vcs.volume_state.read_value()
print(color(' Volume State:', 'green'), volume_state)
volume_flags = await vcs.volume_flags.read_value()
print(color(' Volume Flags:', 'green'), volume_flags)
# -----------------------------------------------------------------------------
async def show_device_info(peer, done: Optional[asyncio.Future]) -> None:
try:
@@ -161,6 +201,12 @@ async def show_device_info(peer, done: Optional[asyncio.Future]) -> None:
if tmas := peer.create_service_proxy(TelephonyAndMediaAudioServiceProxy):
await try_show(show_tmas, tmas)
if pacs := peer.create_service_proxy(PublishedAudioCapabilitiesServiceProxy):
await try_show(show_pacs, pacs)
if vcs := peer.create_service_proxy(VolumeControlServiceProxy):
await try_show(show_vcs, vcs)
if done is not None:
done.set_result(None)
except asyncio.CancelledError:

17
bumble/audio/__init__.py Normal file
View File

@@ -0,0 +1,17 @@
# Copyright 2025 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
# -----------------------------------------------------------------------------

553
bumble/audio/io.py Normal file
View File

@@ -0,0 +1,553 @@
# Copyright 2025 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 asyncio
import abc
from concurrent.futures import ThreadPoolExecutor
import dataclasses
import enum
import logging
import pathlib
from typing import (
AsyncGenerator,
BinaryIO,
TYPE_CHECKING,
)
import sys
import wave
from bumble.colors import color
if TYPE_CHECKING:
import sounddevice # type: ignore[import-untyped]
# -----------------------------------------------------------------------------
# Logging
# -----------------------------------------------------------------------------
logger = logging.getLogger(__name__)
# -----------------------------------------------------------------------------
# Classes
# -----------------------------------------------------------------------------
@dataclasses.dataclass
class PcmFormat:
class Endianness(enum.Enum):
LITTLE = 0
BIG = 1
class SampleType(enum.Enum):
FLOAT32 = 0
INT16 = 1
endianness: Endianness
sample_type: SampleType
sample_rate: int
channels: int
@classmethod
def from_str(cls, format_str: str) -> PcmFormat:
endianness = cls.Endianness.LITTLE # Others not yet supported.
sample_type_str, sample_rate_str, channels_str = format_str.split(',')
if sample_type_str == 'int16le':
sample_type = cls.SampleType.INT16
elif sample_type_str == 'float32le':
sample_type = cls.SampleType.FLOAT32
else:
raise ValueError(f'sample type {sample_type_str} not supported')
sample_rate = int(sample_rate_str)
channels = int(channels_str)
return cls(endianness, sample_type, sample_rate, channels)
@property
def bytes_per_sample(self) -> int:
return 2 if self.sample_type == self.SampleType.INT16 else 4
def check_audio_output(output: str) -> bool:
if output == 'device' or output.startswith('device:'):
try:
import sounddevice
except ImportError as exc:
raise ValueError(
'audio output not available (sounddevice python module not installed)'
) from exc
except OSError as exc:
raise ValueError(
'audio output not available '
'(sounddevice python module failed to load: '
f'{exc})'
) from exc
if output == 'device':
# Default device
return True
# Specific device
device = output[7:]
if device == '?':
print(color('Audio Devices:', 'yellow'))
for device_info in [
device_info
for device_info in sounddevice.query_devices()
if device_info['max_output_channels'] > 0
]:
device_index = device_info['index']
is_default = (
color(' [default]', 'green')
if sounddevice.default.device[1] == device_index
else ''
)
print(
f'{color(device_index, "cyan")}: {device_info["name"]}{is_default}'
)
return False
try:
device_info = sounddevice.query_devices(int(device))
except sounddevice.PortAudioError as exc:
raise ValueError('No such audio device') from exc
if device_info['max_output_channels'] < 1:
raise ValueError(
f'Device {device} ({device_info["name"]}) does not have an output'
)
return True
async def create_audio_output(output: str) -> AudioOutput:
if output == 'stdout':
return StreamAudioOutput(sys.stdout.buffer)
if output == 'device' or output.startswith('device:'):
device_name = '' if output == 'device' else output[7:]
return SoundDeviceAudioOutput(device_name)
if output == 'ffplay':
return SubprocessAudioOutput(
command=(
'ffplay -probesize 32 -fflags nobuffer -analyzeduration 0 '
'-ar {sample_rate} '
'-ch_layout {channel_layout} '
'-f f32le pipe:0'
)
)
if output.startswith('file:'):
return FileAudioOutput(output[5:])
raise ValueError('unsupported audio output')
class AudioOutput(abc.ABC):
"""Audio output to which PCM samples can be written."""
async def open(self, pcm_format: PcmFormat) -> None:
"""Start the output."""
@abc.abstractmethod
def write(self, pcm_samples: bytes) -> None:
"""Write PCM samples. Must not block."""
async def aclose(self) -> None:
"""Close the output."""
class ThreadedAudioOutput(AudioOutput):
"""Base class for AudioOutput classes that may need to call blocking functions.
The actual writing is performed in a thread, so as to ensure that calling write()
does not block the caller.
"""
def __init__(self) -> None:
self._thread_pool = ThreadPoolExecutor(1)
self._pcm_samples: asyncio.Queue[bytes] = asyncio.Queue()
self._write_task = asyncio.create_task(self._write_loop())
async def _write_loop(self) -> None:
while True:
pcm_samples = await self._pcm_samples.get()
await asyncio.get_running_loop().run_in_executor(
self._thread_pool, self._write, pcm_samples
)
@abc.abstractmethod
def _write(self, pcm_samples: bytes) -> None:
"""This method does the actual writing and can block."""
def write(self, pcm_samples: bytes) -> None:
self._pcm_samples.put_nowait(pcm_samples)
def _close(self) -> None:
"""This method does the actual closing and can block."""
async def aclose(self) -> None:
await asyncio.get_running_loop().run_in_executor(self._thread_pool, self._close)
self._write_task.cancel()
self._thread_pool.shutdown()
class SoundDeviceAudioOutput(ThreadedAudioOutput):
def __init__(self, device_name: str) -> None:
super().__init__()
self._device = int(device_name) if device_name else None
self._stream: sounddevice.RawOutputStream | None = None
async def open(self, pcm_format: PcmFormat) -> None:
import sounddevice # pylint: disable=import-error
self._stream = sounddevice.RawOutputStream(
samplerate=pcm_format.sample_rate,
device=self._device,
channels=pcm_format.channels,
dtype='float32',
)
self._stream.start()
def _write(self, pcm_samples: bytes) -> None:
if self._stream is None:
return
try:
self._stream.write(pcm_samples)
except Exception as error:
print(f'Sound device error: {error}')
raise
def _close(self):
self._stream.stop()
self._stream = None
class StreamAudioOutput(ThreadedAudioOutput):
"""AudioOutput where PCM samples are written to a stream that may block."""
def __init__(self, stream: BinaryIO) -> None:
super().__init__()
self._stream = stream
def _write(self, pcm_samples: bytes) -> None:
self._stream.write(pcm_samples)
self._stream.flush()
class FileAudioOutput(StreamAudioOutput):
"""AudioOutput where PCM samples are written to a file."""
def __init__(self, filename: str) -> None:
self._file = open(filename, "wb")
super().__init__(self._file)
async def shutdown(self):
self._file.close()
return await super().shutdown()
class SubprocessAudioOutput(AudioOutput):
"""AudioOutput where audio samples are written to a subprocess via stdin."""
def __init__(self, command: str) -> None:
self._command = command
self._subprocess: asyncio.subprocess.Process | None
async def open(self, pcm_format: PcmFormat) -> None:
if pcm_format.channels == 1:
channel_layout = 'mono'
elif pcm_format.channels == 2:
channel_layout = 'stereo'
else:
raise ValueError(f'{pcm_format.channels} channels not supported')
command = self._command.format(
sample_rate=pcm_format.sample_rate, channel_layout=channel_layout
)
self._subprocess = await asyncio.create_subprocess_shell(
command,
stdin=asyncio.subprocess.PIPE,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
)
def write(self, pcm_samples: bytes) -> None:
if self._subprocess is None or self._subprocess.stdin is None:
return
self._subprocess.stdin.write(pcm_samples)
async def aclose(self):
if self._subprocess:
self._subprocess.terminate()
def check_audio_input(input: str) -> bool:
if input == 'device' or input.startswith('device:'):
try:
import sounddevice # pylint: disable=import-error
except ImportError as exc:
raise ValueError(
'audio input not available (sounddevice python module not installed)'
) from exc
except OSError as exc:
raise ValueError(
'audio input not available '
'(sounddevice python module failed to load: '
f'{exc})'
) from exc
if input == 'device':
# Default device
return True
# Specific device
device = input[7:]
if device == '?':
print(color('Audio Devices:', 'yellow'))
for device_info in [
device_info
for device_info in sounddevice.query_devices()
if device_info['max_input_channels'] > 0
]:
device_index = device_info["index"]
is_mono = device_info['max_input_channels'] == 1
max_channels = color(f'[{"mono" if is_mono else "stereo"}]', 'cyan')
is_default = (
color(' [default]', 'green')
if sounddevice.default.device[0] == device_index
else ''
)
print(
f'{color(device_index, "cyan")}: {device_info["name"]}'
f' {max_channels}{is_default}'
)
return False
try:
device_info = sounddevice.query_devices(int(device))
except sounddevice.PortAudioError as exc:
raise ValueError('No such audio device') from exc
if device_info['max_input_channels'] < 1:
raise ValueError(
f'Device {device} ({device_info["name"]}) does not have an input'
)
return True
async def create_audio_input(input: str, input_format: str) -> AudioInput:
pcm_format: PcmFormat | None
if input_format == 'auto':
pcm_format = None
else:
pcm_format = PcmFormat.from_str(input_format)
if input == 'stdin':
if not pcm_format:
raise ValueError('input format details required for stdin')
return StreamAudioInput(sys.stdin.buffer, pcm_format)
if input == 'device' or input.startswith('device:'):
if not pcm_format:
raise ValueError('input format details required for device')
device_name = '' if input == 'device' else input[7:]
return SoundDeviceAudioInput(device_name, pcm_format)
# If there's no file: prefix, check if we can assume it is a file.
if pathlib.Path(input).is_file():
input = 'file:' + input
if input.startswith('file:'):
filename = input[5:]
if filename.endswith('.wav'):
if input_format != 'auto':
raise ValueError(".wav file only supported with 'auto' format")
return WaveAudioInput(filename)
if pcm_format is None:
raise ValueError('input format details required for raw PCM files')
return FileAudioInput(filename, pcm_format)
raise ValueError('input not supported')
class AudioInput(abc.ABC):
"""Audio input that produces PCM samples."""
@abc.abstractmethod
async def open(self) -> PcmFormat:
"""Open the input."""
@abc.abstractmethod
def frames(self, frame_size: int) -> AsyncGenerator[bytes]:
"""Generate one frame of PCM samples. Must not block."""
async def aclose(self) -> None:
"""Close the input."""
class ThreadedAudioInput(AudioInput):
"""Base class for AudioInput implementation where reading samples may block."""
def __init__(self) -> None:
self._thread_pool = ThreadPoolExecutor(1)
self._pcm_samples: asyncio.Queue[bytes] = asyncio.Queue()
@abc.abstractmethod
def _read(self, frame_size: int) -> bytes:
pass
@abc.abstractmethod
def _open(self) -> PcmFormat:
pass
def _close(self) -> None:
pass
async def open(self) -> PcmFormat:
return await asyncio.get_running_loop().run_in_executor(
self._thread_pool, self._open
)
async def frames(self, frame_size: int) -> AsyncGenerator[bytes]:
while pcm_sample := await asyncio.get_running_loop().run_in_executor(
self._thread_pool, self._read, frame_size
):
yield pcm_sample
async def aclose(self) -> None:
await asyncio.get_running_loop().run_in_executor(self._thread_pool, self._close)
self._thread_pool.shutdown()
class WaveAudioInput(ThreadedAudioInput):
"""Audio input that reads PCM samples from a .wav file."""
def __init__(self, filename: str) -> None:
super().__init__()
self._filename = filename
self._wav: wave.Wave_read | None = None
self._bytes_read = 0
def _open(self) -> PcmFormat:
self._wav = wave.open(self._filename, 'rb')
if self._wav.getsampwidth() != 2:
raise ValueError('sample width not supported')
return PcmFormat(
PcmFormat.Endianness.LITTLE,
PcmFormat.SampleType.INT16,
self._wav.getframerate(),
self._wav.getnchannels(),
)
def _read(self, frame_size: int) -> bytes:
if not self._wav:
return b''
pcm_samples = self._wav.readframes(frame_size)
if not pcm_samples and self._bytes_read:
# Loop around.
self._wav.rewind()
self._bytes_read = 0
pcm_samples = self._wav.readframes(frame_size)
self._bytes_read += len(pcm_samples)
return pcm_samples
def _close(self) -> None:
if self._wav:
self._wav.close()
class StreamAudioInput(ThreadedAudioInput):
"""AudioInput where samples are read from a raw PCM stream that may block."""
def __init__(self, stream: BinaryIO, pcm_format: PcmFormat) -> None:
super().__init__()
self._stream = stream
self._pcm_format = pcm_format
def _open(self) -> PcmFormat:
return self._pcm_format
def _read(self, frame_size: int) -> bytes:
return self._stream.read(
frame_size * self._pcm_format.channels * self._pcm_format.bytes_per_sample
)
class FileAudioInput(StreamAudioInput):
"""AudioInput where PCM samples are read from a raw PCM file."""
def __init__(self, filename: str, pcm_format: PcmFormat) -> None:
self._stream = open(filename, "rb")
super().__init__(self._stream, pcm_format)
def _close(self) -> None:
self._stream.close()
class SoundDeviceAudioInput(ThreadedAudioInput):
def __init__(self, device_name: str, pcm_format: PcmFormat) -> None:
super().__init__()
self._device = int(device_name) if device_name else None
self._pcm_format = pcm_format
self._stream: sounddevice.RawInputStream | None = None
def _open(self) -> PcmFormat:
import sounddevice # pylint: disable=import-error
self._stream = sounddevice.RawInputStream(
samplerate=self._pcm_format.sample_rate,
device=self._device,
channels=self._pcm_format.channels,
dtype='int16',
)
self._stream.start()
return PcmFormat(
PcmFormat.Endianness.LITTLE,
PcmFormat.SampleType.INT16,
self._pcm_format.sample_rate,
2,
)
def _read(self, frame_size: int) -> bytes:
if not self._stream:
return b''
pcm_buffer, overflowed = self._stream.read(frame_size)
if overflowed:
logger.warning("input overflow")
# Convert the buffer to stereo if needed
if self._pcm_format.channels == 1:
stereo_buffer = bytearray()
for i in range(frame_size):
sample = pcm_buffer[i * 2 : i * 2 + 2]
stereo_buffer += sample + sample
return stereo_buffer
return bytes(pcm_buffer)
def _close(self):
self._stream.stop()
self._stream = None

View File

@@ -154,15 +154,17 @@ class Controller:
'0000000060000000'
) # BR/EDR Not Supported, LE Supported (Controller)
self.manufacturer_name = 0xFFFF
self.hc_data_packet_length = 27
self.hc_total_num_data_packets = 64
self.hc_le_data_packet_length = 27
self.hc_total_num_le_data_packets = 64
self.acl_data_packet_length = 27
self.total_num_acl_data_packets = 64
self.le_acl_data_packet_length = 27
self.total_num_le_acl_data_packets = 64
self.iso_data_packet_length = 960
self.total_num_iso_data_packets = 64
self.event_mask = 0
self.event_mask_page_2 = 0
self.supported_commands = bytes.fromhex(
'2000800000c000000000e4000000a822000000000000040000f7ffff7f000000'
'30f0f9ff01008004000000000000000000000000000000000000000000000000'
'30f0f9ff01008004002000000000000000000000000000000000000000000000'
)
self.le_event_mask = 0
self.advertising_parameters = None
@@ -1181,9 +1183,9 @@ class Controller:
return struct.pack(
'<BHBHH',
HCI_SUCCESS,
self.hc_data_packet_length,
self.acl_data_packet_length,
0,
self.hc_total_num_data_packets,
self.total_num_acl_data_packets,
0,
)
@@ -1212,8 +1214,21 @@ class Controller:
return struct.pack(
'<BHB',
HCI_SUCCESS,
self.hc_le_data_packet_length,
self.hc_total_num_le_data_packets,
self.le_acl_data_packet_length,
self.total_num_le_acl_data_packets,
)
def on_hci_le_read_buffer_size_v2_command(self, _command):
'''
See Bluetooth spec Vol 4, Part E - 7.8.2 LE Read Buffer Size Command
'''
return struct.pack(
'<BHBHB',
HCI_SUCCESS,
self.le_acl_data_packet_length,
self.total_num_le_acl_data_packets,
self.iso_data_packet_length,
self.total_num_iso_data_packets,
)
def on_hci_le_read_local_supported_features_command(self, _command):

View File

@@ -17,6 +17,7 @@
# -----------------------------------------------------------------------------
from __future__ import annotations
import asyncio
import collections
from collections.abc import Iterable, Sequence
from contextlib import (
asynccontextmanager,
@@ -36,6 +37,7 @@ from typing import (
Any,
Callable,
ClassVar,
Deque,
Dict,
Optional,
Type,
@@ -52,7 +54,7 @@ from pyee import EventEmitter
from .colors import color
from .att import ATT_CID, ATT_DEFAULT_MTU, ATT_PDU
from .gatt import Characteristic, Descriptor, Service
from .host import Host
from .host import DataPacketQueue, Host
from .profiles.gap import GenericAccessService
from .core import (
BT_BR_EDR_TRANSPORT,
@@ -1329,7 +1331,6 @@ class ScoLink(CompositeEventEmitter):
class _IsoLink:
handle: int
device: Device
packet_sequence_number: int
sink: Callable[[hci.HCI_IsoDataPacket], Any] | None = None
class Direction(IntEnum):
@@ -1391,22 +1392,12 @@ class _IsoLink:
return response.return_parameters.status
def write(self, sdu: bytes) -> None:
"""Write an ISO SDU.
"""Write an ISO SDU."""
self.device.host.send_iso_sdu(connection_handle=self.handle, sdu=sdu)
This will automatically increase the packet sequence number.
"""
self.device.host.send_hci_packet(
hci.HCI_IsoDataPacket(
connection_handle=self.handle,
data_total_length=len(sdu) + 4,
packet_sequence_number=self.packet_sequence_number,
pb_flag=0b10,
packet_status_flag=0,
iso_sdu_length=len(sdu),
iso_sdu_fragment=sdu,
)
)
self.packet_sequence_number += 1
@property
def data_packet_queue(self) -> DataPacketQueue | None:
return self.device.host.get_data_packet_queue(self.handle)
# -----------------------------------------------------------------------------
@@ -1426,7 +1417,6 @@ class CisLink(CompositeEventEmitter, _IsoLink):
def __post_init__(self) -> None:
super().__init__()
self.packet_sequence_number = 0
async def disconnect(
self, reason: int = hci.HCI_REMOTE_USER_TERMINATED_CONNECTION_ERROR
@@ -1443,7 +1433,49 @@ class BisLink(_IsoLink):
def __post_init__(self) -> None:
self.device = self.big.device
self.packet_sequence_number = 0
# -----------------------------------------------------------------------------
class IsoPacketStream:
"""Async stream that can write SDUs to a CIS or BIS, with a maximum queue size."""
iso_link: _IsoLink
data_packet_queue: DataPacketQueue
def __init__(self, iso_link: _IsoLink, max_queue_size: int) -> None:
if iso_link.data_packet_queue is None:
raise ValueError('link has no data packet queue')
self.iso_link = iso_link
self.data_packet_queue = iso_link.data_packet_queue
self.data_packet_queue.on('flow', self._on_flow)
self._thresholds: Deque[int] = collections.deque()
self._semaphore = asyncio.Semaphore(max_queue_size)
def _on_flow(self) -> None:
# Release the semaphore once for each completed packet.
while (
self._thresholds and self.data_packet_queue.completed >= self._thresholds[0]
):
self._thresholds.popleft()
self._semaphore.release()
async def write(self, sdu: bytes) -> None:
"""
Write an SDU to the queue.
This method blocks until there are fewer than max_queue_size packets queued
but not yet completed.
"""
# Wait until there's space in the queue.
await self._semaphore.acquire()
# Queue the packet.
self.iso_link.write(sdu)
# Remember the position of the packet so we can know when it is completed.
self._thresholds.append(self.data_packet_queue.queued)
# -----------------------------------------------------------------------------
@@ -1691,6 +1723,10 @@ class Connection(CompositeEventEmitter):
self.peer_le_features = await self.device.get_remote_le_features(self)
return self.peer_le_features
@property
def data_packet_queue(self) -> DataPacketQueue | None:
return self.device.host.get_data_packet_queue(self.handle)
async def __aenter__(self):
return self
@@ -2909,7 +2945,7 @@ class Device(CompositeEventEmitter):
scanning_filter_policy=scanning_filter_policy,
scanning_phys=scanning_phys_bits,
scan_types=[scan_type] * scanning_phy_count,
scan_intervals=[int(scan_window / 0.625)] * scanning_phy_count,
scan_intervals=[int(scan_interval / 0.625)] * scanning_phy_count,
scan_windows=[int(scan_window / 0.625)] * scanning_phy_count,
),
check_result=True,

View File

@@ -42,7 +42,7 @@ from typing import (
)
from bumble.colors import color
from bumble.core import BaseBumbleError, UUID
from bumble.core import BaseBumbleError, InvalidOperationError, UUID
from bumble.att import Attribute, AttributeValue
from bumble.utils import ByteSerializable
@@ -315,6 +315,7 @@ GATT_CENTRAL_ADDRESS_RESOLUTION__CHARACTERISTIC = UUID.from_16_bi
GATT_CLIENT_SUPPORTED_FEATURES_CHARACTERISTIC = UUID.from_16_bits(0x2B29, 'Client Supported Features')
GATT_DATABASE_HASH_CHARACTERISTIC = UUID.from_16_bits(0x2B2A, 'Database Hash')
GATT_SERVER_SUPPORTED_FEATURES_CHARACTERISTIC = UUID.from_16_bits(0x2B3A, 'Server Supported Features')
GATT_LE_GATT_SECURITY_LEVELS_CHARACTERISTIC = UUID.from_16_bits(0x2BF5, 'E GATT Security Levels')
# fmt: on
# pylint: enable=line-too-long
@@ -323,8 +324,6 @@ GATT_SERVER_SUPPORTED_FEATURES_CHARACTERISTIC = UUID.from_16_bi
# -----------------------------------------------------------------------------
# Utils
# -----------------------------------------------------------------------------
def show_services(services: Iterable[Service]) -> None:
for service in services:
print(color(str(service), 'cyan'))
@@ -680,10 +679,14 @@ class DelegatedCharacteristicAdapter(CharacteristicAdapter):
self.decode = decode
def encode_value(self, value):
return self.encode(value) if self.encode else value
if self.encode is None:
raise InvalidOperationError('delegated adapter does not have an encoder')
return self.encode(value)
def decode_value(self, value):
return self.decode(value) if self.decode else value
if self.decode is None:
raise InvalidOperationError('delegate adapter does not have a decoder')
return self.decode(value)
# -----------------------------------------------------------------------------

View File

@@ -78,6 +78,7 @@ from .gatt import (
GATT_INCLUDE_ATTRIBUTE_TYPE,
Characteristic,
ClientCharacteristicConfigurationBits,
InvalidServiceError,
TemplateService,
)
@@ -162,12 +163,23 @@ class ServiceProxy(AttributeProxy):
self.uuid = uuid
self.characteristics = []
async def discover_characteristics(self, uuids=()):
async def discover_characteristics(self, uuids=()) -> list[CharacteristicProxy]:
return await self.client.discover_characteristics(uuids, self)
def get_characteristics_by_uuid(self, uuid):
def get_characteristics_by_uuid(self, uuid: UUID) -> list[CharacteristicProxy]:
"""Get all the characteristics with a specified UUID."""
return self.client.get_characteristics_by_uuid(uuid, self)
def get_required_characteristic_by_uuid(self, uuid: UUID) -> CharacteristicProxy:
"""
Get the first characteristic with a specified UUID.
If no characteristic with that UUID is found, an InvalidServiceError is raised.
"""
if not (characteristics := self.get_characteristics_by_uuid(uuid)):
raise InvalidServiceError(f'{uuid} characteristic not found')
return characteristics[0]
def __str__(self) -> str:
return f'Service(handle=0x{self.handle:04X}, uuid={self.uuid})'

View File

@@ -275,7 +275,7 @@ HCI_LE_CS_READ_REMOTE_SUPPORTED_CAPABILITIES_COMPLETE_EVENT = 0x2C
HCI_LE_CS_READ_REMOTE_FAE_TABLE_COMPLETE_EVENT = 0x2D
HCI_LE_CS_SECURITY_ENABLE_COMPLETE_EVENT = 0x2E
HCI_LE_CS_CONFIG_COMPLETE_EVENT = 0x2F
HCI_LE_CS_PROCEDURE_ENABLE_EVENT = 0x30
HCI_LE_CS_PROCEDURE_ENABLE_COMPLETE_EVENT = 0x30
HCI_LE_CS_SUBEVENT_RESULT_EVENT = 0x31
HCI_LE_CS_SUBEVENT_RESULT_CONTINUE_EVENT = 0x32
HCI_LE_CS_TEST_END_COMPLETE_EVENT = 0x33
@@ -599,7 +599,7 @@ HCI_LE_READ_ALL_LOCAL_SUPPORTED_FEATURES_COMMAND = hci_c
HCI_LE_READ_ALL_REMOTE_FEATURES_COMMAND = hci_command_op_code(0x08, 0x0088)
HCI_LE_CS_READ_LOCAL_SUPPORTED_CAPABILITIES_COMMAND = hci_command_op_code(0x08, 0x0089)
HCI_LE_CS_READ_REMOTE_SUPPORTED_CAPABILITIES_COMMAND = hci_command_op_code(0x08, 0x008A)
HCI_LE_CS_WRITE_CACHED_REMOTE_SUPPORTED_CAPABILITIES = hci_command_op_code(0x08, 0x008B)
HCI_LE_CS_WRITE_CACHED_REMOTE_SUPPORTED_CAPABILITIES_COMMAND = hci_command_op_code(0x08, 0x008B)
HCI_LE_CS_SECURITY_ENABLE_COMMAND = hci_command_op_code(0x08, 0x008C)
HCI_LE_CS_SET_DEFAULT_SETTINGS_COMMAND = hci_command_op_code(0x08, 0x008D)
HCI_LE_CS_READ_REMOTE_FAE_TABLE_COMMAND = hci_command_op_code(0x08, 0x008E)
@@ -751,6 +751,46 @@ class PhyBit(enum.IntFlag):
LE_CODED = 1 << HCI_LE_CODED_PHY_BIT
class CsRole(OpenIntEnum):
INITIATOR = 0x00
REFLECTOR = 0x01
class CsRoleMask(enum.IntFlag):
INITIATOR = 0x01
REFLECTOR = 0x02
class CsSyncPhy(OpenIntEnum):
LE_1M = 1
LE_2M = 2
LE_2M_2BT = 3
class CsSyncPhySupported(enum.IntFlag):
LE_2M = 0x01
LE_2M_2BT = 0x02
class RttType(OpenIntEnum):
AA_ONLY = 0x00
SOUNDING_SEQUENCE_32_BIT = 0x01
SOUNDING_SEQUENCE_96_BIT = 0x02
RANDOM_SEQUENCE_32_BIT = 0x03
RANDOM_SEQUENCE_64_BIT = 0x04
RANDOM_SEQUENCE_96_BIT = 0x05
RANDOM_SEQUENCE_128_BIT = 0x06
class CsSnr(OpenIntEnum):
SNR_18_DB = 0x00
SNR_21_DB = 0x01
SNR_24_DB = 0x02
SNR_27_DB = 0x03
SNR_30_DB = 0x04
NOT_APPLIED = 0xFF
# Connection Parameters
HCI_CONNECTION_INTERVAL_MS_PER_UNIT = 1.25
HCI_CONNECTION_LATENCY_MS_PER_UNIT = 1.25
@@ -971,7 +1011,7 @@ HCI_SUPPORTED_COMMANDS_MASKS = {
HCI_READ_ENCRYPTION_KEY_SIZE_COMMAND : 1 << (20*8+4),
HCI_LE_CS_READ_LOCAL_SUPPORTED_CAPABILITIES_COMMAND : 1 << (20*8+5),
HCI_LE_CS_READ_REMOTE_SUPPORTED_CAPABILITIES_COMMAND : 1 << (20*8+6),
HCI_LE_CS_WRITE_CACHED_REMOTE_SUPPORTED_CAPABILITIES : 1 << (20*8+7),
HCI_LE_CS_WRITE_CACHED_REMOTE_SUPPORTED_CAPABILITIES_COMMAND : 1 << (20*8+7),
HCI_SET_EVENT_MASK_PAGE_2_COMMAND : 1 << (22*8+2),
HCI_READ_FLOW_CONTROL_MODE_COMMAND : 1 << (23*8+0),
HCI_WRITE_FLOW_CONTROL_MODE_COMMAND : 1 << (23*8+1),
@@ -1462,6 +1502,12 @@ class LmpFeatureMask(enum.IntFlag):
# -----------------------------------------------------------------------------
# pylint: disable-next=unnecessary-lambda
STATUS_SPEC = {'size': 1, 'mapper': lambda x: HCI_Constant.status_name(x)}
CS_ROLE_SPEC = {'size': 1, 'mapper': lambda x: CsRole(x).name}
CS_ROLE_MASK_SPEC = {'size': 1, 'mapper': lambda x: CsRoleMask(x).name}
CS_SYNC_PHY_SPEC = {'size': 1, 'mapper': lambda x: CsSyncPhy(x).name}
CS_SYNC_PHY_SUPPORTED_SPEC = {'size': 1, 'mapper': lambda x: CsSyncPhySupported(x).name}
RTT_TYPE_SPEC = {'size': 1, 'mapper': lambda x: RttType(x).name}
CS_SNR_SPEC = {'size': 1, 'mapper': lambda x: CsSnr(x).name}
class CodecID(OpenIntEnum):
@@ -3539,8 +3585,8 @@ class HCI_LE_Set_Event_Mask_Command(HCI_Command):
@HCI_Command.command(
return_parameters_fields=[
('status', STATUS_SPEC),
('hc_le_acl_data_packet_length', 2),
('hc_total_num_le_acl_data_packets', 1),
('le_acl_data_packet_length', 2),
('total_num_le_acl_data_packets', 1),
]
)
class HCI_LE_Read_Buffer_Size_Command(HCI_Command):
@@ -3549,6 +3595,22 @@ class HCI_LE_Read_Buffer_Size_Command(HCI_Command):
'''
# -----------------------------------------------------------------------------
@HCI_Command.command(
return_parameters_fields=[
('status', STATUS_SPEC),
('le_acl_data_packet_length', 2),
('total_num_le_acl_data_packets', 1),
('iso_data_packet_length', 2),
('total_num_iso_data_packets', 1),
]
)
class HCI_LE_Read_Buffer_Size_V2_Command(HCI_Command):
'''
See Bluetooth spec @ 7.8.2 LE Read Buffer Size V2 Command
'''
# -----------------------------------------------------------------------------
@HCI_Command.command(
return_parameters_fields=[('status', STATUS_SPEC), ('le_features', 8)]
@@ -5059,6 +5121,275 @@ class HCI_LE_Set_Host_Feature_Command(HCI_Command):
'''
# -----------------------------------------------------------------------------
@HCI_Command.command(
return_parameters_fields=[
('status', STATUS_SPEC),
('num_config_supported', 1),
('max_consecutive_procedures_supported', 2),
('num_antennas_supported', 1),
('max_antenna_paths_supported', 1),
('roles_supported', 1),
('modes_supported', 1),
('rtt_capability', 1),
('rtt_aa_only_n', 1),
('rtt_sounding_n', 1),
('rtt_random_payload_n', 1),
('nadm_sounding_capability', 2),
('nadm_random_capability', 2),
('cs_sync_phys_supported', CS_SYNC_PHY_SUPPORTED_SPEC),
('subfeatures_supported', 2),
('t_ip1_times_supported', 2),
('t_ip2_times_supported', 2),
('t_fcs_times_supported', 2),
('t_pm_times_supported', 2),
('t_sw_time_supported', 1),
('tx_snr_capability', CS_SNR_SPEC),
]
)
class HCI_LE_CS_Read_Local_Supported_Capabilities_Command(HCI_Command):
'''
See Bluetooth spec @ 7.8.130 LE CS Read Local Supported Capabilities command
'''
# -----------------------------------------------------------------------------
@HCI_Command.command([('connection_handle', 2)])
class HCI_LE_CS_Read_Remote_Supported_Capabilities_Command(HCI_Command):
'''
See Bluetooth spec @ 7.8.131 LE CS Read Remote Supported Capabilities command
'''
# -----------------------------------------------------------------------------
@HCI_Command.command(
[
('connection_handle', 2),
('num_config_supported', 1),
('max_consecutive_procedures_supported', 2),
('num_antennas_supported', 1),
('max_antenna_paths_supported', 1),
('roles_supported', 1),
('modes_supported', 1),
('rtt_capability', 1),
('rtt_aa_only_n', 1),
('rtt_sounding_n', 1),
('rtt_random_payload_n', 1),
('nadm_sounding_capability', 2),
('nadm_random_capability', 2),
('cs_sync_phys_supported', CS_SYNC_PHY_SUPPORTED_SPEC),
('subfeatures_supported', 2),
('t_ip1_times_supported', 2),
('t_ip2_times_supported', 2),
('t_fcs_times_supported', 2),
('t_pm_times_supported', 2),
('t_sw_time_supported', 1),
('tx_snr_capability', CS_SNR_SPEC),
],
return_parameters_fields=[
('status', STATUS_SPEC),
('connection_handle', 2),
],
)
class HCI_LE_CS_Write_Cached_Remote_Supported_Capabilities_Command(HCI_Command):
'''
See Bluetooth spec @ 7.8.132 LE CS Write Cached Remote Supported Capabilities command
'''
# -----------------------------------------------------------------------------
@HCI_Command.command([('connection_handle', 2)])
class HCI_LE_CS_Security_Enable_Command(HCI_Command):
'''
See Bluetooth spec @ 7.8.133 LE CS Security Enable command
'''
# -----------------------------------------------------------------------------
@HCI_Command.command(
[
('connection_handle', 2),
(
'role_enable',
CS_ROLE_MASK_SPEC,
),
('cs_sync_antenna_selection', 1),
('max_tx_power', 1),
],
return_parameters_fields=[('status', STATUS_SPEC), ('connection_handle', 2)],
)
class HCI_LE_CS_Set_Default_Settings_Command(HCI_Command):
'''
See Bluetooth spec @ 7.8.134 LE CS Security Enable command
'''
# -----------------------------------------------------------------------------
@HCI_Command.command([('connection_handle', 2)])
class HCI_LE_CS_Read_Remote_FAE_Table_Command(HCI_Command):
'''
See Bluetooth spec @ 7.8.135 LE CS Read Remote FAE Table command
'''
# -----------------------------------------------------------------------------
@HCI_Command.command(
[
('connection_handle', 2),
('remote_fae_table', 72),
],
return_parameters_fields=[('status', STATUS_SPEC), ('connection_handle', 2)],
)
class HCI_LE_CS_Write_Cached_Remote_FAE_Table_Command(HCI_Command):
'''
See Bluetooth spec @ 7.8.136 LE CS Write Cached Remote FAE Table command
'''
# -----------------------------------------------------------------------------
@HCI_Command.command(
[
('connection_handle', 2),
('config_id', 1),
('create_context', 1),
('main_mode_type', 1),
('sub_mode_type', 1),
('min_main_mode_steps', 1),
('max_main_mode_steps', 1),
('main_mode_repetition', 1),
('mode_0_steps', 1),
('role', CS_ROLE_SPEC),
('rtt_type', RTT_TYPE_SPEC),
('cs_sync_phy', CS_SYNC_PHY_SPEC),
('channel_map', 10),
('channel_map_repetition', 1),
('channel_selection_type', 1),
('ch3c_shape', 1),
('ch3c_jump', 1),
('reserved', 1),
],
)
class HCI_LE_CS_Create_Config_Command(HCI_Command):
'''
See Bluetooth spec @ 7.8.137 LE CS Create Config command
'''
class ChannelSelectionType(OpenIntEnum):
ALGO_3B = 0
ALGO_3C = 1
class Ch3cShape(OpenIntEnum):
HAT = 0x00
X = 0x01
# -----------------------------------------------------------------------------
@HCI_Command.command(
[
('connection_handle', 2),
('config_id', 1),
],
)
class HCI_LE_CS_Remove_Config_Command(HCI_Command):
'''
See Bluetooth spec @ 7.8.138 LE CS Remove Config command
'''
# -----------------------------------------------------------------------------
@HCI_Command.command(
[('channel_classification', 10)], return_parameters_fields=[('status', STATUS_SPEC)]
)
class HCI_LE_CS_Set_Channel_Classification_Command(HCI_Command):
'''
See Bluetooth spec @ 7.8.139 LE CS Set Channel Classification command
'''
# -----------------------------------------------------------------------------
@HCI_Command.command(
[
('connection_handle', 2),
('config_id', 1),
('max_procedure_len', 2),
('min_procedure_interval', 2),
('max_procedure_interval', 2),
('max_procedure_count', 2),
('min_subevent_len', 3),
('max_subevent_len', 3),
('tone_antenna_config_selection', 1),
('phy', 1),
('tx_power_delta', 1),
('preferred_peer_antenna', 1),
('snr_control_initiator', CS_SNR_SPEC),
('snr_control_reflector', CS_SNR_SPEC),
],
return_parameters_fields=[('status', STATUS_SPEC), ('connection_handle', 2)],
)
class HCI_LE_CS_Set_Procedure_Parameters_Command(HCI_Command):
'''
See Bluetooth spec @ 7.8.140 LE CS Set Procedure Parameters command
'''
# -----------------------------------------------------------------------------
@HCI_Command.command(
[
('connection_handle', 2),
('config_id', 1),
('enable', 1),
],
)
class HCI_LE_CS_Procedure_Enable_Command(HCI_Command):
'''
See Bluetooth spec @ 7.8.141 LE CS Procedure Enable command
'''
# -----------------------------------------------------------------------------
@HCI_Command.command(
[
('main_mode_type', 1),
('sub_mode_type', 1),
('main_mode_repetition', 1),
('mode_0_steps', 1),
('role', CS_ROLE_SPEC),
('rtt_type', RTT_TYPE_SPEC),
('cs_sync_phy', CS_SYNC_PHY_SPEC),
('cs_sync_antenna_selection', 1),
('subevent_len', 3),
('subevent_interval', 2),
('max_num_subevents', 1),
('transmit_power_level', 1),
('t_ip1_time', 1),
('t_ip2_time', 1),
('t_fcs_time', 1),
('t_pm_time', 1),
('t_sw_time', 1),
('tone_antenna_config_selection', 1),
('reserved', 1),
('snr_control_initiator', CS_SNR_SPEC),
('snr_control_reflector', CS_SNR_SPEC),
('drbg_nonce', 2),
('channel_map_repetition', 1),
('override_config', 2),
('override_parameters_data', 'v'),
],
)
class HCI_LE_CS_Test_Command(HCI_Command):
'''
See Bluetooth spec @ 7.8.142 LE CS Test command
'''
# -----------------------------------------------------------------------------
@HCI_Command.command()
class HCI_LE_CS_Test_End_Command(HCI_Command):
'''
See Bluetooth spec @ 7.8.143 LE CS Test End command
'''
# -----------------------------------------------------------------------------
# HCI Events
# -----------------------------------------------------------------------------
@@ -6009,6 +6340,291 @@ class HCI_LE_BIGInfo_Advertising_Report_Event(HCI_LE_Meta_Event):
'''
# -----------------------------------------------------------------------------
@HCI_LE_Meta_Event.event(
[
('status', STATUS_SPEC),
('connection_handle', 2),
('num_config_supported', 1),
('max_consecutive_procedures_supported', 2),
('num_antennas_supported', 1),
('max_antenna_paths_supported', 1),
('roles_supported', 1),
('modes_supported', 1),
('rtt_capability', 1),
('rtt_aa_only_n', 1),
('rtt_sounding_n', 1),
('rtt_random_payload_n', 1),
('nadm_sounding_capability', 2),
('nadm_random_capability', 2),
('cs_sync_phys_supported', CS_SYNC_PHY_SUPPORTED_SPEC),
('subfeatures_supported', 2),
('t_ip1_times_supported', 2),
('t_ip2_times_supported', 2),
('t_fcs_times_supported', 2),
('t_pm_times_supported', 2),
('t_sw_time_supported', 1),
('tx_snr_capability', CS_SNR_SPEC),
]
)
class HCI_LE_CS_Read_Remote_Supported_Capabilities_Complete_Event(HCI_LE_Meta_Event):
'''
See Bluetooth spec @ 7.7.65.39 LE CS Read Remote Supported Capabilities Complete event
'''
status: int
connection_handle: int
num_config_supported: int
max_consecutive_procedures_supported: int
num_antennas_supported: int
max_antenna_paths_supported: int
roles_supported: int
modes_supported: int
rtt_capability: int
rtt_aa_only_n: int
rtt_sounding_n: int
rtt_random_payload_n: int
nadm_sounding_capability: int
nadm_random_capability: int
cs_sync_phys_supported: int
subfeatures_supported: int
t_ip1_times_supported: int
t_ip2_times_supported: int
t_fcs_times_supported: int
t_pm_times_supported: int
t_sw_time_supported: int
tx_snr_capability: int
# -----------------------------------------------------------------------------
@HCI_LE_Meta_Event.event(
[
('status', STATUS_SPEC),
('connection_handle', 2),
('remote_fae_table', 72),
]
)
class HCI_LE_CS_Read_Remote_FAE_Table_Complete_Event(HCI_LE_Meta_Event):
'''
See Bluetooth spec @ 7.7.65.40 LE CS Read Remote FAE Table Complete event
'''
status: int
connection_handle: int
remote_fae_table: bytes
# -----------------------------------------------------------------------------
@HCI_LE_Meta_Event.event(
[
('status', STATUS_SPEC),
('connection_handle', 2),
]
)
class HCI_LE_CS_Security_Enable_Complete_Event(HCI_LE_Meta_Event):
'''
See Bluetooth spec @ 7.7.65.41 LE CS Security Enable Complete event
'''
status: int
connection_handle: int
# -----------------------------------------------------------------------------
@HCI_LE_Meta_Event.event(
[
('status', STATUS_SPEC),
('connection_handle', 2),
('config_id', 1),
(
'action',
{
'size': 1,
'mapper': lambda x: HCI_LE_CS_Config_Complete_Event.Action(x).name,
},
),
('main_mode_type', 1),
('sub_mode_type', 1),
('min_main_mode_steps', 1),
('max_main_mode_steps', 1),
('main_mode_repetition', 1),
('mode_0_steps', 1),
('role', CS_ROLE_SPEC),
('rtt_type', RTT_TYPE_SPEC),
('cs_sync_phy', CS_SYNC_PHY_SPEC),
('channel_map', 10),
('channel_map_repetition', 1),
('channel_selection_type', 1),
('ch3c_shape', 1),
('ch3c_jump', 1),
('reserved', 1),
('t_ip1_time', 1),
('t_ip2_time', 1),
('t_fcs_time', 1),
('t_pm_time', 1),
]
)
class HCI_LE_CS_Config_Complete_Event(HCI_LE_Meta_Event):
'''
See Bluetooth spec @ 7.7.65.42 LE CS Config Complete event
'''
class Action(OpenIntEnum):
REMOVED = 0
CREATED = 1
status: int
connection_handle: int
config_id: int
action: int
main_mode_type: int
sub_mode_type: int
min_main_mode_steps: int
max_main_mode_steps: int
main_mode_repetition: int
mode_0_steps: int
role: int
rtt_type: int
cs_sync_phy: int
channel_map: bytes
channel_map_repetition: int
channel_selection_type: int
ch3c_shape: int
ch3c_jump: int
reserved: int
t_ip1_time: int
t_ip2_time: int
t_fcs_time: int
t_pm_time: int
# -----------------------------------------------------------------------------
@HCI_LE_Meta_Event.event(
[
('status', STATUS_SPEC),
('connection_handle', 2),
('config_id', 1),
('state', 1),
('tone_antenna_config_selection', 1),
('selected_tx_power', 1),
('subevent_len', 3),
('subevents_per_event', 1),
('subevent_interval', 2),
('event_interval', 2),
('procedure_interval', 2),
('procedure_count', 2),
('max_procedure_len', 2),
]
)
class HCI_LE_CS_Procedure_Enable_Complete_Event(HCI_LE_Meta_Event):
'''
See Bluetooth spec @ 7.7.65.43 LE CS Procedure Enable Complete event
'''
class State(OpenIntEnum):
DISABLED = 0
ENABLED = 1
status: int
connection_handle: int
config_id: int
state: int
tone_antenna_config_selection: int
selected_tx_power: int
subevent_len: int
subevents_per_event: int
subevent_interval: int
event_interval: int
procedure_interval: int
procedure_count: int
max_procedure_len: int
# -----------------------------------------------------------------------------
@HCI_LE_Meta_Event.event(
[
('connection_handle', 2),
('config_id', 1),
('start_acl_conn_event_counter', 2),
('procedure_counter', 2),
('frequency_compensation', 2),
('reference_power_level', 1),
('procedure_done_status', 1),
('subevent_done_status', 1),
('abort_reason', 1),
('num_antenna_paths', 1),
[
('step_mode', 1),
('step_channel', 1),
('step_data', 'v'),
],
]
)
class HCI_LE_CS_Subevent_Result_Event(HCI_LE_Meta_Event):
'''
See Bluetooth spec @ 7.7.65.44 LE CS Subevent Result event
'''
status: int
config_id: int
start_acl_conn_event_counter: int
procedure_counter: int
frequency_compensation: int
reference_power_level: int
procedure_done_status: int
subevent_done_status: int
abort_reason: int
num_antenna_paths: int
step_mode: list[int]
step_channel: list[int]
step_data: list[bytes]
# -----------------------------------------------------------------------------
@HCI_LE_Meta_Event.event(
[
('connection_handle', 2),
('config_id', 1),
('procedure_done_status', 1),
('subevent_done_status', 1),
('abort_reason', 1),
('num_antenna_paths', 1),
[
('step_mode', 1),
('step_channel', 1),
('step_data', 'v'),
],
]
)
class HCI_LE_CS_Subevent_Result_Continue_Event(HCI_LE_Meta_Event):
'''
See Bluetooth spec @ 7.7.65.45 LE CS Subevent Result Continue event
'''
status: int
config_id: int
procedure_done_status: int
subevent_done_status: int
abort_reason: int
num_antenna_paths: int
step_mode: list[int]
step_channel: list[int]
step_data: list[bytes]
# -----------------------------------------------------------------------------
@HCI_LE_Meta_Event.event(
[
('connection_handle', 2),
('status', STATUS_SPEC),
]
)
class HCI_LE_CS_Test_End_Complete_Event(HCI_LE_Meta_Event):
'''
See Bluetooth spec @ 7.7.65.46 LE CS Test End Complete event
'''
# -----------------------------------------------------------------------------
@HCI_Event.event([('status', STATUS_SPEC)])
class HCI_Inquiry_Complete_Event(HCI_Event):
@@ -6955,7 +7571,7 @@ class HCI_IsoDataPacket(HCI_Packet):
if should_include_sdu_info:
packet_sequence_number, sdu_info = struct.unpack_from('<HH', packet, pos)
iso_sdu_length = sdu_info & 0xFFF
packet_status_flag = sdu_info >> 14
packet_status_flag = (sdu_info >> 15) & 1
pos += 4
iso_sdu_fragment = packet[pos:]
@@ -6989,7 +7605,7 @@ class HCI_IsoDataPacket(HCI_Packet):
fmt += 'HH'
args += [
self.packet_sequence_number,
self.iso_sdu_length | self.packet_status_flag << 14,
self.iso_sdu_length | self.packet_status_flag << 15,
]
return struct.pack(fmt, *args) + self.iso_sdu_fragment
@@ -6997,9 +7613,10 @@ class HCI_IsoDataPacket(HCI_Packet):
return (
f'{color("ISO", "blue")}: '
f'handle=0x{self.connection_handle:04x}, '
f'pb={self.pb_flag}, '
f'ps={self.packet_status_flag}, '
f'data_total_length={self.data_total_length}, '
f'sdu={self.iso_sdu_fragment.hex()}'
f'sdu_fragment={self.iso_sdu_fragment.hex()}'
)

View File

@@ -1,4 +1,4 @@
# Copyright 2021-2022 Google LLC
# Copyright 2021-2025 Google LLC
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
@@ -21,7 +21,6 @@ import collections
import dataclasses
import logging
import struct
import itertools
from typing import (
Any,
@@ -35,6 +34,8 @@ from typing import (
TYPE_CHECKING,
)
import pyee
from bumble.colors import color
from bumble.l2cap import L2CAP_PDU
from bumble.snoop import Snooper
@@ -60,7 +61,19 @@ logger = logging.getLogger(__name__)
# -----------------------------------------------------------------------------
class AclPacketQueue:
class DataPacketQueue(pyee.EventEmitter):
"""
Flow-control queue for host->controller data packets (ACL, ISO).
The queue holds packets associated with a connection handle. The packets
are sent to the controller, up to a maximum total number of packets in flight.
A packet is considered to be "in flight" when it has been sent to the controller
but not completed yet. Packets are no longer "in flight" when the controller
declares them as completed.
The queue emits a 'flow' event whenever one or more packets are completed.
"""
max_packet_size: int
def __init__(
@@ -69,40 +82,105 @@ class AclPacketQueue:
max_in_flight: int,
send: Callable[[hci.HCI_Packet], None],
) -> None:
super().__init__()
self.max_packet_size = max_packet_size
self.max_in_flight = max_in_flight
self.in_flight = 0
self.send = send
self.packets: Deque[hci.HCI_AclDataPacket] = collections.deque()
self._in_flight = 0 # Total number of packets in flight across all connections
self._in_flight_per_connection: dict[int, int] = collections.defaultdict(
int
) # Number of packets in flight per connection
self._send = send
self._packets: Deque[tuple[hci.HCI_Packet, int]] = collections.deque()
self._queued = 0
self._completed = 0
def enqueue(self, packet: hci.HCI_AclDataPacket) -> None:
self.packets.appendleft(packet)
self.check_queue()
@property
def queued(self) -> int:
"""Total number of packets queued since creation."""
return self._queued
if self.packets:
@property
def completed(self) -> int:
"""Total number of packets completed since creation."""
return self._completed
@property
def pending(self) -> int:
"""Number of packets that have been queued but not completed."""
return self._queued - self._completed
def enqueue(self, packet: hci.HCI_Packet, connection_handle: int) -> None:
"""Enqueue a packet associated with a connection"""
self._packets.appendleft((packet, connection_handle))
self._queued += 1
self._check_queue()
if self._packets:
logger.debug(
f'{self.in_flight} ACL packets in flight, '
f'{len(self.packets)} in queue'
f'{self._in_flight} packets in flight, '
f'{len(self._packets)} in queue'
)
def check_queue(self) -> None:
while self.packets and self.in_flight < self.max_in_flight:
packet = self.packets.pop()
self.send(packet)
self.in_flight += 1
def flush(self, connection_handle: int) -> None:
"""
Remove all packets associated with a connection.
def on_packets_completed(self, packet_count: int) -> None:
if packet_count > self.in_flight:
All packets associated with the connection that are in flight are implicitly
marked as completed, but no 'flow' event is emitted.
"""
packets_to_keep = [
(packet, handle)
for (packet, handle) in self._packets
if handle != connection_handle
]
if flushed_count := len(self._packets) - len(packets_to_keep):
self._completed += flushed_count
self._packets = collections.deque(packets_to_keep)
if connection_handle in self._in_flight_per_connection:
in_flight = self._in_flight_per_connection[connection_handle]
self._completed += in_flight
self._in_flight -= in_flight
del self._in_flight_per_connection[connection_handle]
def _check_queue(self) -> None:
while self._packets and self._in_flight < self.max_in_flight:
packet, connection_handle = self._packets.pop()
self._send(packet)
self._in_flight += 1
self._in_flight_per_connection[connection_handle] += 1
def on_packets_completed(self, packet_count: int, connection_handle: int) -> None:
"""Mark one or more packets associated with a connection as completed."""
if connection_handle not in self._in_flight_per_connection:
logger.warning(
color(
'!!! {packet_count} completed but only '
f'{self.in_flight} in flight'
)
f'received completion for unknown connection {connection_handle}'
)
packet_count = self.in_flight
return
self.in_flight -= packet_count
self.check_queue()
in_flight_for_connection = self._in_flight_per_connection[connection_handle]
if packet_count <= in_flight_for_connection:
self._in_flight_per_connection[connection_handle] -= packet_count
else:
logger.warning(
f'{packet_count} completed for {connection_handle} '
f'but only {in_flight_for_connection} in flight'
)
self._in_flight_per_connection[connection_handle] = 0
if packet_count <= self._in_flight:
self._in_flight -= packet_count
self._completed += packet_count
else:
logger.warning(
f'{packet_count} completed but only {self._in_flight} in flight'
)
self._in_flight = 0
self._completed = self._queued
self._check_queue()
self.emit('flow')
# -----------------------------------------------------------------------------
@@ -115,7 +193,7 @@ class Connection:
self.peer_address = peer_address
self.assembler = hci.HCI_AclDataPacketAssembler(self.on_acl_pdu)
self.transport = transport
acl_packet_queue: Optional[AclPacketQueue] = (
acl_packet_queue: Optional[DataPacketQueue] = (
host.le_acl_packet_queue
if transport == BT_LE_TRANSPORT
else host.acl_packet_queue
@@ -130,29 +208,37 @@ class Connection:
l2cap_pdu = L2CAP_PDU.from_bytes(pdu)
self.host.on_l2cap_pdu(self, l2cap_pdu.cid, l2cap_pdu.payload)
def __str__(self) -> str:
return (
f'Connection(transport={self.transport}, peer_address={self.peer_address})'
)
# -----------------------------------------------------------------------------
@dataclasses.dataclass
class ScoLink:
peer_address: hci.Address
handle: int
connection_handle: int
# -----------------------------------------------------------------------------
@dataclasses.dataclass
class CisLink:
peer_address: hci.Address
class IsoLink:
handle: int
packet_queue: DataPacketQueue = dataclasses.field(repr=False)
packet_sequence_number: int = 0
# -----------------------------------------------------------------------------
class Host(AbortableEventEmitter):
connections: Dict[int, Connection]
cis_links: Dict[int, CisLink]
cis_links: Dict[int, IsoLink]
bis_links: Dict[int, IsoLink]
sco_links: Dict[int, ScoLink]
bigs: dict[int, set[int]] = {} # BIG Handle to BIS Handles
acl_packet_queue: Optional[AclPacketQueue] = None
le_acl_packet_queue: Optional[AclPacketQueue] = None
acl_packet_queue: Optional[DataPacketQueue] = None
le_acl_packet_queue: Optional[DataPacketQueue] = None
iso_packet_queue: Optional[DataPacketQueue] = None
hci_sink: Optional[TransportSink] = None
hci_metadata: Dict[str, Any]
long_term_key_provider: Optional[
@@ -171,6 +257,7 @@ class Host(AbortableEventEmitter):
self.ready = False # True when we can accept incoming packets
self.connections = {} # Connections, by connection handle
self.cis_links = {} # CIS links, by connection handle
self.bis_links = {} # BIS links, by connection handle
self.sco_links = {} # SCO links, by connection handle
self.pending_command = None
self.pending_response: Optional[asyncio.Future[Any]] = None
@@ -413,39 +500,70 @@ class Host(AbortableEventEmitter):
f'hc_total_num_acl_data_packets={hc_total_num_acl_data_packets}'
)
self.acl_packet_queue = AclPacketQueue(
self.acl_packet_queue = DataPacketQueue(
max_packet_size=hc_acl_data_packet_length,
max_in_flight=hc_total_num_acl_data_packets,
send=self.send_hci_packet,
)
hc_le_acl_data_packet_length = 0
hc_total_num_le_acl_data_packets = 0
if self.supports_command(hci.HCI_LE_READ_BUFFER_SIZE_COMMAND):
le_acl_data_packet_length = 0
total_num_le_acl_data_packets = 0
iso_data_packet_length = 0
total_num_iso_data_packets = 0
if self.supports_command(hci.HCI_LE_READ_BUFFER_SIZE_V2_COMMAND):
response = await self.send_command(
hci.HCI_LE_Read_Buffer_Size_V2_Command(), check_result=True
)
le_acl_data_packet_length = (
response.return_parameters.le_acl_data_packet_length
)
total_num_le_acl_data_packets = (
response.return_parameters.total_num_le_acl_data_packets
)
iso_data_packet_length = response.return_parameters.iso_data_packet_length
total_num_iso_data_packets = (
response.return_parameters.total_num_iso_data_packets
)
logger.debug(
'HCI LE flow control: '
f'le_acl_data_packet_length={le_acl_data_packet_length},'
f'total_num_le_acl_data_packets={total_num_le_acl_data_packets}'
f'iso_data_packet_length={iso_data_packet_length},'
f'total_num_iso_data_packets={total_num_iso_data_packets}'
)
elif self.supports_command(hci.HCI_LE_READ_BUFFER_SIZE_COMMAND):
response = await self.send_command(
hci.HCI_LE_Read_Buffer_Size_Command(), check_result=True
)
hc_le_acl_data_packet_length = (
response.return_parameters.hc_le_acl_data_packet_length
le_acl_data_packet_length = (
response.return_parameters.le_acl_data_packet_length
)
hc_total_num_le_acl_data_packets = (
response.return_parameters.hc_total_num_le_acl_data_packets
total_num_le_acl_data_packets = (
response.return_parameters.total_num_le_acl_data_packets
)
logger.debug(
'HCI LE ACL flow control: '
f'hc_le_acl_data_packet_length={hc_le_acl_data_packet_length},'
f'hc_total_num_le_acl_data_packets={hc_total_num_le_acl_data_packets}'
f'le_acl_data_packet_length={le_acl_data_packet_length},'
f'total_num_le_acl_data_packets={total_num_le_acl_data_packets}'
)
if hc_le_acl_data_packet_length == 0 or hc_total_num_le_acl_data_packets == 0:
if le_acl_data_packet_length == 0 or total_num_le_acl_data_packets == 0:
# LE and Classic share the same queue
self.le_acl_packet_queue = self.acl_packet_queue
else:
# Create a separate queue for LE
self.le_acl_packet_queue = AclPacketQueue(
max_packet_size=hc_le_acl_data_packet_length,
max_in_flight=hc_total_num_le_acl_data_packets,
self.le_acl_packet_queue = DataPacketQueue(
max_packet_size=le_acl_data_packet_length,
max_in_flight=total_num_le_acl_data_packets,
send=self.send_hci_packet,
)
if iso_data_packet_length and total_num_iso_data_packets:
self.iso_packet_queue = DataPacketQueue(
max_packet_size=iso_data_packet_length,
max_in_flight=total_num_iso_data_packets,
send=self.send_hci_packet,
)
@@ -597,11 +715,78 @@ class Host(AbortableEventEmitter):
data=l2cap_pdu[offset : offset + data_total_length],
)
logger.debug(f'>>> ACL packet enqueue: (CID={cid}) {acl_packet}')
packet_queue.enqueue(acl_packet)
packet_queue.enqueue(acl_packet, connection_handle)
pb_flag = 1
offset += data_total_length
bytes_remaining -= data_total_length
def get_data_packet_queue(self, connection_handle: int) -> DataPacketQueue | None:
if connection := self.connections.get(connection_handle):
return connection.acl_packet_queue
if iso_link := self.cis_links.get(connection_handle) or self.bis_links.get(
connection_handle
):
return iso_link.packet_queue
return None
def send_iso_sdu(self, connection_handle: int, sdu: bytes) -> None:
if not (
iso_link := self.cis_links.get(connection_handle)
or self.bis_links.get(connection_handle)
):
logger.warning(f"no ISO link for connection handle {connection_handle}")
return
if iso_link.packet_queue is None:
logger.warning("ISO link has no data packet queue")
return
bytes_remaining = len(sdu)
offset = 0
while bytes_remaining:
is_first_fragment = offset == 0
header_length = 4 if is_first_fragment else 0
assert iso_link.packet_queue.max_packet_size > header_length
fragment_length = min(
bytes_remaining, iso_link.packet_queue.max_packet_size - header_length
)
is_last_fragment = bytes_remaining == fragment_length
iso_sdu_fragment = sdu[offset : offset + fragment_length]
iso_link.packet_queue.enqueue(
(
hci.HCI_IsoDataPacket(
connection_handle=connection_handle,
data_total_length=header_length + fragment_length,
packet_sequence_number=iso_link.packet_sequence_number,
pb_flag=0b10 if is_last_fragment else 0b00,
packet_status_flag=0,
iso_sdu_length=len(sdu),
iso_sdu_fragment=iso_sdu_fragment,
)
if is_first_fragment
else hci.HCI_IsoDataPacket(
connection_handle=connection_handle,
data_total_length=fragment_length,
pb_flag=0b11 if is_last_fragment else 0b01,
iso_sdu_fragment=iso_sdu_fragment,
)
),
connection_handle,
)
offset += fragment_length
bytes_remaining -= fragment_length
iso_link.packet_sequence_number = (iso_link.packet_sequence_number + 1) & 0xFFFF
def remove_big(self, big_handle: int) -> None:
if big := self.bigs.pop(big_handle, None):
for connection_handle in big:
if bis_link := self.bis_links.pop(connection_handle, None):
bis_link.packet_queue.flush(bis_link.handle)
def supports_command(self, op_code: int) -> bool:
return (
self.local_supported_commands
@@ -729,17 +914,17 @@ class Host(AbortableEventEmitter):
def on_hci_command_status_event(self, event):
return self.on_command_processed(event)
def on_hci_number_of_completed_packets_event(self, event):
def on_hci_number_of_completed_packets_event(
self, event: hci.HCI_Number_Of_Completed_Packets_Event
) -> None:
for connection_handle, num_completed_packets in zip(
event.connection_handles, event.num_completed_packets
):
if connection := self.connections.get(connection_handle):
connection.acl_packet_queue.on_packets_completed(num_completed_packets)
elif connection_handle not in itertools.chain(
self.cis_links.keys(),
self.sco_links.keys(),
itertools.chain.from_iterable(self.bigs.values()),
):
if queue := self.get_data_packet_queue(connection_handle):
queue.on_packets_completed(num_completed_packets, connection_handle)
continue
if connection_handle not in self.sco_links:
logger.warning(
'received packet completion event for unknown handle '
f'0x{connection_handle:04X}'
@@ -857,11 +1042,7 @@ class Host(AbortableEventEmitter):
return
if event.status == hci.HCI_SUCCESS:
logger.debug(
f'### DISCONNECTION: [0x{handle:04X}] '
f'{connection.peer_address} '
f'reason={event.reason}'
)
logger.debug(f'### DISCONNECTION: {connection}, reason={event.reason}')
# Notify the listeners
self.emit('disconnection', handle, event.reason)
@@ -872,6 +1053,12 @@ class Host(AbortableEventEmitter):
or self.cis_links.pop(handle, 0)
or self.sco_links.pop(handle, 0)
)
# Flush the data queues
self.acl_packet_queue.flush(handle)
self.le_acl_packet_queue.flush(handle)
if self.iso_packet_queue:
self.iso_packet_queue.flush(handle)
else:
logger.debug(f'### DISCONNECTION FAILED: {event.status}')
@@ -958,6 +1145,14 @@ class Host(AbortableEventEmitter):
def on_hci_le_create_big_complete_event(self, event):
self.bigs[event.big_handle] = set(event.connection_handle)
if self.iso_packet_queue is None:
logger.warning("BIS established but ISO packets not supported")
for connection_handle in event.connection_handle:
self.bis_links[connection_handle] = IsoLink(
connection_handle, self.iso_packet_queue
)
self.emit(
'big_establishment',
event.status,
@@ -975,6 +1170,12 @@ class Host(AbortableEventEmitter):
)
def on_hci_le_big_sync_established_event(self, event):
self.bigs[event.big_handle] = set(event.connection_handle)
for connection_handle in event.connection_handle:
self.bis_links[connection_handle] = IsoLink(
connection_handle, self.iso_packet_queue
)
self.emit(
'big_sync_establishment',
event.status,
@@ -990,22 +1191,20 @@ class Host(AbortableEventEmitter):
)
def on_hci_le_big_sync_lost_event(self, event):
self.emit(
'big_sync_lost',
event.big_handle,
event.reason,
)
self.remove_big(event.big_handle)
self.emit('big_sync_lost', event.big_handle, event.reason)
def on_hci_le_terminate_big_complete_event(self, event):
self.bigs.pop(event.big_handle)
self.remove_big(event.big_handle)
self.emit('big_termination', event.reason, event.big_handle)
def on_hci_le_cis_established_event(self, event):
# The remaining parameters are unused for now.
if event.status == hci.HCI_SUCCESS:
self.cis_links[event.connection_handle] = CisLink(
handle=event.connection_handle,
peer_address=hci.Address.ANY,
if self.iso_packet_queue is None:
logger.warning("CIS established but ISO packets not supported")
self.cis_links[event.connection_handle] = IsoLink(
handle=event.connection_handle, packet_queue=self.iso_packet_queue
)
self.emit('cis_establishment', event.connection_handle)
else:
@@ -1075,7 +1274,7 @@ class Host(AbortableEventEmitter):
self.sco_links[event.connection_handle] = ScoLink(
peer_address=event.bd_addr,
handle=event.connection_handle,
connection_handle=event.connection_handle,
)
# Notify the client

View File

@@ -773,7 +773,6 @@ class ClassicChannel(EventEmitter):
self.psm = psm
self.source_cid = source_cid
self.destination_cid = 0
self.response = None
self.connection_result = None
self.disconnection_result = None
self.sink = None
@@ -783,27 +782,15 @@ class ClassicChannel(EventEmitter):
self.state = new_state
def send_pdu(self, pdu: Union[SupportsBytes, bytes]) -> None:
if self.state != self.State.OPEN:
raise InvalidStateError('channel not open')
self.manager.send_pdu(self.connection, self.destination_cid, pdu)
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: SupportsBytes) -> bytes:
# Check that there isn't already a request pending
if self.response:
raise InvalidStateError('request already pending')
if self.state != self.State.OPEN:
raise InvalidStateError('channel not open')
self.response = asyncio.get_running_loop().create_future()
self.send_pdu(request)
return await self.response
def on_pdu(self, pdu: bytes) -> None:
if self.response:
self.response.set_result(pdu)
self.response = None
elif self.sink:
if self.sink:
# pylint: disable=not-callable
self.sink(pdu)
else:

View File

@@ -451,54 +451,35 @@ class AICSServiceProxy(ProfileServiceProxy):
def __init__(self, service_proxy: ServiceProxy) -> None:
self.service_proxy = service_proxy
if not (
characteristics := service_proxy.get_characteristics_by_uuid(
GATT_AUDIO_INPUT_STATE_CHARACTERISTIC
)
):
raise gatt.InvalidServiceError("Audio Input State Characteristic not found")
self.audio_input_state = SerializableCharacteristicAdapter(
characteristics[0], AudioInputState
service_proxy.get_required_characteristic_by_uuid(
GATT_AUDIO_INPUT_STATE_CHARACTERISTIC
),
AudioInputState,
)
if not (
characteristics := service_proxy.get_characteristics_by_uuid(
GATT_GAIN_SETTINGS_ATTRIBUTE_CHARACTERISTIC
)
):
raise gatt.InvalidServiceError(
"Gain Settings Attribute Characteristic not found"
)
self.gain_settings_properties = SerializableCharacteristicAdapter(
characteristics[0], GainSettingsProperties
service_proxy.get_required_characteristic_by_uuid(
GATT_GAIN_SETTINGS_ATTRIBUTE_CHARACTERISTIC
),
GainSettingsProperties,
)
if not (
characteristics := service_proxy.get_characteristics_by_uuid(
self.audio_input_status = PackedCharacteristicAdapter(
service_proxy.get_required_characteristic_by_uuid(
GATT_AUDIO_INPUT_STATUS_CHARACTERISTIC
)
):
raise gatt.InvalidServiceError(
"Audio Input Status Characteristic not found"
)
self.audio_input_status = PackedCharacteristicAdapter(characteristics[0], 'B')
),
'B',
)
if not (
characteristics := service_proxy.get_characteristics_by_uuid(
self.audio_input_control_point = (
service_proxy.get_required_characteristic_by_uuid(
GATT_AUDIO_INPUT_CONTROL_POINT_CHARACTERISTIC
)
):
raise gatt.InvalidServiceError(
"Audio Input Control Point Characteristic not found"
)
self.audio_input_control_point = characteristics[0]
)
if not (
characteristics := service_proxy.get_characteristics_by_uuid(
self.audio_input_description = UTF8CharacteristicAdapter(
service_proxy.get_required_characteristic_by_uuid(
GATT_AUDIO_INPUT_DESCRIPTION_CHARACTERISTIC
)
):
raise gatt.InvalidServiceError(
"Audio Input Description Characteristic not found"
)
self.audio_input_description = UTF8CharacteristicAdapter(characteristics[0])
)

View File

@@ -288,8 +288,8 @@ class AshaServiceProxy(gatt_client.ProfileServiceProxy):
'psm_characteristic',
),
):
if not (
characteristics := self.service_proxy.get_characteristics_by_uuid(uuid)
):
raise gatt.InvalidServiceError(f"Missing {uuid} Characteristic")
setattr(self, attribute_name, characteristics[0])
setattr(
self,
attribute_name,
self.service_proxy.get_required_characteristic_by_uuid(uuid),
)

View File

@@ -354,34 +354,25 @@ class BroadcastAudioScanServiceProxy(gatt_client.ProfileServiceProxy):
SERVICE_CLASS = BroadcastAudioScanService
broadcast_audio_scan_control_point: gatt_client.CharacteristicProxy
broadcast_receive_states: List[gatt.SerializableCharacteristicAdapter]
broadcast_receive_states: List[gatt.DelegatedCharacteristicAdapter]
def __init__(self, service_proxy: gatt_client.ServiceProxy):
self.service_proxy = service_proxy
if not (
characteristics := service_proxy.get_characteristics_by_uuid(
self.broadcast_audio_scan_control_point = (
service_proxy.get_required_characteristic_by_uuid(
gatt.GATT_BROADCAST_AUDIO_SCAN_CONTROL_POINT_CHARACTERISTIC
)
):
raise gatt.InvalidServiceError(
"Broadcast Audio Scan Control Point characteristic not found"
)
self.broadcast_audio_scan_control_point = characteristics[0]
)
if not (
characteristics := service_proxy.get_characteristics_by_uuid(
self.broadcast_receive_states = [
gatt.DelegatedCharacteristicAdapter(
characteristic,
decode=lambda x: BroadcastReceiveState.from_bytes(x) if x else None,
)
for characteristic in service_proxy.get_characteristics_by_uuid(
gatt.GATT_BROADCAST_RECEIVE_STATE_CHARACTERISTIC
)
):
raise gatt.InvalidServiceError(
"Broadcast Receive State characteristic not found"
)
self.broadcast_receive_states = [
gatt.SerializableCharacteristicAdapter(
characteristic, BroadcastReceiveState
)
for characteristic in characteristics
]
async def send_control_point_operation(

View File

@@ -30,7 +30,6 @@ from bumble.gatt import (
GATT_UGT_FEATURES_CHARACTERISTIC,
GATT_BGS_FEATURES_CHARACTERISTIC,
GATT_BGR_FEATURES_CHARACTERISTIC,
InvalidServiceError,
)
from bumble.gatt_client import ProfileServiceProxy, ServiceProxy
from enum import IntFlag
@@ -154,14 +153,10 @@ class GamingAudioServiceProxy(ProfileServiceProxy):
def __init__(self, service_proxy: ServiceProxy) -> None:
self.service_proxy = service_proxy
if not (
characteristics := service_proxy.get_characteristics_by_uuid(
GATT_GMAP_ROLE_CHARACTERISTIC
)
):
raise InvalidServiceError("GMAP Role Characteristic not found")
self.gmap_role = DelegatedCharacteristicAdapter(
characteristic=characteristics[0],
service_proxy.get_required_characteristic_by_uuid(
GATT_GMAP_ROLE_CHARACTERISTIC
),
decode=lambda value: GmapRole(value[0]),
)

View File

@@ -17,23 +17,35 @@
# -----------------------------------------------------------------------------
from __future__ import annotations
import dataclasses
import enum
import struct
from typing import List, Type
from typing import Any, List, Type
from typing_extensions import Self
from bumble.profiles import bap
from bumble import utils
# -----------------------------------------------------------------------------
# Classes
# -----------------------------------------------------------------------------
class AudioActiveState(utils.OpenIntEnum):
NO_AUDIO_DATA_TRANSMITTED = 0x00
AUDIO_DATA_TRANSMITTED = 0x01
class AssistedListeningStream(utils.OpenIntEnum):
UNSPECIFIED_AUDIO_ENHANCEMENT = 0x00
@dataclasses.dataclass
class Metadata:
'''Bluetooth Assigned Numbers, Section 6.12.6 - Metadata LTV structures.
As Metadata fields may extend, and Spec doesn't forbid duplication, we don't parse
Metadata into a key-value style dataclass here. Rather, we encourage users to parse
again outside the lib.
As Metadata fields may extend, and the spec may not guarantee the uniqueness of
tags, we don't automatically parse the Metadata data into specific classes.
Users of this class may decode the data by themselves, or use the Entry.decode
method.
'''
class Tag(utils.OpenIntEnum):
@@ -57,6 +69,44 @@ class Metadata:
tag: Metadata.Tag
data: bytes
def decode(self) -> Any:
"""
Decode the data into an object, if possible.
If no specific object class exists to represent the data, the raw data
bytes are returned.
"""
if self.tag in (
Metadata.Tag.PREFERRED_AUDIO_CONTEXTS,
Metadata.Tag.STREAMING_AUDIO_CONTEXTS,
):
return bap.ContextType(struct.unpack("<H", self.data)[0])
if self.tag in (
Metadata.Tag.PROGRAM_INFO,
Metadata.Tag.PROGRAM_INFO_URI,
Metadata.Tag.BROADCAST_NAME,
):
return self.data.decode("utf-8")
if self.tag == Metadata.Tag.LANGUAGE:
return self.data.decode("ascii")
if self.tag == Metadata.Tag.CCID_LIST:
return list(self.data)
if self.tag == Metadata.Tag.PARENTAL_RATING:
return self.data[0]
if self.tag == Metadata.Tag.AUDIO_ACTIVE_STATE:
return AudioActiveState(self.data[0])
if self.tag == Metadata.Tag.ASSISTED_LISTENING_STREAM:
return AssistedListeningStream(self.data[0])
return self.data
@classmethod
def from_bytes(cls: Type[Self], data: bytes) -> Self:
return cls(tag=Metadata.Tag(data[0]), data=data[1:])
@@ -66,6 +116,29 @@ class Metadata:
entries: List[Entry] = dataclasses.field(default_factory=list)
def pretty_print(self, indent: str) -> str:
"""Convenience method to generate a string with one key-value pair per line."""
max_key_length = 0
keys = []
values = []
for entry in self.entries:
key = entry.tag.name
max_key_length = max(max_key_length, len(key))
keys.append(key)
decoded = entry.decode()
if isinstance(decoded, enum.Enum):
values.append(decoded.name)
elif isinstance(decoded, bytes):
values.append(decoded.hex())
else:
values.append(str(decoded))
return '\n'.join(
f'{indent}{key}: {" " * (max_key_length-len(key))}{value}'
for key, value in zip(keys, values)
)
@classmethod
def from_bytes(cls: Type[Self], data: bytes) -> Self:
entries = []
@@ -81,3 +154,13 @@ class Metadata:
def __bytes__(self) -> bytes:
return b''.join([bytes(entry) for entry in self.entries])
def __str__(self) -> str:
entries_str = []
for entry in self.entries:
decoded = entry.decode()
entries_str.append(
f'{entry.tag.name}: '
f'{decoded.hex() if isinstance(decoded, bytes) else decoded!r}'
)
return f'Metadata(entries={", ".join(entry_str for entry_str in entries_str)})'

View File

@@ -72,6 +72,19 @@ class PacRecord:
metadata=metadata,
)
@classmethod
def list_from_bytes(cls, data: bytes) -> list[PacRecord]:
"""Parse a serialized list of records preceded by a one byte list length."""
record_count = data[0]
records = []
offset = 1
for _ in range(record_count):
record = PacRecord.from_bytes(data[offset:])
offset += len(bytes(record))
records.append(record)
return records
def __bytes__(self) -> bytes:
capabilities_bytes = bytes(self.codec_specific_capabilities)
metadata_bytes = bytes(self.metadata)
@@ -172,39 +185,58 @@ class PublishedAudioCapabilitiesService(gatt.TemplateService):
class PublishedAudioCapabilitiesServiceProxy(gatt_client.ProfileServiceProxy):
SERVICE_CLASS = PublishedAudioCapabilitiesService
sink_pac: Optional[gatt_client.CharacteristicProxy] = None
sink_audio_locations: Optional[gatt_client.CharacteristicProxy] = None
source_pac: Optional[gatt_client.CharacteristicProxy] = None
source_audio_locations: Optional[gatt_client.CharacteristicProxy] = None
available_audio_contexts: gatt_client.CharacteristicProxy
supported_audio_contexts: gatt_client.CharacteristicProxy
sink_pac: Optional[gatt.DelegatedCharacteristicAdapter] = None
sink_audio_locations: Optional[gatt.DelegatedCharacteristicAdapter] = None
source_pac: Optional[gatt.DelegatedCharacteristicAdapter] = None
source_audio_locations: Optional[gatt.DelegatedCharacteristicAdapter] = None
available_audio_contexts: gatt.DelegatedCharacteristicAdapter
supported_audio_contexts: gatt.DelegatedCharacteristicAdapter
def __init__(self, service_proxy: gatt_client.ServiceProxy):
self.service_proxy = service_proxy
self.available_audio_contexts = service_proxy.get_characteristics_by_uuid(
gatt.GATT_AVAILABLE_AUDIO_CONTEXTS_CHARACTERISTIC
)[0]
self.supported_audio_contexts = service_proxy.get_characteristics_by_uuid(
gatt.GATT_SUPPORTED_AUDIO_CONTEXTS_CHARACTERISTIC
)[0]
self.available_audio_contexts = gatt.DelegatedCharacteristicAdapter(
service_proxy.get_required_characteristic_by_uuid(
gatt.GATT_AVAILABLE_AUDIO_CONTEXTS_CHARACTERISTIC
),
decode=lambda x: tuple(map(ContextType, struct.unpack('<HH', x))),
)
self.supported_audio_contexts = gatt.DelegatedCharacteristicAdapter(
service_proxy.get_required_characteristic_by_uuid(
gatt.GATT_SUPPORTED_AUDIO_CONTEXTS_CHARACTERISTIC
),
decode=lambda x: tuple(map(ContextType, struct.unpack('<HH', x))),
)
if characteristics := service_proxy.get_characteristics_by_uuid(
gatt.GATT_SINK_PAC_CHARACTERISTIC
):
self.sink_pac = characteristics[0]
self.sink_pac = gatt.DelegatedCharacteristicAdapter(
characteristics[0],
decode=PacRecord.list_from_bytes,
)
if characteristics := service_proxy.get_characteristics_by_uuid(
gatt.GATT_SOURCE_PAC_CHARACTERISTIC
):
self.source_pac = characteristics[0]
self.source_pac = gatt.DelegatedCharacteristicAdapter(
characteristics[0],
decode=PacRecord.list_from_bytes,
)
if characteristics := service_proxy.get_characteristics_by_uuid(
gatt.GATT_SINK_AUDIO_LOCATION_CHARACTERISTIC
):
self.sink_audio_locations = characteristics[0]
self.sink_audio_locations = gatt.DelegatedCharacteristicAdapter(
characteristics[0],
decode=lambda x: AudioLocation(struct.unpack('<I', x)[0]),
)
if characteristics := service_proxy.get_characteristics_by_uuid(
gatt.GATT_SOURCE_AUDIO_LOCATION_CHARACTERISTIC
):
self.source_audio_locations = characteristics[0]
self.source_audio_locations = gatt.DelegatedCharacteristicAdapter(
characteristics[0],
decode=lambda x: AudioLocation(struct.unpack('<I', x)[0]),
)

View File

@@ -25,7 +25,6 @@ from bumble.gatt import (
TemplateService,
Characteristic,
DelegatedCharacteristicAdapter,
InvalidServiceError,
GATT_TELEPHONY_AND_MEDIA_AUDIO_SERVICE,
GATT_TMAP_ROLE_CHARACTERISTIC,
)
@@ -74,15 +73,10 @@ class TelephonyAndMediaAudioServiceProxy(ProfileServiceProxy):
def __init__(self, service_proxy: ServiceProxy):
self.service_proxy = service_proxy
if not (
characteristics := service_proxy.get_characteristics_by_uuid(
GATT_TMAP_ROLE_CHARACTERISTIC
)
):
raise InvalidServiceError('TMAP Role characteristic not found')
self.role = DelegatedCharacteristicAdapter(
characteristics[0],
service_proxy.get_required_characteristic_by_uuid(
GATT_TMAP_ROLE_CHARACTERISTIC
),
decode=lambda value: Role(
struct.unpack_from('<H', value, 0)[0],
),

View File

@@ -1,4 +1,4 @@
# Copyright 2021-2024 Google LLC
# Copyright 2021-2025 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,14 +17,16 @@
# Imports
# -----------------------------------------------------------------------------
from __future__ import annotations
import dataclasses
import enum
from typing import Optional, Sequence
from bumble import att
from bumble import device
from bumble import gatt
from bumble import gatt_client
from typing import Optional, Sequence
# -----------------------------------------------------------------------------
# Constants
@@ -67,6 +69,20 @@ class VolumeControlPointOpcode(enum.IntEnum):
MUTE = 0x06
@dataclasses.dataclass
class VolumeState:
volume_setting: int
mute: int
change_counter: int
@classmethod
def from_bytes(cls, data: bytes) -> VolumeState:
return cls(data[0], data[1], data[2])
def __bytes__(self) -> bytes:
return bytes([self.volume_setting, self.mute, self.change_counter])
# -----------------------------------------------------------------------------
# Server
# -----------------------------------------------------------------------------
@@ -126,16 +142,8 @@ class VolumeControlService(gatt.TemplateService):
included_services=list(included_services),
)
@property
def volume_state_bytes(self) -> bytes:
return bytes([self.volume_setting, self.muted, self.change_counter])
@volume_state_bytes.setter
def volume_state_bytes(self, new_value: bytes) -> None:
self.volume_setting, self.muted, self.change_counter = new_value
def _on_read_volume_state(self, _connection: Optional[device.Connection]) -> bytes:
return self.volume_state_bytes
return bytes(VolumeState(self.volume_setting, self.muted, self.change_counter))
def _on_write_volume_control_point(
self, connection: Optional[device.Connection], value: bytes
@@ -153,14 +161,9 @@ class VolumeControlService(gatt.TemplateService):
self.change_counter = (self.change_counter + 1) % 256
connection.abort_on(
'disconnection',
connection.device.notify_subscribers(
attribute=self.volume_state,
value=self.volume_state_bytes,
),
)
self.emit(
'volume_state', self.volume_setting, self.muted, self.change_counter
connection.device.notify_subscribers(attribute=self.volume_state),
)
self.emit('volume_state_change')
def _on_relative_volume_down(self) -> bool:
old_volume = self.volume_setting
@@ -207,24 +210,26 @@ class VolumeControlServiceProxy(gatt_client.ProfileServiceProxy):
SERVICE_CLASS = VolumeControlService
volume_control_point: gatt_client.CharacteristicProxy
volume_state: gatt.SerializableCharacteristicAdapter
volume_flags: gatt.DelegatedCharacteristicAdapter
def __init__(self, service_proxy: gatt_client.ServiceProxy) -> None:
self.service_proxy = service_proxy
self.volume_state = gatt.PackedCharacteristicAdapter(
service_proxy.get_characteristics_by_uuid(
self.volume_state = gatt.SerializableCharacteristicAdapter(
service_proxy.get_required_characteristic_by_uuid(
gatt.GATT_VOLUME_STATE_CHARACTERISTIC
)[0],
'BBB',
),
VolumeState,
)
self.volume_control_point = service_proxy.get_characteristics_by_uuid(
self.volume_control_point = service_proxy.get_required_characteristic_by_uuid(
gatt.GATT_VOLUME_CONTROL_POINT_CHARACTERISTIC
)[0]
self.volume_flags = gatt.PackedCharacteristicAdapter(
service_proxy.get_characteristics_by_uuid(
gatt.GATT_VOLUME_FLAGS_CHARACTERISTIC
)[0],
'B',
)
self.volume_flags = gatt.DelegatedCharacteristicAdapter(
service_proxy.get_required_characteristic_by_uuid(
gatt.GATT_VOLUME_FLAGS_CHARACTERISTIC
),
decode=lambda data: VolumeFlags(data[0]),
)

View File

@@ -27,8 +27,8 @@ from bumble.gatt import (
DelegatedCharacteristicAdapter,
TemplateService,
CharacteristicValue,
SerializableCharacteristicAdapter,
UTF8CharacteristicAdapter,
InvalidServiceError,
GATT_VOLUME_OFFSET_CONTROL_SERVICE,
GATT_VOLUME_OFFSET_STATE_CHARACTERISTIC,
GATT_AUDIO_LOCATION_CHARACTERISTIC,
@@ -82,9 +82,7 @@ class VolumeOffsetState:
async def notify_subscribers_via_connection(self, connection: Connection) -> None:
assert self.attribute_value is not None
await connection.device.notify_subscribers(
attribute=self.attribute_value, value=bytes(self)
)
await connection.device.notify_subscribers(attribute=self.attribute_value)
def on_read(self, _connection: Optional[Connection]) -> bytes:
return bytes(self)
@@ -111,9 +109,7 @@ class VocsAudioLocation:
assert self.attribute_value
self.audio_location = AudioLocation(int.from_bytes(value, 'little'))
await connection.device.notify_subscribers(
attribute=self.attribute_value, value=value
)
await connection.device.notify_subscribers(attribute=self.attribute_value)
@dataclass
@@ -169,9 +165,7 @@ class AudioOutputDescription:
assert self.attribute_value
self.audio_output_description = value.decode('utf-8')
await connection.device.notify_subscribers(
attribute=self.attribute_value, value=value
)
await connection.device.notify_subscribers(attribute=self.attribute_value)
# -----------------------------------------------------------------------------
@@ -203,37 +197,30 @@ class VolumeOffsetControlService(TemplateService):
VolumeOffsetControlPoint(self.volume_offset_state)
)
self.volume_offset_state_characteristic = DelegatedCharacteristicAdapter(
Characteristic(
uuid=GATT_VOLUME_OFFSET_STATE_CHARACTERISTIC,
properties=(
Characteristic.Properties.READ | Characteristic.Properties.NOTIFY
),
permissions=Characteristic.Permissions.READ_REQUIRES_ENCRYPTION,
value=CharacteristicValue(read=self.volume_offset_state.on_read),
self.volume_offset_state_characteristic = Characteristic(
uuid=GATT_VOLUME_OFFSET_STATE_CHARACTERISTIC,
properties=(
Characteristic.Properties.READ | Characteristic.Properties.NOTIFY
),
encode=lambda value: bytes(value),
permissions=Characteristic.Permissions.READ_REQUIRES_ENCRYPTION,
value=CharacteristicValue(read=self.volume_offset_state.on_read),
)
self.audio_location_characteristic = DelegatedCharacteristicAdapter(
Characteristic(
uuid=GATT_AUDIO_LOCATION_CHARACTERISTIC,
properties=(
Characteristic.Properties.READ
| Characteristic.Properties.NOTIFY
| Characteristic.Properties.WRITE_WITHOUT_RESPONSE
),
permissions=(
Characteristic.Permissions.READ_REQUIRES_ENCRYPTION
| Characteristic.Permissions.WRITE_REQUIRES_ENCRYPTION
),
value=CharacteristicValue(
read=self.audio_location.on_read,
write=self.audio_location.on_write,
),
self.audio_location_characteristic = Characteristic(
uuid=GATT_AUDIO_LOCATION_CHARACTERISTIC,
properties=(
Characteristic.Properties.READ
| Characteristic.Properties.NOTIFY
| Characteristic.Properties.WRITE_WITHOUT_RESPONSE
),
permissions=(
Characteristic.Permissions.READ_REQUIRES_ENCRYPTION
| Characteristic.Permissions.WRITE_REQUIRES_ENCRYPTION
),
value=CharacteristicValue(
read=self.audio_location.on_read,
write=self.audio_location.on_write,
),
encode=lambda value: bytes(value),
decode=VocsAudioLocation.from_bytes,
)
self.audio_location.attribute_value = self.audio_location_characteristic.value
@@ -244,25 +231,22 @@ class VolumeOffsetControlService(TemplateService):
value=CharacteristicValue(write=self.volume_offset_control_point.on_write),
)
self.audio_output_description_characteristic = DelegatedCharacteristicAdapter(
Characteristic(
uuid=GATT_AUDIO_OUTPUT_DESCRIPTION_CHARACTERISTIC,
properties=(
Characteristic.Properties.READ
| Characteristic.Properties.NOTIFY
| Characteristic.Properties.WRITE_WITHOUT_RESPONSE
),
permissions=(
Characteristic.Permissions.READ_REQUIRES_ENCRYPTION
| Characteristic.Permissions.WRITE_REQUIRES_ENCRYPTION
),
value=CharacteristicValue(
read=self.audio_output_description.on_read,
write=self.audio_output_description.on_write,
),
)
self.audio_output_description_characteristic = Characteristic(
uuid=GATT_AUDIO_OUTPUT_DESCRIPTION_CHARACTERISTIC,
properties=(
Characteristic.Properties.READ
| Characteristic.Properties.NOTIFY
| Characteristic.Properties.WRITE_WITHOUT_RESPONSE
),
permissions=(
Characteristic.Permissions.READ_REQUIRES_ENCRYPTION
| Characteristic.Permissions.WRITE_REQUIRES_ENCRYPTION
),
value=CharacteristicValue(
read=self.audio_output_description.on_read,
write=self.audio_output_description.on_write,
),
)
self.audio_output_description.attribute_value = (
self.audio_output_description_characteristic.value
)
@@ -287,44 +271,29 @@ class VolumeOffsetControlServiceProxy(ProfileServiceProxy):
def __init__(self, service_proxy: ServiceProxy) -> None:
self.service_proxy = service_proxy
if not (
characteristics := service_proxy.get_characteristics_by_uuid(
self.volume_offset_state = SerializableCharacteristicAdapter(
service_proxy.get_required_characteristic_by_uuid(
GATT_VOLUME_OFFSET_STATE_CHARACTERISTIC
)
):
raise InvalidServiceError("Volume Offset State characteristic not found")
self.volume_offset_state = DelegatedCharacteristicAdapter(
characteristics[0], decode=VolumeOffsetState.from_bytes
),
VolumeOffsetState,
)
if not (
characteristics := service_proxy.get_characteristics_by_uuid(
GATT_AUDIO_LOCATION_CHARACTERISTIC
)
):
raise InvalidServiceError("Audio Location characteristic not found")
self.audio_location = DelegatedCharacteristicAdapter(
characteristics[0],
encode=lambda value: bytes(value),
decode=VocsAudioLocation.from_bytes,
service_proxy.get_required_characteristic_by_uuid(
GATT_AUDIO_LOCATION_CHARACTERISTIC
),
encode=lambda value: bytes([int(value)]),
decode=lambda data: AudioLocation(data[0]),
)
if not (
characteristics := service_proxy.get_characteristics_by_uuid(
self.volume_offset_control_point = (
service_proxy.get_required_characteristic_by_uuid(
GATT_VOLUME_OFFSET_CONTROL_POINT_CHARACTERISTIC
)
):
raise InvalidServiceError(
"Volume Offset Control Point characteristic not found"
)
self.volume_offset_control_point = characteristics[0]
)
if not (
characteristics := service_proxy.get_characteristics_by_uuid(
self.audio_output_description = UTF8CharacteristicAdapter(
service_proxy.get_required_characteristic_by_uuid(
GATT_AUDIO_OUTPUT_DESCRIPTION_CHARACTERISTIC
)
):
raise InvalidServiceError(
"Audio Output Description characteristic not found"
)
self.audio_output_description = UTF8CharacteristicAdapter(characteristics[0])
)

View File

@@ -16,15 +16,21 @@
# Imports
# -----------------------------------------------------------------------------
from __future__ import annotations
import asyncio
import logging
import struct
from typing import Dict, List, Type, Optional, Tuple, Union, NewType, TYPE_CHECKING
from typing import Iterable, NewType, Optional, Union, Sequence, Type, TYPE_CHECKING
from typing_extensions import Self
from . import core, l2cap
from .colors import color
from .core import InvalidStateError, InvalidArgumentError, InvalidPacketError
from .hci import HCI_Object, name_or_number, key_with_value
from bumble import core, l2cap
from bumble.colors import color
from bumble.core import (
InvalidStateError,
InvalidArgumentError,
InvalidPacketError,
ProtocolError,
)
from bumble.hci import HCI_Object, name_or_number, key_with_value
if TYPE_CHECKING:
from .device import Device, Connection
@@ -242,11 +248,11 @@ class DataElement:
return DataElement(DataElement.BOOLEAN, value)
@staticmethod
def sequence(value: List[DataElement]) -> DataElement:
def sequence(value: Iterable[DataElement]) -> DataElement:
return DataElement(DataElement.SEQUENCE, value)
@staticmethod
def alternative(value: List[DataElement]) -> DataElement:
def alternative(value: Iterable[DataElement]) -> DataElement:
return DataElement(DataElement.ALTERNATIVE, value)
@staticmethod
@@ -473,7 +479,9 @@ class ServiceAttribute:
self.value = value
@staticmethod
def list_from_data_elements(elements: List[DataElement]) -> List[ServiceAttribute]:
def list_from_data_elements(
elements: Sequence[DataElement],
) -> list[ServiceAttribute]:
attribute_list = []
for i in range(0, len(elements) // 2):
attribute_id, attribute_value = elements[2 * i : 2 * (i + 1)]
@@ -486,7 +494,7 @@ class ServiceAttribute:
@staticmethod
def find_attribute_in_list(
attribute_list: List[ServiceAttribute], attribute_id: int
attribute_list: Iterable[ServiceAttribute], attribute_id: int
) -> Optional[DataElement]:
return next(
(
@@ -534,7 +542,12 @@ class SDP_PDU:
See Bluetooth spec @ Vol 3, Part B - 4.2 PROTOCOL DATA UNIT FORMAT
'''
sdp_pdu_classes: Dict[int, Type[SDP_PDU]] = {}
RESPONSE_PDU_IDS = {
SDP_SERVICE_SEARCH_REQUEST: SDP_SERVICE_SEARCH_RESPONSE,
SDP_SERVICE_ATTRIBUTE_REQUEST: SDP_SERVICE_ATTRIBUTE_RESPONSE,
SDP_SERVICE_SEARCH_ATTRIBUTE_REQUEST: SDP_SERVICE_SEARCH_ATTRIBUTE_RESPONSE,
}
sdp_pdu_classes: dict[int, Type[SDP_PDU]] = {}
name = None
pdu_id = 0
@@ -558,7 +571,7 @@ class SDP_PDU:
@staticmethod
def parse_service_record_handle_list_preceded_by_count(
data: bytes, offset: int
) -> Tuple[int, List[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)
@@ -639,6 +652,8 @@ class SDP_ErrorResponse(SDP_PDU):
See Bluetooth spec @ Vol 3, Part B - 4.4.1 SDP_ErrorResponse PDU
'''
error_code: int
# -----------------------------------------------------------------------------
@SDP_PDU.subclass(
@@ -675,7 +690,7 @@ class SDP_ServiceSearchResponse(SDP_PDU):
See Bluetooth spec @ Vol 3, Part B - 4.5.2 SDP_ServiceSearchResponse PDU
'''
service_record_handle_list: List[int]
service_record_handle_list: list[int]
total_service_record_count: int
current_service_record_count: int
continuation_state: bytes
@@ -752,31 +767,99 @@ 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
attribute_lists_byte_count: int
attribute_lists: bytes
continuation_state: bytes
# -----------------------------------------------------------------------------
class Client:
channel: Optional[l2cap.ClassicChannel]
def __init__(self, connection: Connection) -> None:
def __init__(self, connection: Connection, mtu: int = 0) -> None:
self.connection = connection
self.pending_request = None
self.channel = None
self.channel: Optional[l2cap.ClassicChannel] = None
self.mtu = mtu
self.request_semaphore = asyncio.Semaphore(1)
self.pending_request: Optional[SDP_PDU] = None
self.pending_response: Optional[asyncio.futures.Future[SDP_PDU]] = None
self.next_transaction_id = 0
async def connect(self) -> None:
self.channel = await self.connection.create_l2cap_channel(
spec=l2cap.ClassicChannelSpec(SDP_PSM)
spec=(
l2cap.ClassicChannelSpec(SDP_PSM, self.mtu)
if self.mtu
else l2cap.ClassicChannelSpec(SDP_PSM)
)
)
self.channel.sink = self.on_pdu
async def disconnect(self) -> None:
if self.channel:
await self.channel.disconnect()
self.channel = None
async def search_services(self, uuids: List[core.UUID]) -> List[int]:
def make_transaction_id(self) -> int:
transaction_id = self.next_transaction_id
self.next_transaction_id = (self.next_transaction_id + 1) & 0xFFFF
return transaction_id
def on_pdu(self, pdu: bytes) -> None:
if not self.pending_request:
logger.warning('received response with no pending request')
return
assert self.pending_response is not None
response = SDP_PDU.from_bytes(pdu)
# Check that the transaction ID is what we expect
if self.pending_request.transaction_id != response.transaction_id:
logger.warning(
f"received response with transaction ID {response.transaction_id} "
f"but expected {self.pending_request.transaction_id}"
)
return
# Check if the response is an error
if isinstance(response, SDP_ErrorResponse):
self.pending_response.set_exception(
ProtocolError(error_code=response.error_code)
)
return
# Check that the type of the response matches the request
if response.pdu_id != SDP_PDU.RESPONSE_PDU_IDS.get(self.pending_request.pdu_id):
logger.warning("response type mismatch")
return
self.pending_response.set_result(response)
async def send_request(self, request: SDP_PDU) -> SDP_PDU:
assert self.channel is not None
async with self.request_semaphore:
assert self.pending_request is None
assert self.pending_response is None
# Create a future value to hold the eventual response
self.pending_response = asyncio.get_running_loop().create_future()
self.pending_request = request
try:
self.channel.send_pdu(bytes(request))
return await self.pending_response
finally:
self.pending_request = None
self.pending_response = None
async def search_services(self, uuids: Iterable[core.UUID]) -> list[int]:
"""
Search for services by UUID.
Args:
uuids: service the UUIDs to search for.
Returns:
A list of matching service record handles.
"""
if self.pending_request is not None:
raise InvalidStateError('request already pending')
if self.channel is None:
@@ -791,16 +874,16 @@ class Client:
continuation_state = bytes([0])
watchdog = SDP_CONTINUATION_WATCHDOG
while watchdog > 0:
response_pdu = await self.channel.send_request(
response = await self.send_request(
SDP_ServiceSearchRequest(
transaction_id=0, # Transaction ID TODO: pick a real value
transaction_id=self.make_transaction_id(),
service_search_pattern=service_search_pattern,
maximum_service_record_count=0xFFFF,
continuation_state=continuation_state,
)
)
response = SDP_PDU.from_bytes(response_pdu)
logger.debug(f'<<< Response: {response}')
assert isinstance(response, SDP_ServiceSearchResponse)
service_record_handle_list += response.service_record_handle_list
continuation_state = response.continuation_state
if len(continuation_state) == 1 and continuation_state[0] == 0:
@@ -811,8 +894,21 @@ class Client:
return service_record_handle_list
async def search_attributes(
self, uuids: List[core.UUID], attribute_ids: List[Union[int, Tuple[int, int]]]
) -> List[List[ServiceAttribute]]:
self,
uuids: Iterable[core.UUID],
attribute_ids: Iterable[Union[int, tuple[int, int]]],
) -> list[list[ServiceAttribute]]:
"""
Search for attributes by UUID and attribute IDs.
Args:
uuids: the service UUIDs to search for.
attribute_ids: list of attribute IDs or (start, end) attribute ID ranges.
(use (0, 0xFFFF) to include all attributes)
Returns:
A list of list of attributes, one list per matching service.
"""
if self.pending_request is not None:
raise InvalidStateError('request already pending')
if self.channel is None:
@@ -824,8 +920,8 @@ class Client:
attribute_id_list = DataElement.sequence(
[
(
DataElement.unsigned_integer(
attribute_id[0], value_size=attribute_id[1]
DataElement.unsigned_integer_32(
attribute_id[0] << 16 | attribute_id[1]
)
if isinstance(attribute_id, tuple)
else DataElement.unsigned_integer_16(attribute_id)
@@ -839,17 +935,17 @@ class Client:
continuation_state = bytes([0])
watchdog = SDP_CONTINUATION_WATCHDOG
while watchdog > 0:
response_pdu = await self.channel.send_request(
response = await self.send_request(
SDP_ServiceSearchAttributeRequest(
transaction_id=0, # Transaction ID TODO: pick a real value
transaction_id=self.make_transaction_id(),
service_search_pattern=service_search_pattern,
maximum_attribute_byte_count=0xFFFF,
attribute_id_list=attribute_id_list,
continuation_state=continuation_state,
)
)
response = SDP_PDU.from_bytes(response_pdu)
logger.debug(f'<<< Response: {response}')
assert isinstance(response, SDP_ServiceSearchAttributeResponse)
accumulator += response.attribute_lists
continuation_state = response.continuation_state
if len(continuation_state) == 1 and continuation_state[0] == 0:
@@ -872,8 +968,18 @@ class Client:
async def get_attributes(
self,
service_record_handle: int,
attribute_ids: List[Union[int, Tuple[int, int]]],
) -> List[ServiceAttribute]:
attribute_ids: Iterable[Union[int, tuple[int, int]]],
) -> list[ServiceAttribute]:
"""
Get attributes for a service.
Args:
service_record_handle: the handle for a service
attribute_ids: list or attribute IDs or (start, end) attribute ID handles.
Returns:
A list of attributes.
"""
if self.pending_request is not None:
raise InvalidStateError('request already pending')
if self.channel is None:
@@ -882,8 +988,8 @@ class Client:
attribute_id_list = DataElement.sequence(
[
(
DataElement.unsigned_integer(
attribute_id[0], value_size=attribute_id[1]
DataElement.unsigned_integer_32(
attribute_id[0] << 16 | attribute_id[1]
)
if isinstance(attribute_id, tuple)
else DataElement.unsigned_integer_16(attribute_id)
@@ -897,17 +1003,17 @@ class Client:
continuation_state = bytes([0])
watchdog = SDP_CONTINUATION_WATCHDOG
while watchdog > 0:
response_pdu = await self.channel.send_request(
response = await self.send_request(
SDP_ServiceAttributeRequest(
transaction_id=0, # Transaction ID TODO: pick a real value
transaction_id=self.make_transaction_id(),
service_record_handle=service_record_handle,
maximum_attribute_byte_count=0xFFFF,
attribute_id_list=attribute_id_list,
continuation_state=continuation_state,
)
)
response = SDP_PDU.from_bytes(response_pdu)
logger.debug(f'<<< Response: {response}')
assert isinstance(response, SDP_ServiceAttributeResponse)
accumulator += response.attribute_list
continuation_state = response.continuation_state
if len(continuation_state) == 1 and continuation_state[0] == 0:
@@ -933,17 +1039,17 @@ class Client:
# -----------------------------------------------------------------------------
class Server:
CONTINUATION_STATE = bytes([0x01, 0x43])
CONTINUATION_STATE = bytes([0x01, 0x00])
channel: Optional[l2cap.ClassicChannel]
Service = NewType('Service', List[ServiceAttribute])
service_records: Dict[int, Service]
current_response: Union[None, bytes, Tuple[int, List[int]]]
Service = NewType('Service', list[ServiceAttribute])
service_records: dict[int, Service]
current_response: Union[None, bytes, tuple[int, list[int]]]
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
self.current_response = None # Current response data, used for continuations
def register(self, l2cap_channel_manager: l2cap.ChannelManager) -> None:
l2cap_channel_manager.create_classic_server(
@@ -954,7 +1060,7 @@ class Server:
logger.debug(f'{color(">>> Sending SDP Response", "blue")}: {response}')
self.channel.send_pdu(response)
def match_services(self, search_pattern: DataElement) -> Dict[int, Service]:
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 = {}
@@ -1011,6 +1117,31 @@ class Server:
)
)
def check_continuation(
self,
continuation_state: bytes,
transaction_id: int,
) -> Optional[bool]:
# Check if this is a valid continuation
if len(continuation_state) > 1:
if (
self.current_response is None
or continuation_state != self.CONTINUATION_STATE
):
self.send_response(
SDP_ErrorResponse(
transaction_id=transaction_id,
error_code=SDP_INVALID_CONTINUATION_STATE_ERROR,
)
)
return None
return True
# Cleanup any partial response leftover
self.current_response = None
return False
def get_next_response_payload(self, maximum_size):
if len(self.current_response) > maximum_size:
payload = self.current_response[:maximum_size]
@@ -1025,7 +1156,7 @@ class Server:
@staticmethod
def get_service_attributes(
service: Service, attribute_ids: List[DataElement]
service: Service, attribute_ids: Iterable[DataElement]
) -> DataElement:
attributes = []
for attribute_id in attribute_ids:
@@ -1053,30 +1184,24 @@ class Server:
def on_sdp_service_search_request(self, request: SDP_ServiceSearchRequest) -> None:
# Check if this is a continuation
if len(request.continuation_state) > 1:
if self.current_response is None:
self.send_response(
SDP_ErrorResponse(
transaction_id=request.transaction_id,
error_code=SDP_INVALID_CONTINUATION_STATE_ERROR,
)
)
return
else:
# Cleanup any partial response leftover
self.current_response = None
if (
continuation := self.check_continuation(
request.continuation_state, request.transaction_id
)
) is None:
return
if not continuation:
# Find the matching services
matching_services = self.match_services(request.service_search_pattern)
service_record_handles = list(matching_services.keys())
logger.debug(f'Service Record Handles: {service_record_handles}')
# Only return up to the maximum requested
service_record_handles_subset = service_record_handles[
: request.maximum_service_record_count
]
# Serialize to a byte array, and remember the total count
logger.debug(f'Service Record Handles: {service_record_handles}')
self.current_response = (
len(service_record_handles),
service_record_handles_subset,
@@ -1084,15 +1209,21 @@ 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
assert self.channel is not None
total_service_record_count, service_record_handles = self.current_response
maximum_service_record_count = (self.channel.peer_mtu - 11) // 4
service_record_handles_remaining = service_record_handles[
maximum_service_record_count:
]
service_record_handles = service_record_handles[:maximum_service_record_count]
self.current_response = (
self.current_response[0],
self.current_response[1][request.maximum_service_record_count :],
total_service_record_count,
service_record_handles_remaining,
)
continuation_state = (
Server.CONTINUATION_STATE if self.current_response[1] else bytes([0])
Server.CONTINUATION_STATE
if service_record_handles_remaining
else bytes([0])
)
service_record_handle_list = b''.join(
[struct.pack('>I', handle) for handle in service_record_handles]
@@ -1100,7 +1231,7 @@ class Server:
self.send_response(
SDP_ServiceSearchResponse(
transaction_id=request.transaction_id,
total_service_record_count=self.current_response[0],
total_service_record_count=total_service_record_count,
current_service_record_count=len(service_record_handles),
service_record_handle_list=service_record_handle_list,
continuation_state=continuation_state,
@@ -1111,19 +1242,14 @@ class Server:
self, request: SDP_ServiceAttributeRequest
) -> None:
# Check if this is a continuation
if len(request.continuation_state) > 1:
if self.current_response is None:
self.send_response(
SDP_ErrorResponse(
transaction_id=request.transaction_id,
error_code=SDP_INVALID_CONTINUATION_STATE_ERROR,
)
)
return
else:
# Cleanup any partial response leftover
self.current_response = None
if (
continuation := self.check_continuation(
request.continuation_state, request.transaction_id
)
) is None:
return
if not continuation:
# Check that the service exists
service = self.service_records.get(request.service_record_handle)
if service is None:
@@ -1145,14 +1271,18 @@ class Server:
self.current_response = bytes(attribute_list)
# Respond, keeping any pending chunks for later
assert self.channel is not None
maximum_attribute_byte_count = min(
request.maximum_attribute_byte_count, self.channel.peer_mtu - 9
)
attribute_list_response, continuation_state = self.get_next_response_payload(
request.maximum_attribute_byte_count
maximum_attribute_byte_count
)
self.send_response(
SDP_ServiceAttributeResponse(
transaction_id=request.transaction_id,
attribute_list_byte_count=len(attribute_list_response),
attribute_list=attribute_list,
attribute_list=attribute_list_response,
continuation_state=continuation_state,
)
)
@@ -1161,18 +1291,14 @@ class Server:
self, request: SDP_ServiceSearchAttributeRequest
) -> None:
# Check if this is a continuation
if len(request.continuation_state) > 1:
if self.current_response is None:
self.send_response(
SDP_ErrorResponse(
transaction_id=request.transaction_id,
error_code=SDP_INVALID_CONTINUATION_STATE_ERROR,
)
)
else:
# Cleanup any partial response leftover
self.current_response = None
if (
continuation := self.check_continuation(
request.continuation_state, request.transaction_id
)
) is None:
return
if not continuation:
# Find the matching services
matching_services = self.match_services(
request.service_search_pattern
@@ -1192,14 +1318,18 @@ class Server:
self.current_response = bytes(attribute_lists)
# Respond, keeping any pending chunks for later
assert self.channel is not None
maximum_attribute_byte_count = min(
request.maximum_attribute_byte_count, self.channel.peer_mtu - 9
)
attribute_lists_response, continuation_state = self.get_next_response_payload(
request.maximum_attribute_byte_count
maximum_attribute_byte_count
)
self.send_response(
SDP_ServiceSearchAttributeResponse(
transaction_id=request.transaction_id,
attribute_lists_byte_count=len(attribute_lists_response),
attribute_lists=attribute_lists,
attribute_lists=attribute_lists_response,
continuation_state=continuation_state,
)
)

View File

@@ -39,12 +39,14 @@ nav:
- Drivers:
- drivers/index.md
- Realtek: drivers/realtek.md
- Intel: drivers/intel.md
- API:
- Guide: api/guide.md
- Examples: api/examples.md
- Reference: api/reference.md
- Apps & Tools:
- apps_and_tools/index.md
- Auracast: apps_and_tools/auracast.md
- Console: apps_and_tools/console.md
- Bench: apps_and_tools/bench.md
- Speaker: apps_and_tools/speaker.md
@@ -108,8 +110,8 @@ markdown_extensions:
- pymdownx.details
- pymdownx.superfences
- pymdownx.emoji:
emoji_index: !!python/name:materialx.emoji.twemoji
emoji_generator: !!python/name:materialx.emoji.to_svg
emoji_index: !!python/name:material.extensions.emoji.twemoji
emoji_generator: !!python/name:material.extensions.emoji.to_svg
- pymdownx.tabbed:
alternate_style: true
- codehilite:

View File

@@ -4,12 +4,13 @@ APPS & TOOLS
Included in the project are a few apps and tools, built on top of the core libraries.
These include:
* [Console](console.md) - an interactive text-based console
* [Bench](bench.md) - Speed and Latency benchmarking between two devices (LE and Classic)
* [Pair](pair.md) - Pair/bond two devices (LE and Classic)
* [Unbond](unbond.md) - Remove a previously established bond
* [HCI Bridge](hci_bridge.md) - a HCI transport bridge to connect two HCI transports and filter/snoop the HCI packets
* [Golden Gate Bridge](gg_bridge.md) - a bridge between GATT and UDP to use with the Golden Gate "stack tool"
* [Show](show.md) - Parse a file with HCI packets and print the details of each packet in a human readable form
* [Auracast](auracast.md) - Commands to broadcast, receive and/or control LE Audio.
* [Console](console.md) - An interactive text-based console.
* [Bench](bench.md) - Speed and Latency benchmarking between two devices (LE and Classic).
* [Pair](pair.md) - Pair/bond two devices (LE and Classic).
* [Unbond](unbond.md) - Remove a previously established bond.
* [HCI Bridge](hci_bridge.md) - An HCI transport bridge to connect two HCI transports and filter/snoop the HCI packets.
* [Golden Gate Bridge](gg_bridge.md) - Bridge between GATT and UDP to use with the Golden Gate "stack tool".
* [Show](show.md) - Parse a file with HCI packets and print the details of each packet in a human readable form.
* [Speaker](speaker.md) - Virtual Bluetooth speaker, with a command line and browser-based UI.
* [Link Relay](link_relay.md) - WebSocket relay for virtual RemoteLink instances to communicate with each other.

View File

@@ -9,9 +9,9 @@ for your platform.
Throughout the documentation, when shell commands are shown, it is assumed that you can
invoke Python as
```
$ python
$ python3
```
If invoking python is different on your platform (it may be `python3` for example, or just `py` or `py.exe`),
If invoking python is different on your platform (it may be `python` for example, or just `py` or `py.exe`),
adjust accordingly.
You may be simply using Bumble as a module for your own application or as a dependency to your own
@@ -30,12 +30,18 @@ manager, or from source.
python environment, or in a virtual environment, such as a `venv`, `pyenv` or `conda` environment.
See the [Python Environments page](development/python_environments.md) page for details.
### Install from PyPI
```
$ python3 -m pip install bumble
```
### Install From Source
Install with `pip`. Run in a command shell in the directory where you downloaded the source
distribution
```
$ python -m pip install -e .
$ python3 -m pip install -e .
```
### Install from GitHub
@@ -44,21 +50,21 @@ You can install directly from GitHub without first downloading the repo.
Install the latest commit from the main branch with `pip`:
```
$ python -m pip install git+https://github.com/google/bumble.git
$ python3 -m pip install git+https://github.com/google/bumble.git
```
You can specify a specific tag.
Install tag `v0.0.1` with `pip`:
```
$ python -m pip install git+https://github.com/google/bumble.git@v0.0.1
$ python3 -m pip install git+https://github.com/google/bumble.git@v0.0.1
```
You can also specify a specific commit.
Install commit `27c0551` with `pip`:
```
$ python -m pip install git+https://github.com/google/bumble.git@27c0551
$ python3 -m pip install git+https://github.com/google/bumble.git@27c0551
```
# Working On The Bumble Code
@@ -78,21 +84,21 @@ directory of the project.
```bash
$ export PYTHONPATH=.
$ python apps/console.py serial:/dev/tty.usbmodem0006839912171
$ python3 apps/console.py serial:/dev/tty.usbmodem0006839912171
```
or running an example, with the working directory set to the `examples` subdirectory
```bash
$ cd examples
$ export PYTHONPATH=..
$ python run_scanner.py usb:0
$ python3 run_scanner.py usb:0
```
Or course, `export PYTHONPATH` only needs to be invoked once, not before each app/script execution.
Setting `PYTHONPATH` locally with each command would look something like:
```
$ PYTHONPATH=. python examples/run_advertiser.py examples/device1.json serial:/dev/tty.usbmodem0006839912171
$ PYTHONPATH=. python3 examples/run_advertiser.py examples/device1.json serial:/dev/tty.usbmodem0006839912171
```
# Where To Go Next

View File

@@ -35,11 +35,11 @@ the command line.
visit [this Android Studio user guide page](https://developer.android.com/studio/run/emulator-commandline)
The `-packet-streamer-endpoint <endpoint>` command line option may be used to enable
Bluetooth emulation and tell the emulator which virtual controller to connect to.
Bluetooth emulation and tell the emulator which virtual controller to connect to.
## Connecting to Netsim
If the emulator doesn't have Bluetooth emulation enabled by default, use the
If the emulator doesn't have Bluetooth emulation enabled by default, use the
`-packet-streamer-endpoint default` option to tell it to connect to Netsim.
If Netsim is not running, the emulator will start it automatically.
@@ -60,17 +60,17 @@ the Bumble `android-netsim` transport in `host` mode (the default).
!!! example "Run the example GATT server connected to the emulator via Netsim"
``` shell
$ python run_gatt_server.py device1.json android-netsim
$ python3 run_gatt_server.py device1.json android-netsim
```
By default, the Bumble `android-netsim` transport will try to automatically discover
the port number on which the netsim process is exposing its gRPC server interface. If
that discovery process fails, or if you want to specify the interface manually, you
that discovery process fails, or if you want to specify the interface manually, you
can pass a `hostname` and `port` as parameters to the transport, as: `android-netsim:<host>:<port>`.
!!! example "Run the example GATT server connected to the emulator via Netsim on a localhost, port 8877"
``` shell
$ python run_gatt_server.py device1.json android-netsim:localhost:8877
$ python3 run_gatt_server.py device1.json android-netsim:localhost:8877
```
### Multiple Instances
@@ -84,7 +84,7 @@ For example: `android-netsim:localhost:8877,name=bumble1`
This is an advanced use case, which may not be officially supported, but should work in recent
versions of the emulator.
The first step is to run the Bumble HCI bridge, specifying netsim as the "host" end of the
The first step is to run the Bumble HCI bridge, specifying netsim as the "host" end of the
bridge, and another controller (typically a USB Bluetooth dongle, but any other supported
transport can work as well) as the "controller" end of the bridge.

View File

@@ -25,7 +25,7 @@ import struct
import sys
from typing import Any, List, Union
from bumble.device import Connection, Device, Peer
from bumble.device import Device, Peer
from bumble import transport
from bumble import gatt
from bumble import hci
@@ -82,19 +82,19 @@ async def client(device: Device, address: hci.Address) -> None:
for index in range(1, 9):
characteristics.append(
service.get_characteristics_by_uuid(
CHARACTERISTIC_UUID_BASE + f"{index:02X}"
core.UUID(CHARACTERISTIC_UUID_BASE + f"{index:02X}")
)[0]
)
# Read all characteristics as raw bytes.
for characteristic in characteristics:
value = await characteristic.read_value()
print(f"### {characteristic} = {value} ({value.hex()})")
print(f"### {characteristic} = {value!r} ({value.hex()})")
# Static characteristic with a bytes value.
c1 = characteristics[0]
c1_value = await c1.read_value()
print(f"@@@ C1 {c1} value = {c1_value} (type={type(c1_value)})")
print(f"@@@ C1 {c1} value = {c1_value!r} (type={type(c1_value)})")
await c1.write_value("happy π day".encode("utf-8"))
# Static characteristic with a string value.
@@ -136,7 +136,7 @@ async def client(device: Device, address: hci.Address) -> None:
# Dynamic characteristic with a bytes value.
c7 = characteristics[6]
c7_value = await c7.read_value()
print(f"@@@ C7 {c7} value = {c7_value} (type={type(c7_value)})")
print(f"@@@ C7 {c7} value = {c7_value!r} (type={type(c7_value)})")
await c7.write_value(bytes.fromhex("01020304"))
# Dynamic characteristic with a string value.

View File

@@ -42,7 +42,7 @@ from bumble.profiles.bap import (
from bumble.profiles.pacs import PacRecord, PublishedAudioCapabilitiesService
from bumble.profiles.cap import CommonAudioServiceService
from bumble.profiles.csip import CoordinatedSetIdentificationService, SirkType
from bumble.profiles.vcp import VolumeControlService
from bumble.profiles.vcs import VolumeControlService
from bumble.transport import open_transport_or_link
@@ -117,13 +117,17 @@ async def main() -> None:
ws: Optional[websockets.WebSocketServerProtocol] = None
def on_volume_state(volume_setting: int, muted: int, change_counter: int):
def on_volume_state_change():
if ws:
asyncio.create_task(
ws.send(dumps_volume_state(volume_setting, muted, change_counter))
ws.send(
dumps_volume_state(
vcs.volume_setting, vcs.muted, vcs.change_counter
)
)
)
vcs.on('volume_state', on_volume_state)
vcs.on('volume_state_change', on_volume_state_change)
advertising_data = (
bytes(
@@ -170,16 +174,10 @@ async def main() -> None:
ws = websocket
async for message in websocket:
volume_state = json.loads(message)
vcs.volume_state_bytes = bytes(
[
volume_state['volume_setting'],
volume_state['muted'],
volume_state['change_counter'],
]
)
await device.notify_subscribers(
vcs.volume_state, vcs.volume_state_bytes
)
vcs.volume_setting = volume_state['volume_setting']
vcs.muted = volume_state['muted']
vcs.change_counter = volume_state['change_counter']
await device.notify_subscribers(vcs.volume_state)
ws = None
await websockets.serve(serve, 'localhost', 8989)

View File

@@ -61,15 +61,17 @@ avatar = [
]
pandora = ["bt-test-interfaces >= 0.0.6"]
documentation = [
"mkdocs >= 1.4.0",
"mkdocs-material >= 8.5.6",
"mkdocstrings[python] >= 0.19.0",
"mkdocs >= 1.6.0",
"mkdocs-material >= 9.6",
"mkdocstrings[python] >= 0.27.0",
]
lc3 = [
"lc3 @ git+https://github.com/google/liblc3.git",
auracast = [
"lc3py ; python_version>='3.10' and platform_system=='Linux' and platform_machine=='x86_64'",
"sounddevice >= 0.5.1",
]
[project.scripts]
bumble-auracast = "bumble.apps.auracast:main"
bumble-ble-rpa-tool = "bumble.apps.ble_rpa_tool:main"
bumble-console = "bumble.apps.console:main"
bumble-controller-info = "bumble.apps.controller_info:main"
@@ -190,6 +192,10 @@ ignore_missing_imports = true
module = "serial_asyncio.*"
ignore_missing_imports = true
[[tool.mypy.overrides]]
module = "sounddevice.*"
ignore_missing_imports = true
[[tool.mypy.overrides]]
module = "usb.*"
ignore_missing_imports = true

View File

@@ -33,7 +33,7 @@ from bumble.profiles.aics import (
AudioInputControlPointOpCode,
ErrorCode,
)
from bumble.profiles.vcp import VolumeControlService, VolumeControlServiceProxy
from bumble.profiles.vcs import VolumeControlService, VolumeControlServiceProxy
from .test_utils import TwoDevices

View File

@@ -34,7 +34,7 @@ from bumble.device import (
Device,
PeriodicAdvertisingParameters,
)
from bumble.host import AclPacketQueue, Host
from bumble.host import DataPacketQueue, Host
from bumble.hci import (
HCI_ACCEPT_CONNECTION_REQUEST_COMMAND,
HCI_COMMAND_STATUS_PENDING,
@@ -90,9 +90,9 @@ async def test_device_connect_parallel():
def _send(packet):
pass
d0.host.acl_packet_queue = AclPacketQueue(0, 0, _send)
d1.host.acl_packet_queue = AclPacketQueue(0, 0, _send)
d2.host.acl_packet_queue = AclPacketQueue(0, 0, _send)
d0.host.acl_packet_queue = DataPacketQueue(0, 0, _send)
d1.host.acl_packet_queue = DataPacketQueue(0, 0, _send)
d2.host.acl_packet_queue = DataPacketQueue(0, 0, _send)
# enable classic
d0.classic_enabled = True

View File

@@ -170,8 +170,8 @@ def test_HCI_Command_Complete_Event():
command_opcode=HCI_LE_READ_BUFFER_SIZE_COMMAND,
return_parameters=HCI_LE_Read_Buffer_Size_Command.create_return_parameters(
status=0,
hc_le_acl_data_packet_length=1234,
hc_total_num_le_acl_data_packets=56,
le_acl_data_packet_length=1234,
total_num_le_acl_data_packets=56,
),
)
basic_check(event)

View File

@@ -16,11 +16,14 @@
# Imports
# -----------------------------------------------------------------------------
import logging
import unittest.mock
import pytest
import unittest
from bumble.controller import Controller
from bumble.host import Host
from bumble.host import Host, DataPacketQueue
from bumble.transport import AsyncPipeSink
from bumble.hci import HCI_AclDataPacket
# -----------------------------------------------------------------------------
# Logging
@@ -60,3 +63,90 @@ async def test_reset(supported_commands: str, lmp_features: str):
assert host.local_lmp_features == int.from_bytes(
bytes.fromhex(lmp_features), 'little'
)
# -----------------------------------------------------------------------------
def test_data_packet_queue():
controller = unittest.mock.Mock()
queue = DataPacketQueue(10, 2, controller.send)
assert queue.queued == 0
assert queue.completed == 0
packet = HCI_AclDataPacket(
connection_handle=123, pb_flag=0, bc_flag=0, data_total_length=0, data=b''
)
queue.enqueue(packet, packet.connection_handle)
assert queue.queued == 1
assert queue.completed == 0
assert controller.send.call_count == 1
queue.enqueue(packet, packet.connection_handle)
assert queue.queued == 2
assert queue.completed == 0
assert controller.send.call_count == 2
queue.enqueue(packet, packet.connection_handle)
assert queue.queued == 3
assert queue.completed == 0
assert controller.send.call_count == 2
queue.on_packets_completed(1, 8000)
assert queue.queued == 3
assert queue.completed == 0
assert controller.send.call_count == 2
queue.on_packets_completed(1, 123)
assert queue.queued == 3
assert queue.completed == 1
assert controller.send.call_count == 3
queue.enqueue(packet, packet.connection_handle)
assert queue.queued == 4
assert queue.completed == 1
assert controller.send.call_count == 3
queue.on_packets_completed(2, 123)
assert queue.queued == 4
assert queue.completed == 3
assert controller.send.call_count == 4
queue.on_packets_completed(1, 123)
assert queue.queued == 4
assert queue.completed == 4
assert controller.send.call_count == 4
queue.enqueue(packet, 123)
queue.enqueue(packet, 123)
queue.enqueue(packet, 123)
queue.enqueue(packet, 124)
queue.enqueue(packet, 124)
queue.enqueue(packet, 124)
queue.on_packets_completed(1, 123)
assert queue.queued == 10
assert queue.completed == 5
queue.flush(123)
queue.flush(124)
assert queue.queued == 10
assert queue.completed == 10
queue.enqueue(packet, 123)
queue.on_packets_completed(1, 124)
assert queue.queued == 11
assert queue.completed == 10
queue.on_packets_completed(1000, 123)
assert queue.queued == 11
assert queue.completed == 11
drain_listener = unittest.mock.Mock()
queue.on('flow', drain_listener.on_flow)
queue.enqueue(packet, 123)
assert drain_listener.on_flow.call_count == 0
queue.on_packets_completed(1, 123)
assert drain_listener.on_flow.call_count == 1
queue.enqueue(packet, 123)
queue.enqueue(packet, 123)
queue.enqueue(packet, 123)
queue.flush(123)
assert drain_listener.on_flow.call_count == 1
assert queue.queued == 15
assert queue.completed == 15

View File

@@ -53,7 +53,7 @@ def test_import():
le_audio,
pacs,
pbp,
vcp,
vcs,
)
assert att
@@ -87,7 +87,7 @@ def test_import():
assert le_audio
assert pacs
assert pbp
assert vcp
assert vcs
# -----------------------------------------------------------------------------

View File

@@ -20,12 +20,11 @@ import logging
import os
import pytest
from bumble.core import UUID, BT_L2CAP_PROTOCOL_ID, BT_RFCOMM_PROTOCOL_ID
from bumble.core import UUID, BT_L2CAP_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,
@@ -174,9 +173,10 @@ def test_data_elements() -> None:
# -----------------------------------------------------------------------------
def sdp_records():
def sdp_records(record_count=1):
return {
0x00010001: [
0x00010001
+ i: [
ServiceAttribute(
SDP_SERVICE_RECORD_HANDLE_ATTRIBUTE_ID,
DataElement.unsigned_integer_32(0x00010001),
@@ -200,6 +200,7 @@ def sdp_records():
),
),
]
for i in range(record_count)
}
@@ -216,19 +217,55 @@ async def test_service_search():
devices.devices[0].sdp_server.service_records.update(sdp_records())
# Search for service
client = Client(devices.connections[1])
await client.connect()
services = await client.search_services(
[UUID('E6D55659-C8B4-4B85-96BB-B1143AF6D3AE')]
)
async with Client(devices.connections[1]) as client:
services = await client.search_services(
[UUID('E6D55659-C8B4-4B85-96BB-B1143AF6D3AF')]
)
assert len(services) == 0
# Then
assert services[0] == 0x00010001
services = await client.search_services(
[UUID('E6D55659-C8B4-4B85-96BB-B1143AF6D3AE')]
)
assert len(services) == 1
assert services[0] == 0x00010001
services = await client.search_services(
[BT_L2CAP_PROTOCOL_ID, SDP_PUBLIC_BROWSE_ROOT]
)
assert len(services) == 1
assert services[0] == 0x00010001
services = await client.search_services(
[BT_L2CAP_PROTOCOL_ID, SDP_PUBLIC_BROWSE_ROOT]
)
assert len(services) == 1
assert services[0] == 0x00010001
# -----------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_service_attribute():
async def test_service_search_with_continuation():
# Setup connections
devices = TwoDevices()
await devices.setup_connection()
# Register SDP service
records = sdp_records(100)
devices.devices[0].sdp_server.service_records.update(records)
# Search for service
async with Client(devices.connections[1], mtu=48) as client:
services = await client.search_services(
[UUID('E6D55659-C8B4-4B85-96BB-B1143AF6D3AE')]
)
assert len(services) == len(records)
for i in range(len(records)):
assert services[i] == 0x00010001 + i
# -----------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_service_attributes():
# Setup connections
devices = TwoDevices()
await devices.setup_connection()
@@ -236,15 +273,43 @@ async def test_service_attribute():
# Register SDP service
devices.devices[0].sdp_server.service_records.update(sdp_records())
# Search for service
client = Client(devices.connections[1])
await client.connect()
attributes = await client.get_attributes(
0x00010001, [SDP_SERVICE_RECORD_HANDLE_ATTRIBUTE_ID]
)
# Get attributes
async with Client(devices.connections[1]) as client:
attributes = await client.get_attributes(0x00010001, [1234])
assert len(attributes) == 0
# Then
assert attributes[0].value.value == sdp_records()[0x00010001][0].value.value
attributes = await client.get_attributes(
0x00010001, [SDP_SERVICE_RECORD_HANDLE_ATTRIBUTE_ID]
)
assert len(attributes) == 1
assert attributes[0].value.value == sdp_records()[0x00010001][0].value.value
# -----------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_service_attributes_with_continuation():
# Setup connections
devices = TwoDevices()
await devices.setup_connection()
# Register SDP service
records = {
0x00010001: [
ServiceAttribute(
x,
DataElement.unsigned_integer_32(0x00010001),
)
for x in range(100)
]
}
devices.devices[0].sdp_server.service_records.update(records)
# Get attributes
async with Client(devices.connections[1], mtu=48) as client:
attributes = await client.get_attributes(0x00010001, list(range(100)))
assert len(attributes) == 100
for i, attribute in enumerate(attributes):
assert attribute.id == i
# -----------------------------------------------------------------------------
@@ -255,19 +320,81 @@ async def test_service_search_attribute():
await devices.setup_connection()
# Register SDP service
devices.devices[0].sdp_server.service_records.update(sdp_records())
records = {
0x00010001: [
ServiceAttribute(
4,
DataElement.sequence(
[DataElement.uuid(UUID('E6D55659-C8B4-4B85-96BB-B1143AF6D3AE'))]
),
),
ServiceAttribute(
3,
DataElement.sequence(
[DataElement.uuid(UUID('E6D55659-C8B4-4B85-96BB-B1143AF6D3AE'))]
),
),
ServiceAttribute(
1,
DataElement.sequence(
[DataElement.uuid(UUID('E6D55659-C8B4-4B85-96BB-B1143AF6D3AE'))]
),
),
]
}
devices.devices[0].sdp_server.service_records.update(records)
# Search for service
client = Client(devices.connections[1])
await client.connect()
attributes = await client.search_attributes(
[UUID('E6D55659-C8B4-4B85-96BB-B1143AF6D3AE')], [(0x0000FFFF, 8)]
)
async with Client(devices.connections[1]) as client:
attributes = await client.search_attributes(
[UUID('E6D55659-C8B4-4B85-96BB-B1143AF6D3AE')], [(0, 0xFFFF)]
)
assert len(attributes) == 1
assert len(attributes[0]) == 3
assert attributes[0][0].id == 1
assert attributes[0][1].id == 3
assert attributes[0][2].id == 4
# Then
for expect, actual in zip(attributes, sdp_records().values()):
assert expect.id == actual.id
assert expect.value == actual.value
attributes = await client.search_attributes(
[UUID('E6D55659-C8B4-4B85-96BB-B1143AF6D3AE')], [1, 2, 3]
)
assert len(attributes) == 1
assert len(attributes[0]) == 2
assert attributes[0][0].id == 1
assert attributes[0][1].id == 3
# -----------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_service_search_attribute_with_continuation():
# Setup connections
devices = TwoDevices()
await devices.setup_connection()
# Register SDP service
records = {
0x00010001: [
ServiceAttribute(
x,
DataElement.sequence(
[DataElement.uuid(UUID('E6D55659-C8B4-4B85-96BB-B1143AF6D3AE'))]
),
)
for x in range(100)
]
}
devices.devices[0].sdp_server.service_records.update(records)
# Search for service
async with Client(devices.connections[1], mtu=48) as client:
attributes = await client.search_attributes(
[UUID('E6D55659-C8B4-4B85-96BB-B1143AF6D3AE')], [(0, 0xFFFF)]
)
assert len(attributes) == 1
assert len(attributes[0]) == 100
for i in range(100):
assert attributes[0][i].id == i
# -----------------------------------------------------------------------------
@@ -287,9 +414,12 @@ async def test_client_async_context():
# -----------------------------------------------------------------------------
async def run():
test_data_elements()
await test_service_attribute()
await test_service_attributes()
await test_service_attributes_with_continuation()
await test_service_search()
await test_service_search_with_continuation()
await test_service_search_attribute()
await test_service_search_attribute_with_continuation()
# -----------------------------------------------------------------------------

View File

@@ -20,7 +20,7 @@ import pytest_asyncio
import logging
from bumble import device
from bumble.profiles import vcp
from bumble.profiles import vcs
from .test_utils import TwoDevices
# -----------------------------------------------------------------------------
@@ -34,7 +34,7 @@ logger = logging.getLogger(__name__)
async def vcp_client():
devices = TwoDevices()
devices[0].add_service(
vcp.VolumeControlService(volume_setting=32, muted=1, volume_flags=1)
vcs.VolumeControlService(volume_setting=32, muted=1, volume_flags=1)
)
await devices.setup_connection()
@@ -48,76 +48,76 @@ async def vcp_client():
peer = device.Peer(devices.connections[1])
vcp_client = await peer.discover_service_and_create_proxy(
vcp.VolumeControlServiceProxy
vcs.VolumeControlServiceProxy
)
yield vcp_client
# -----------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_init_service(vcp_client: vcp.VolumeControlServiceProxy):
async def test_init_service(vcp_client: vcs.VolumeControlServiceProxy):
assert (await vcp_client.volume_flags.read_value()) == 1
assert (await vcp_client.volume_state.read_value()) == (32, 1, 0)
assert (await vcp_client.volume_state.read_value()) == vcs.VolumeState(32, 1, 0)
# -----------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_relative_volume_down(vcp_client: vcp.VolumeControlServiceProxy):
async def test_relative_volume_down(vcp_client: vcs.VolumeControlServiceProxy):
await vcp_client.volume_control_point.write_value(
bytes([vcp.VolumeControlPointOpcode.RELATIVE_VOLUME_DOWN, 0])
bytes([vcs.VolumeControlPointOpcode.RELATIVE_VOLUME_DOWN, 0])
)
assert (await vcp_client.volume_state.read_value()) == (16, 1, 1)
assert (await vcp_client.volume_state.read_value()) == vcs.VolumeState(16, 1, 1)
# -----------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_relative_volume_up(vcp_client: vcp.VolumeControlServiceProxy):
async def test_relative_volume_up(vcp_client: vcs.VolumeControlServiceProxy):
await vcp_client.volume_control_point.write_value(
bytes([vcp.VolumeControlPointOpcode.RELATIVE_VOLUME_UP, 0])
bytes([vcs.VolumeControlPointOpcode.RELATIVE_VOLUME_UP, 0])
)
assert (await vcp_client.volume_state.read_value()) == (48, 1, 1)
assert (await vcp_client.volume_state.read_value()) == vcs.VolumeState(48, 1, 1)
# -----------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_unmute_relative_volume_down(vcp_client: vcp.VolumeControlServiceProxy):
async def test_unmute_relative_volume_down(vcp_client: vcs.VolumeControlServiceProxy):
await vcp_client.volume_control_point.write_value(
bytes([vcp.VolumeControlPointOpcode.UNMUTE_RELATIVE_VOLUME_DOWN, 0])
bytes([vcs.VolumeControlPointOpcode.UNMUTE_RELATIVE_VOLUME_DOWN, 0])
)
assert (await vcp_client.volume_state.read_value()) == (16, 0, 1)
assert (await vcp_client.volume_state.read_value()) == vcs.VolumeState(16, 0, 1)
# -----------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_unmute_relative_volume_up(vcp_client: vcp.VolumeControlServiceProxy):
async def test_unmute_relative_volume_up(vcp_client: vcs.VolumeControlServiceProxy):
await vcp_client.volume_control_point.write_value(
bytes([vcp.VolumeControlPointOpcode.UNMUTE_RELATIVE_VOLUME_UP, 0])
bytes([vcs.VolumeControlPointOpcode.UNMUTE_RELATIVE_VOLUME_UP, 0])
)
assert (await vcp_client.volume_state.read_value()) == (48, 0, 1)
assert (await vcp_client.volume_state.read_value()) == vcs.VolumeState(48, 0, 1)
# -----------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_set_absolute_volume(vcp_client: vcp.VolumeControlServiceProxy):
async def test_set_absolute_volume(vcp_client: vcs.VolumeControlServiceProxy):
await vcp_client.volume_control_point.write_value(
bytes([vcp.VolumeControlPointOpcode.SET_ABSOLUTE_VOLUME, 0, 255])
bytes([vcs.VolumeControlPointOpcode.SET_ABSOLUTE_VOLUME, 0, 255])
)
assert (await vcp_client.volume_state.read_value()) == (255, 1, 1)
assert (await vcp_client.volume_state.read_value()) == vcs.VolumeState(255, 1, 1)
# -----------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_mute(vcp_client: vcp.VolumeControlServiceProxy):
async def test_mute(vcp_client: vcs.VolumeControlServiceProxy):
await vcp_client.volume_control_point.write_value(
bytes([vcp.VolumeControlPointOpcode.MUTE, 0])
bytes([vcs.VolumeControlPointOpcode.MUTE, 0])
)
assert (await vcp_client.volume_state.read_value()) == (32, 1, 0)
assert (await vcp_client.volume_state.read_value()) == vcs.VolumeState(32, 1, 0)
# -----------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_unmute(vcp_client: vcp.VolumeControlServiceProxy):
async def test_unmute(vcp_client: vcs.VolumeControlServiceProxy):
await vcp_client.volume_control_point.write_value(
bytes([vcp.VolumeControlPointOpcode.UNMUTE, 0])
bytes([vcs.VolumeControlPointOpcode.UNMUTE, 0])
)
assert (await vcp_client.volume_state.read_value()) == (32, 0, 1)
assert (await vcp_client.volume_state.read_value()) == vcs.VolumeState(32, 0, 1)

View File

@@ -32,9 +32,8 @@ from bumble.profiles.vocs import (
SetVolumeOffsetOpCode,
VolumeOffsetControlServiceProxy,
VolumeOffsetState,
VocsAudioLocation,
)
from bumble.profiles.vcp import VolumeControlService, VolumeControlServiceProxy
from bumble.profiles.vcs import VolumeControlService, VolumeControlServiceProxy
from bumble.profiles.bap import AudioLocation
from .test_utils import TwoDevices
@@ -81,9 +80,7 @@ async def test_init_service(vocs_client: VolumeOffsetControlServiceProxy):
volume_offset=0,
change_counter=0,
)
assert await vocs_client.audio_location.read_value() == VocsAudioLocation(
audio_location=AudioLocation.NOT_ALLOWED
)
assert await vocs_client.audio_location.read_value() == AudioLocation.NOT_ALLOWED
description = await vocs_client.audio_output_description.read_value()
assert description == ''
@@ -162,11 +159,9 @@ async def test_set_volume_offset(vocs_client: VolumeOffsetControlServiceProxy):
@pytest.mark.asyncio
async def test_set_audio_channel_location(vocs_client: VolumeOffsetControlServiceProxy):
new_audio_location = VocsAudioLocation(audio_location=AudioLocation.FRONT_LEFT)
new_audio_location = AudioLocation.FRONT_LEFT
await vocs_client.audio_location.write_value(
struct.pack('<I', new_audio_location.audio_location)
)
await vocs_client.audio_location.write_value(new_audio_location)
location = await vocs_client.audio_location.read_value()
assert location == new_audio_location