mirror of
https://github.com/google/bumble.git
synced 2026-04-17 00:35:31 +00:00
Compare commits
41 Commits
v0.0.206
...
gbg/auraca
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8e509c18c9 | ||
|
|
cc21ed27c7 | ||
|
|
b932bafe6d | ||
|
|
4e35aba033 | ||
|
|
0060ee8ee2 | ||
|
|
3263d71f54 | ||
|
|
f321143837 | ||
|
|
bac6f5baaf | ||
|
|
e027bcb57a | ||
|
|
eeb9de31ed | ||
|
|
2c3af5b2bb | ||
|
|
dfb92e8ed1 | ||
|
|
73d2b54e30 | ||
|
|
8315a60f24 | ||
| 185d5fd577 | |||
|
|
ae5f9cf690 | ||
|
|
4b66a38fe6 | ||
|
|
f526f549ee | ||
|
|
8761129677 | ||
|
|
3f6f036270 | ||
|
|
859bb0609f | ||
|
|
5f2d24570e | ||
|
|
dbf94c8f3e | ||
|
|
b6adc29365 | ||
|
|
5caa7bfa90 | ||
|
|
f39d706fa0 | ||
|
|
c02c1f33d2 | ||
|
|
33435c2980 | ||
|
|
c08449d9db | ||
|
|
3c8718bb5b | ||
|
|
26e87f09fe | ||
|
|
7f5e0d190e | ||
|
|
efae307b3d | ||
|
|
9756572c93 | ||
|
|
d6100755b1 | ||
|
|
f368b5e518 | ||
|
|
5293d32dc6 | ||
|
|
6d9a0bf4e1 | ||
|
|
3c7b5df7c5 | ||
|
|
70141c0439 | ||
|
|
55d3fd90f5 |
473
apps/auracast.py
473
apps/auracast.py
@@ -18,14 +18,22 @@
|
||||
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 (
|
||||
Any,
|
||||
AsyncGenerator,
|
||||
Coroutine,
|
||||
Deque,
|
||||
Optional,
|
||||
Tuple,
|
||||
)
|
||||
|
||||
import click
|
||||
import pyee
|
||||
@@ -33,8 +41,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 +59,6 @@ import bumble.device
|
||||
import bumble.transport
|
||||
import bumble.utils
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Logging
|
||||
# -----------------------------------------------------------------------------
|
||||
@@ -62,6 +72,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)
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
@@ -91,11 +126,9 @@ class BroadcastScanner(pyee.EventEmitter):
|
||||
def update(self, advertisement: bumble.device.Advertisement) -> None:
|
||||
self.rssi = advertisement.rssi
|
||||
for service_data in advertisement.data.get_all(
|
||||
core.AdvertisingData.SERVICE_DATA
|
||||
core.AdvertisingData.Type.SERVICE_DATA_16_BIT_UUID
|
||||
):
|
||||
assert isinstance(service_data, tuple)
|
||||
service_uuid, data = service_data
|
||||
assert isinstance(data, bytes)
|
||||
|
||||
if service_uuid == gatt.GATT_PUBLIC_BROADCAST_ANNOUNCEMENT_SERVICE:
|
||||
self.public_broadcast_announcement = (
|
||||
@@ -109,16 +142,14 @@ class BroadcastScanner(pyee.EventEmitter):
|
||||
)
|
||||
continue
|
||||
|
||||
self.appearance = advertisement.data.get( # type: ignore[assignment]
|
||||
core.AdvertisingData.APPEARANCE
|
||||
self.appearance = advertisement.data.get(
|
||||
core.AdvertisingData.Type.APPEARANCE
|
||||
)
|
||||
|
||||
if manufacturer_data := advertisement.data.get(
|
||||
core.AdvertisingData.MANUFACTURER_SPECIFIC_DATA
|
||||
core.AdvertisingData.Type.MANUFACTURER_SPECIFIC_DATA
|
||||
):
|
||||
assert isinstance(manufacturer_data, tuple)
|
||||
company_id = cast(int, manufacturer_data[0])
|
||||
data = cast(bytes, manufacturer_data[1])
|
||||
company_id, data = manufacturer_data
|
||||
self.manufacturer_data = (
|
||||
company_ids.COMPANY_IDENTIFIERS.get(
|
||||
company_id, f'0x{company_id:04X}'
|
||||
@@ -156,18 +187,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 +214,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:
|
||||
@@ -231,11 +266,9 @@ class BroadcastScanner(pyee.EventEmitter):
|
||||
return
|
||||
|
||||
for service_data in advertisement.data.get_all(
|
||||
core.AdvertisingData.SERVICE_DATA
|
||||
core.AdvertisingData.Type.SERVICE_DATA_16_BIT_UUID
|
||||
):
|
||||
assert isinstance(service_data, tuple)
|
||||
service_uuid, data = service_data
|
||||
assert isinstance(data, bytes)
|
||||
|
||||
if service_uuid == gatt.GATT_BASIC_AUDIO_ANNOUNCEMENT_SERVICE:
|
||||
self.basic_audio_announcement = (
|
||||
@@ -276,24 +309,23 @@ class BroadcastScanner(pyee.EventEmitter):
|
||||
def on_advertisement(self, advertisement: bumble.device.Advertisement) -> None:
|
||||
if not (
|
||||
ads := advertisement.data.get_all(
|
||||
core.AdvertisingData.SERVICE_DATA_16_BIT_UUID
|
||||
core.AdvertisingData.Type.SERVICE_DATA_16_BIT_UUID
|
||||
)
|
||||
) or not (
|
||||
broadcast_audio_announcement := next(
|
||||
(
|
||||
ad
|
||||
for ad in ads
|
||||
if isinstance(ad, tuple)
|
||||
and ad[0] == gatt.GATT_BROADCAST_AUDIO_ANNOUNCEMENT_SERVICE
|
||||
if ad[0] == gatt.GATT_BROADCAST_AUDIO_ANNOUNCEMENT_SERVICE
|
||||
),
|
||||
None,
|
||||
)
|
||||
):
|
||||
return
|
||||
|
||||
broadcast_name = advertisement.data.get(core.AdvertisingData.BROADCAST_NAME)
|
||||
assert isinstance(broadcast_name, str) or broadcast_name is None
|
||||
assert isinstance(broadcast_audio_announcement[1], bytes)
|
||||
broadcast_name = advertisement.data.get_all(
|
||||
core.AdvertisingData.Type.BROADCAST_NAME
|
||||
)
|
||||
|
||||
if broadcast := self.broadcasts.get(advertisement.address):
|
||||
broadcast.update(advertisement)
|
||||
@@ -301,7 +333,7 @@ class BroadcastScanner(pyee.EventEmitter):
|
||||
|
||||
bumble.utils.AsyncRunner.spawn(
|
||||
self.on_new_broadcast(
|
||||
broadcast_name,
|
||||
broadcast_name[0] if broadcast_name else None,
|
||||
advertisement,
|
||||
bap.BroadcastAudioAnnouncement.from_bytes(
|
||||
broadcast_audio_announcement[1]
|
||||
@@ -494,7 +526,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 +657,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 +684,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 +735,87 @@ 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 bis_link.setup_data_path(
|
||||
direction=bis_link.Direction.CONTROLLER_TO_HOST
|
||||
)
|
||||
|
||||
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 +854,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 +883,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,47 +896,88 @@ 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')
|
||||
|
||||
def on_flow(packet_queue):
|
||||
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'\rPACKETS: pending={packet_queue.pending}, '
|
||||
f'queued={packet_queue.queued}, completed={packet_queue.completed}',
|
||||
end='',
|
||||
f'Encoding with {lc3_frame_samples} '
|
||||
f'PCM samples per {lc3_frame_size} byte frame'
|
||||
)
|
||||
|
||||
packet_queue = None
|
||||
for bis_link in big.bis_links:
|
||||
await bis_link.setup_data_path(
|
||||
direction=bis_link.Direction.HOST_TO_CONTROLLER
|
||||
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
|
||||
),
|
||||
),
|
||||
)
|
||||
if packet_queue is None:
|
||||
packet_queue = bis_link.data_packet_queue
|
||||
for bis_link in big.bis_links:
|
||||
print(f'Setup ISO for BIS {bis_link.handle}')
|
||||
await bis_link.setup_data_path(
|
||||
direction=bis_link.Direction.HOST_TO_CONTROLLER
|
||||
)
|
||||
|
||||
if packet_queue:
|
||||
packet_queue.on('flow', lambda: on_flow(packet_queue))
|
||||
iso_queues = [
|
||||
bumble.device.IsoPacketStream(bis_link, 64)
|
||||
for bis_link in big.bis_links
|
||||
]
|
||||
|
||||
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)
|
||||
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:
|
||||
@@ -917,7 +1046,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))
|
||||
|
||||
|
||||
@@ -932,7 +1061,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',
|
||||
@@ -954,16 +1100,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',
|
||||
@@ -974,18 +1161,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,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
393
apps/bench.py
393
apps/bench.py
@@ -16,6 +16,7 @@
|
||||
# Imports
|
||||
# -----------------------------------------------------------------------------
|
||||
import asyncio
|
||||
import dataclasses
|
||||
import enum
|
||||
import logging
|
||||
import os
|
||||
@@ -97,49 +98,22 @@ DEFAULT_RFCOMM_MTU = 2048
|
||||
# -----------------------------------------------------------------------------
|
||||
# Utils
|
||||
# -----------------------------------------------------------------------------
|
||||
def parse_packet(packet):
|
||||
if len(packet) < 1:
|
||||
logging.info(
|
||||
color(f'!!! Packet too short (got {len(packet)} bytes, need >= 1)', 'red')
|
||||
)
|
||||
raise ValueError('packet too short')
|
||||
|
||||
try:
|
||||
packet_type = PacketType(packet[0])
|
||||
except ValueError:
|
||||
logging.info(color(f'!!! Invalid packet type 0x{packet[0]:02X}', 'red'))
|
||||
raise
|
||||
|
||||
return (packet_type, packet[1:])
|
||||
|
||||
|
||||
def parse_packet_sequence(packet_data):
|
||||
if len(packet_data) < 5:
|
||||
logging.info(
|
||||
color(
|
||||
f'!!!Packet too short (got {len(packet_data)} bytes, need >= 5)',
|
||||
'red',
|
||||
)
|
||||
)
|
||||
raise ValueError('packet too short')
|
||||
return struct.unpack_from('>bI', packet_data, 0)
|
||||
|
||||
|
||||
def le_phy_name(phy_id):
|
||||
return {HCI_LE_1M_PHY: '1M', HCI_LE_2M_PHY: '2M', HCI_LE_CODED_PHY: 'CODED'}.get(
|
||||
phy_id, HCI_Constant.le_phy_name(phy_id)
|
||||
)
|
||||
|
||||
|
||||
def print_connection_phy(phy):
|
||||
logging.info(
|
||||
color('@@@ PHY: ', 'yellow') + f'TX:{le_phy_name(phy.tx_phy)}/'
|
||||
f'RX:{le_phy_name(phy.rx_phy)}'
|
||||
)
|
||||
|
||||
|
||||
def print_connection(connection):
|
||||
params = []
|
||||
if connection.transport == BT_LE_TRANSPORT:
|
||||
params.append(
|
||||
'PHY='
|
||||
f'TX:{le_phy_name(connection.phy.tx_phy)}/'
|
||||
f'RX:{le_phy_name(connection.phy.rx_phy)}'
|
||||
)
|
||||
|
||||
params.append(
|
||||
'DL=('
|
||||
f'TX:{connection.data_length[0]}/{connection.data_length[1]},'
|
||||
@@ -225,13 +199,135 @@ async def switch_roles(connection, role):
|
||||
logging.info(f'{color("### Role switch failed:", "red")} {error}')
|
||||
|
||||
|
||||
class PacketType(enum.IntEnum):
|
||||
RESET = 0
|
||||
SEQUENCE = 1
|
||||
ACK = 2
|
||||
# -----------------------------------------------------------------------------
|
||||
# Packet
|
||||
# -----------------------------------------------------------------------------
|
||||
@dataclasses.dataclass
|
||||
class Packet:
|
||||
class PacketType(enum.IntEnum):
|
||||
RESET = 0
|
||||
SEQUENCE = 1
|
||||
ACK = 2
|
||||
|
||||
class PacketFlags(enum.IntFlag):
|
||||
LAST = 1
|
||||
|
||||
packet_type: PacketType
|
||||
flags: PacketFlags = PacketFlags(0)
|
||||
sequence: int = 0
|
||||
timestamp: int = 0
|
||||
payload: bytes = b""
|
||||
|
||||
@classmethod
|
||||
def from_bytes(cls, data: bytes):
|
||||
if len(data) < 1:
|
||||
logging.warning(
|
||||
color(f'!!! Packet too short (got {len(data)} bytes, need >= 1)', 'red')
|
||||
)
|
||||
raise ValueError('packet too short')
|
||||
|
||||
try:
|
||||
packet_type = cls.PacketType(data[0])
|
||||
except ValueError:
|
||||
logging.warning(color(f'!!! Invalid packet type 0x{data[0]:02X}', 'red'))
|
||||
raise
|
||||
|
||||
if packet_type == cls.PacketType.RESET:
|
||||
return cls(packet_type)
|
||||
|
||||
flags = cls.PacketFlags(data[1])
|
||||
(sequence,) = struct.unpack_from("<I", data, 2)
|
||||
|
||||
if packet_type == cls.PacketType.ACK:
|
||||
if len(data) < 6:
|
||||
logging.warning(
|
||||
color(
|
||||
f'!!! Packet too short (got {len(data)} bytes, need >= 6)',
|
||||
'red',
|
||||
)
|
||||
)
|
||||
return cls(packet_type, flags, sequence)
|
||||
|
||||
if len(data) < 10:
|
||||
logging.warning(
|
||||
color(
|
||||
f'!!! Packet too short (got {len(data)} bytes, need >= 10)', 'red'
|
||||
)
|
||||
)
|
||||
raise ValueError('packet too short')
|
||||
|
||||
(timestamp,) = struct.unpack_from("<I", data, 6)
|
||||
return cls(packet_type, flags, sequence, timestamp, data[10:])
|
||||
|
||||
def __bytes__(self):
|
||||
if self.packet_type == self.PacketType.RESET:
|
||||
return bytes([self.packet_type])
|
||||
|
||||
if self.packet_type == self.PacketType.ACK:
|
||||
return struct.pack("<BBI", self.packet_type, self.flags, self.sequence)
|
||||
|
||||
return (
|
||||
struct.pack(
|
||||
"<BBII", self.packet_type, self.flags, self.sequence, self.timestamp
|
||||
)
|
||||
+ self.payload
|
||||
)
|
||||
|
||||
|
||||
PACKET_FLAG_LAST = 1
|
||||
# -----------------------------------------------------------------------------
|
||||
# Jitter Stats
|
||||
# -----------------------------------------------------------------------------
|
||||
class JitterStats:
|
||||
def __init__(self):
|
||||
self.reset()
|
||||
|
||||
def reset(self):
|
||||
self.packets = []
|
||||
self.receive_times = []
|
||||
self.jitter = []
|
||||
|
||||
def on_packet_received(self, packet):
|
||||
now = time.time()
|
||||
self.packets.append(packet)
|
||||
self.receive_times.append(now)
|
||||
|
||||
if packet.timestamp and len(self.packets) > 1:
|
||||
expected_time = (
|
||||
self.receive_times[0]
|
||||
+ (packet.timestamp - self.packets[0].timestamp) / 1000000
|
||||
)
|
||||
jitter = now - expected_time
|
||||
else:
|
||||
jitter = 0.0
|
||||
|
||||
self.jitter.append(jitter)
|
||||
return jitter
|
||||
|
||||
def show_stats(self):
|
||||
if len(self.jitter) < 3:
|
||||
return
|
||||
average = sum(self.jitter) / len(self.jitter)
|
||||
adjusted = [jitter - average for jitter in self.jitter]
|
||||
|
||||
log_stats('Jitter (signed)', adjusted, 3)
|
||||
log_stats('Jitter (absolute)', [abs(jitter) for jitter in adjusted], 3)
|
||||
|
||||
# Show a histogram
|
||||
bin_count = 20
|
||||
bins = [0] * bin_count
|
||||
interval_min = min(adjusted)
|
||||
interval_max = max(adjusted)
|
||||
interval_range = interval_max - interval_min
|
||||
bin_thresholds = [
|
||||
interval_min + i * (interval_range / bin_count) for i in range(bin_count)
|
||||
]
|
||||
for jitter in adjusted:
|
||||
for i in reversed(range(bin_count)):
|
||||
if jitter >= bin_thresholds[i]:
|
||||
bins[i] += 1
|
||||
break
|
||||
for i in range(bin_count):
|
||||
logging.info(f'@@@ >= {bin_thresholds[i]:.4f}: {bins[i]}')
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
@@ -281,19 +377,37 @@ class Sender:
|
||||
await asyncio.sleep(self.tx_start_delay)
|
||||
|
||||
logging.info(color('=== Sending RESET', 'magenta'))
|
||||
await self.packet_io.send_packet(bytes([PacketType.RESET]))
|
||||
await self.packet_io.send_packet(
|
||||
bytes(Packet(packet_type=Packet.PacketType.RESET))
|
||||
)
|
||||
|
||||
self.start_time = time.time()
|
||||
self.bytes_sent = 0
|
||||
for tx_i in range(self.tx_packet_count):
|
||||
packet_flags = (
|
||||
PACKET_FLAG_LAST if tx_i == self.tx_packet_count - 1 else 0
|
||||
if self.pace > 0:
|
||||
# Wait until it is time to send the next packet
|
||||
target_time = self.start_time + (tx_i * self.pace / 1000)
|
||||
now = time.time()
|
||||
if now < target_time:
|
||||
await asyncio.sleep(target_time - now)
|
||||
else:
|
||||
await self.packet_io.drain()
|
||||
|
||||
packet = bytes(
|
||||
Packet(
|
||||
packet_type=Packet.PacketType.SEQUENCE,
|
||||
flags=(
|
||||
Packet.PacketFlags.LAST
|
||||
if tx_i == self.tx_packet_count - 1
|
||||
else 0
|
||||
),
|
||||
sequence=tx_i,
|
||||
timestamp=int((time.time() - self.start_time) * 1000000),
|
||||
payload=bytes(
|
||||
self.tx_packet_size - 10 - self.packet_io.overhead_size
|
||||
),
|
||||
)
|
||||
)
|
||||
packet = struct.pack(
|
||||
'>bbI',
|
||||
PacketType.SEQUENCE,
|
||||
packet_flags,
|
||||
tx_i,
|
||||
) + bytes(self.tx_packet_size - 6 - self.packet_io.overhead_size)
|
||||
logging.info(
|
||||
color(
|
||||
f'Sending packet {tx_i}: {self.tx_packet_size} bytes', 'yellow'
|
||||
@@ -302,14 +416,6 @@ class Sender:
|
||||
self.bytes_sent += len(packet)
|
||||
await self.packet_io.send_packet(packet)
|
||||
|
||||
if self.pace is None:
|
||||
continue
|
||||
|
||||
if self.pace > 0:
|
||||
await asyncio.sleep(self.pace / 1000)
|
||||
else:
|
||||
await self.packet_io.drain()
|
||||
|
||||
await self.done.wait()
|
||||
|
||||
run_counter = f'[{run + 1} of {self.repeat + 1}]' if self.repeat else ''
|
||||
@@ -321,13 +427,13 @@ class Sender:
|
||||
if self.repeat:
|
||||
logging.info(color('--- End of runs', 'blue'))
|
||||
|
||||
def on_packet_received(self, packet):
|
||||
def on_packet_received(self, data):
|
||||
try:
|
||||
packet_type, _ = parse_packet(packet)
|
||||
packet = Packet.from_bytes(data)
|
||||
except ValueError:
|
||||
return
|
||||
|
||||
if packet_type == PacketType.ACK:
|
||||
if packet.packet_type == Packet.PacketType.ACK:
|
||||
elapsed = time.time() - self.start_time
|
||||
average_tx_speed = self.bytes_sent / elapsed
|
||||
self.stats.append(average_tx_speed)
|
||||
@@ -350,52 +456,53 @@ class Receiver:
|
||||
last_timestamp: float
|
||||
|
||||
def __init__(self, packet_io, linger):
|
||||
self.reset()
|
||||
self.jitter_stats = JitterStats()
|
||||
self.packet_io = packet_io
|
||||
self.packet_io.packet_listener = self
|
||||
self.linger = linger
|
||||
self.done = asyncio.Event()
|
||||
self.reset()
|
||||
|
||||
def reset(self):
|
||||
self.expected_packet_index = 0
|
||||
self.measurements = [(time.time(), 0)]
|
||||
self.total_bytes_received = 0
|
||||
self.jitter_stats.reset()
|
||||
|
||||
def on_packet_received(self, packet):
|
||||
def on_packet_received(self, data):
|
||||
try:
|
||||
packet_type, packet_data = parse_packet(packet)
|
||||
packet = Packet.from_bytes(data)
|
||||
except ValueError:
|
||||
logging.exception("invalid packet")
|
||||
return
|
||||
|
||||
if packet_type == PacketType.RESET:
|
||||
if packet.packet_type == Packet.PacketType.RESET:
|
||||
logging.info(color('=== Received RESET', 'magenta'))
|
||||
self.reset()
|
||||
return
|
||||
|
||||
try:
|
||||
packet_flags, packet_index = parse_packet_sequence(packet_data)
|
||||
except ValueError:
|
||||
return
|
||||
jitter = self.jitter_stats.on_packet_received(packet)
|
||||
logging.info(
|
||||
f'<<< Received packet {packet_index}: '
|
||||
f'flags=0x{packet_flags:02X}, '
|
||||
f'{len(packet) + self.packet_io.overhead_size} bytes'
|
||||
f'<<< Received packet {packet.sequence}: '
|
||||
f'flags={packet.flags}, '
|
||||
f'jitter={jitter:.4f}, '
|
||||
f'{len(data) + self.packet_io.overhead_size} bytes',
|
||||
)
|
||||
|
||||
if packet_index != self.expected_packet_index:
|
||||
if packet.sequence != self.expected_packet_index:
|
||||
logging.info(
|
||||
color(
|
||||
f'!!! Unexpected packet, expected {self.expected_packet_index} '
|
||||
f'but received {packet_index}'
|
||||
f'but received {packet.sequence}'
|
||||
)
|
||||
)
|
||||
|
||||
now = time.time()
|
||||
elapsed_since_start = now - self.measurements[0][0]
|
||||
elapsed_since_last = now - self.measurements[-1][0]
|
||||
self.measurements.append((now, len(packet)))
|
||||
self.total_bytes_received += len(packet)
|
||||
instant_rx_speed = len(packet) / elapsed_since_last
|
||||
self.measurements.append((now, len(data)))
|
||||
self.total_bytes_received += len(data)
|
||||
instant_rx_speed = len(data) / elapsed_since_last
|
||||
average_rx_speed = self.total_bytes_received / elapsed_since_start
|
||||
window = self.measurements[-64:]
|
||||
windowed_rx_speed = sum(measurement[1] for measurement in window[1:]) / (
|
||||
@@ -411,15 +518,17 @@ class Receiver:
|
||||
)
|
||||
)
|
||||
|
||||
self.expected_packet_index = packet_index + 1
|
||||
self.expected_packet_index = packet.sequence + 1
|
||||
|
||||
if packet_flags & PACKET_FLAG_LAST:
|
||||
if packet.flags & Packet.PacketFlags.LAST:
|
||||
AsyncRunner.spawn(
|
||||
self.packet_io.send_packet(
|
||||
struct.pack('>bbI', PacketType.ACK, packet_flags, packet_index)
|
||||
bytes(Packet(Packet.PacketType.ACK, packet.flags, packet.sequence))
|
||||
)
|
||||
)
|
||||
logging.info(color('@@@ Received last packet', 'green'))
|
||||
self.jitter_stats.show_stats()
|
||||
|
||||
if not self.linger:
|
||||
self.done.set()
|
||||
|
||||
@@ -479,25 +588,32 @@ class Ping:
|
||||
await asyncio.sleep(self.tx_start_delay)
|
||||
|
||||
logging.info(color('=== Sending RESET', 'magenta'))
|
||||
await self.packet_io.send_packet(bytes([PacketType.RESET]))
|
||||
await self.packet_io.send_packet(bytes(Packet(Packet.PacketType.RESET)))
|
||||
|
||||
packet_interval = self.pace / 1000
|
||||
start_time = time.time()
|
||||
self.next_expected_packet_index = 0
|
||||
for i in range(self.tx_packet_count):
|
||||
target_time = start_time + (i * packet_interval)
|
||||
target_time = start_time + (i * self.pace / 1000)
|
||||
now = time.time()
|
||||
if now < target_time:
|
||||
await asyncio.sleep(target_time - now)
|
||||
now = time.time()
|
||||
|
||||
packet = struct.pack(
|
||||
'>bbI',
|
||||
PacketType.SEQUENCE,
|
||||
(PACKET_FLAG_LAST if i == self.tx_packet_count - 1 else 0),
|
||||
i,
|
||||
) + bytes(self.tx_packet_size - 6)
|
||||
packet = bytes(
|
||||
Packet(
|
||||
packet_type=Packet.PacketType.SEQUENCE,
|
||||
flags=(
|
||||
Packet.PacketFlags.LAST
|
||||
if i == self.tx_packet_count - 1
|
||||
else 0
|
||||
),
|
||||
sequence=i,
|
||||
timestamp=int((now - start_time) * 1000000),
|
||||
payload=bytes(self.tx_packet_size - 10),
|
||||
)
|
||||
)
|
||||
logging.info(color(f'Sending packet {i}', 'yellow'))
|
||||
self.ping_times.append(time.time())
|
||||
self.ping_times.append(now)
|
||||
await self.packet_io.send_packet(packet)
|
||||
|
||||
await self.done.wait()
|
||||
@@ -531,40 +647,35 @@ class Ping:
|
||||
if self.repeat:
|
||||
logging.info(color('--- End of runs', 'blue'))
|
||||
|
||||
def on_packet_received(self, packet):
|
||||
def on_packet_received(self, data):
|
||||
try:
|
||||
packet_type, packet_data = parse_packet(packet)
|
||||
packet = Packet.from_bytes(data)
|
||||
except ValueError:
|
||||
return
|
||||
|
||||
try:
|
||||
packet_flags, packet_index = parse_packet_sequence(packet_data)
|
||||
except ValueError:
|
||||
return
|
||||
|
||||
if packet_type == PacketType.ACK:
|
||||
elapsed = time.time() - self.ping_times[packet_index]
|
||||
if packet.packet_type == Packet.PacketType.ACK:
|
||||
elapsed = time.time() - self.ping_times[packet.sequence]
|
||||
rtt = elapsed * 1000
|
||||
self.rtts.append(rtt)
|
||||
logging.info(
|
||||
color(
|
||||
f'<<< Received ACK [{packet_index}], RTT={rtt:.2f}ms',
|
||||
f'<<< Received ACK [{packet.sequence}], RTT={rtt:.2f}ms',
|
||||
'green',
|
||||
)
|
||||
)
|
||||
|
||||
if packet_index == self.next_expected_packet_index:
|
||||
if packet.sequence == self.next_expected_packet_index:
|
||||
self.next_expected_packet_index += 1
|
||||
else:
|
||||
logging.info(
|
||||
color(
|
||||
f'!!! Unexpected packet, '
|
||||
f'expected {self.next_expected_packet_index} '
|
||||
f'but received {packet_index}'
|
||||
f'but received {packet.sequence}'
|
||||
)
|
||||
)
|
||||
|
||||
if packet_flags & PACKET_FLAG_LAST:
|
||||
if packet.flags & Packet.PacketFlags.LAST:
|
||||
self.done.set()
|
||||
return
|
||||
|
||||
@@ -576,89 +687,56 @@ class Pong:
|
||||
expected_packet_index: int
|
||||
|
||||
def __init__(self, packet_io, linger):
|
||||
self.reset()
|
||||
self.jitter_stats = JitterStats()
|
||||
self.packet_io = packet_io
|
||||
self.packet_io.packet_listener = self
|
||||
self.linger = linger
|
||||
self.done = asyncio.Event()
|
||||
self.reset()
|
||||
|
||||
def reset(self):
|
||||
self.expected_packet_index = 0
|
||||
self.receive_times = []
|
||||
|
||||
def on_packet_received(self, packet):
|
||||
self.receive_times.append(time.time())
|
||||
self.jitter_stats.reset()
|
||||
|
||||
def on_packet_received(self, data):
|
||||
try:
|
||||
packet_type, packet_data = parse_packet(packet)
|
||||
packet = Packet.from_bytes(data)
|
||||
except ValueError:
|
||||
return
|
||||
|
||||
if packet_type == PacketType.RESET:
|
||||
if packet.packet_type == Packet.PacketType.RESET:
|
||||
logging.info(color('=== Received RESET', 'magenta'))
|
||||
self.reset()
|
||||
return
|
||||
|
||||
try:
|
||||
packet_flags, packet_index = parse_packet_sequence(packet_data)
|
||||
except ValueError:
|
||||
return
|
||||
interval = (
|
||||
self.receive_times[-1] - self.receive_times[-2]
|
||||
if len(self.receive_times) >= 2
|
||||
else 0
|
||||
)
|
||||
jitter = self.jitter_stats.on_packet_received(packet)
|
||||
logging.info(
|
||||
color(
|
||||
f'<<< Received packet {packet_index}: '
|
||||
f'flags=0x{packet_flags:02X}, {len(packet)} bytes, '
|
||||
f'interval={interval:.4f}',
|
||||
f'<<< Received packet {packet.sequence}: '
|
||||
f'flags={packet.flags}, {len(data)} bytes, '
|
||||
f'jitter={jitter:.4f}',
|
||||
'green',
|
||||
)
|
||||
)
|
||||
|
||||
if packet_index != self.expected_packet_index:
|
||||
if packet.sequence != self.expected_packet_index:
|
||||
logging.info(
|
||||
color(
|
||||
f'!!! Unexpected packet, expected {self.expected_packet_index} '
|
||||
f'but received {packet_index}'
|
||||
f'but received {packet.sequence}'
|
||||
)
|
||||
)
|
||||
|
||||
self.expected_packet_index = packet_index + 1
|
||||
self.expected_packet_index = packet.sequence + 1
|
||||
|
||||
AsyncRunner.spawn(
|
||||
self.packet_io.send_packet(
|
||||
struct.pack('>bbI', PacketType.ACK, packet_flags, packet_index)
|
||||
bytes(Packet(Packet.PacketType.ACK, packet.flags, packet.sequence))
|
||||
)
|
||||
)
|
||||
|
||||
if packet_flags & PACKET_FLAG_LAST:
|
||||
if len(self.receive_times) >= 3:
|
||||
# Show basic stats
|
||||
intervals = [
|
||||
self.receive_times[i + 1] - self.receive_times[i]
|
||||
for i in range(len(self.receive_times) - 1)
|
||||
]
|
||||
log_stats('Packet intervals', intervals, 3)
|
||||
|
||||
# Show a histogram
|
||||
bin_count = 20
|
||||
bins = [0] * bin_count
|
||||
interval_min = min(intervals)
|
||||
interval_max = max(intervals)
|
||||
interval_range = interval_max - interval_min
|
||||
bin_thresholds = [
|
||||
interval_min + i * (interval_range / bin_count)
|
||||
for i in range(bin_count)
|
||||
]
|
||||
for interval in intervals:
|
||||
for i in reversed(range(bin_count)):
|
||||
if interval >= bin_thresholds[i]:
|
||||
bins[i] += 1
|
||||
break
|
||||
for i in range(bin_count):
|
||||
logging.info(f'@@@ >= {bin_thresholds[i]:.4f}: {bins[i]}')
|
||||
if packet.flags & Packet.PacketFlags.LAST:
|
||||
self.jitter_stats.show_stats()
|
||||
|
||||
if not self.linger:
|
||||
self.done.set()
|
||||
@@ -1211,6 +1289,8 @@ class Central(Connection.Listener):
|
||||
logging.info(color('### Connected', 'cyan'))
|
||||
self.connection.listener = self
|
||||
print_connection(self.connection)
|
||||
phy = await self.connection.get_phy()
|
||||
print_connection_phy(phy)
|
||||
|
||||
# Switch roles if needed.
|
||||
if self.role_switch:
|
||||
@@ -1268,8 +1348,8 @@ class Central(Connection.Listener):
|
||||
def on_connection_parameters_update(self):
|
||||
print_connection(self.connection)
|
||||
|
||||
def on_connection_phy_update(self):
|
||||
print_connection(self.connection)
|
||||
def on_connection_phy_update(self, phy):
|
||||
print_connection_phy(phy)
|
||||
|
||||
def on_connection_att_mtu_update(self):
|
||||
print_connection(self.connection)
|
||||
@@ -1395,8 +1475,8 @@ class Peripheral(Device.Listener, Connection.Listener):
|
||||
def on_connection_parameters_update(self):
|
||||
print_connection(self.connection)
|
||||
|
||||
def on_connection_phy_update(self):
|
||||
print_connection(self.connection)
|
||||
def on_connection_phy_update(self, phy):
|
||||
print_connection_phy(phy)
|
||||
|
||||
def on_connection_att_mtu_update(self):
|
||||
print_connection(self.connection)
|
||||
@@ -1471,7 +1551,7 @@ def create_mode_factory(ctx, default_mode):
|
||||
def create_scenario_factory(ctx, default_scenario):
|
||||
scenario = ctx.obj['scenario']
|
||||
if scenario is None:
|
||||
scenarion = default_scenario
|
||||
scenario = default_scenario
|
||||
|
||||
def create_scenario(packet_io):
|
||||
if scenario == 'send':
|
||||
@@ -1530,6 +1610,7 @@ def create_scenario_factory(ctx, default_scenario):
|
||||
'--att-mtu',
|
||||
metavar='MTU',
|
||||
type=click.IntRange(23, 517),
|
||||
default=517,
|
||||
help='GATT MTU (gatt-client mode)',
|
||||
)
|
||||
@click.option(
|
||||
@@ -1605,7 +1686,7 @@ def create_scenario_factory(ctx, default_scenario):
|
||||
'--packet-size',
|
||||
'-s',
|
||||
metavar='SIZE',
|
||||
type=click.IntRange(8, 8192),
|
||||
type=click.IntRange(10, 8192),
|
||||
default=500,
|
||||
help='Packet size (send or ping scenario)',
|
||||
)
|
||||
|
||||
@@ -22,7 +22,6 @@
|
||||
import asyncio
|
||||
import logging
|
||||
import os
|
||||
import random
|
||||
import re
|
||||
import humanize
|
||||
from typing import Optional, Union
|
||||
@@ -57,7 +56,13 @@ from bumble import __version__
|
||||
import bumble.core
|
||||
from bumble import colors
|
||||
from bumble.core import UUID, AdvertisingData, BT_LE_TRANSPORT
|
||||
from bumble.device import ConnectionParametersPreferences, Device, Connection, Peer
|
||||
from bumble.device import (
|
||||
ConnectionParametersPreferences,
|
||||
ConnectionPHY,
|
||||
Device,
|
||||
Connection,
|
||||
Peer,
|
||||
)
|
||||
from bumble.utils import AsyncRunner
|
||||
from bumble.transport import open_transport_or_link
|
||||
from bumble.gatt import Characteristic, Service, CharacteristicDeclaration, Descriptor
|
||||
@@ -125,6 +130,7 @@ def parse_phys(phys):
|
||||
# -----------------------------------------------------------------------------
|
||||
class ConsoleApp:
|
||||
connected_peer: Optional[Peer]
|
||||
connection_phy: Optional[ConnectionPHY]
|
||||
|
||||
def __init__(self):
|
||||
self.known_addresses = set()
|
||||
@@ -132,6 +138,7 @@ class ConsoleApp:
|
||||
self.known_local_attributes = []
|
||||
self.device = None
|
||||
self.connected_peer = None
|
||||
self.connection_phy = None
|
||||
self.top_tab = 'device'
|
||||
self.monitor_rssi = False
|
||||
self.connection_rssi = None
|
||||
@@ -332,10 +339,10 @@ class ConsoleApp:
|
||||
f'{connection.parameters.peripheral_latency}/'
|
||||
f'{connection.parameters.supervision_timeout}'
|
||||
)
|
||||
if connection.transport == BT_LE_TRANSPORT:
|
||||
if self.connection_phy is not None:
|
||||
phy_state = (
|
||||
f' RX={le_phy_name(connection.phy.rx_phy)}/'
|
||||
f'TX={le_phy_name(connection.phy.tx_phy)}'
|
||||
f' RX={le_phy_name(self.connection_phy.rx_phy)}/'
|
||||
f'TX={le_phy_name(self.connection_phy.tx_phy)}'
|
||||
)
|
||||
else:
|
||||
phy_state = ''
|
||||
@@ -654,11 +661,12 @@ class ConsoleApp:
|
||||
self.append_to_output('connecting...')
|
||||
|
||||
try:
|
||||
await self.device.connect(
|
||||
connection = await self.device.connect(
|
||||
params[0],
|
||||
connection_parameters_preferences=connection_parameters_preferences,
|
||||
timeout=DEFAULT_CONNECTION_TIMEOUT,
|
||||
)
|
||||
self.connection_phy = await connection.get_phy()
|
||||
self.top_tab = 'services'
|
||||
except bumble.core.TimeoutError:
|
||||
self.show_error('connection timed out')
|
||||
@@ -838,8 +846,8 @@ class ConsoleApp:
|
||||
|
||||
phy = await self.connected_peer.connection.get_phy()
|
||||
self.append_to_output(
|
||||
f'PHY: RX={HCI_Constant.le_phy_name(phy[0])}, '
|
||||
f'TX={HCI_Constant.le_phy_name(phy[1])}'
|
||||
f'PHY: RX={HCI_Constant.le_phy_name(phy.rx_phy)}, '
|
||||
f'TX={HCI_Constant.le_phy_name(phy.tx_phy)}'
|
||||
)
|
||||
|
||||
async def do_request_mtu(self, params):
|
||||
@@ -1076,10 +1084,9 @@ class DeviceListener(Device.Listener, Connection.Listener):
|
||||
f'{self.app.connected_peer.connection.parameters}'
|
||||
)
|
||||
|
||||
def on_connection_phy_update(self):
|
||||
self.app.append_to_output(
|
||||
f'connection phy update: {self.app.connected_peer.connection.phy}'
|
||||
)
|
||||
def on_connection_phy_update(self, phy):
|
||||
self.app.connection_phy = phy
|
||||
self.app.append_to_output(f'connection phy update: {phy}')
|
||||
|
||||
def on_connection_att_mtu_update(self):
|
||||
self.app.append_to_output(
|
||||
|
||||
@@ -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
17
bumble/audio/__init__.py
Normal 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
553
bumble/audio/io.py
Normal 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
|
||||
454
bumble/core.py
454
bumble/core.py
@@ -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.
|
||||
@@ -16,10 +16,10 @@
|
||||
# Imports
|
||||
# -----------------------------------------------------------------------------
|
||||
from __future__ import annotations
|
||||
import dataclasses
|
||||
|
||||
import enum
|
||||
import struct
|
||||
from typing import List, Optional, Tuple, Union, cast, Dict
|
||||
from typing import cast, overload, Literal, Union, Optional
|
||||
from typing_extensions import Self
|
||||
|
||||
from bumble.company_ids import COMPANY_IDENTIFIERS
|
||||
@@ -57,7 +57,7 @@ def bit_flags_to_strings(bits, bit_flag_names):
|
||||
return names
|
||||
|
||||
|
||||
def name_or_number(dictionary: Dict[int, str], number: int, width: int = 2) -> str:
|
||||
def name_or_number(dictionary: dict[int, str], number: int, width: int = 2) -> str:
|
||||
name = dictionary.get(number)
|
||||
if name is not None:
|
||||
return name
|
||||
@@ -200,7 +200,7 @@ class UUID:
|
||||
'''
|
||||
|
||||
BASE_UUID = bytes.fromhex('00001000800000805F9B34FB')[::-1] # little-endian
|
||||
UUIDS: List[UUID] = [] # Registry of all instances created
|
||||
UUIDS: list[UUID] = [] # Registry of all instances created
|
||||
|
||||
uuid_bytes: bytes
|
||||
name: Optional[str]
|
||||
@@ -259,11 +259,11 @@ class UUID:
|
||||
return cls.from_bytes(struct.pack('<I', uuid_32), name)
|
||||
|
||||
@classmethod
|
||||
def parse_uuid(cls, uuid_as_bytes: bytes, offset: int) -> Tuple[int, UUID]:
|
||||
def parse_uuid(cls, uuid_as_bytes: bytes, offset: int) -> tuple[int, UUID]:
|
||||
return len(uuid_as_bytes), cls.from_bytes(uuid_as_bytes[offset:])
|
||||
|
||||
@classmethod
|
||||
def parse_uuid_2(cls, uuid_as_bytes: bytes, offset: int) -> Tuple[int, UUID]:
|
||||
def parse_uuid_2(cls, uuid_as_bytes: bytes, offset: int) -> tuple[int, UUID]:
|
||||
return offset + 2, cls.from_bytes(uuid_as_bytes[offset : offset + 2])
|
||||
|
||||
def to_bytes(self, force_128: bool = False) -> bytes:
|
||||
@@ -1280,13 +1280,13 @@ class Appearance:
|
||||
# Advertising Data
|
||||
# -----------------------------------------------------------------------------
|
||||
AdvertisingDataObject = Union[
|
||||
List[UUID],
|
||||
Tuple[UUID, bytes],
|
||||
list[UUID],
|
||||
tuple[UUID, bytes],
|
||||
bytes,
|
||||
str,
|
||||
int,
|
||||
Tuple[int, int],
|
||||
Tuple[int, bytes],
|
||||
tuple[int, int],
|
||||
tuple[int, bytes],
|
||||
Appearance,
|
||||
]
|
||||
|
||||
@@ -1295,116 +1295,116 @@ class AdvertisingData:
|
||||
# fmt: off
|
||||
# pylint: disable=line-too-long
|
||||
|
||||
FLAGS = 0x01
|
||||
INCOMPLETE_LIST_OF_16_BIT_SERVICE_CLASS_UUIDS = 0x02
|
||||
COMPLETE_LIST_OF_16_BIT_SERVICE_CLASS_UUIDS = 0x03
|
||||
INCOMPLETE_LIST_OF_32_BIT_SERVICE_CLASS_UUIDS = 0x04
|
||||
COMPLETE_LIST_OF_32_BIT_SERVICE_CLASS_UUIDS = 0x05
|
||||
INCOMPLETE_LIST_OF_128_BIT_SERVICE_CLASS_UUIDS = 0x06
|
||||
COMPLETE_LIST_OF_128_BIT_SERVICE_CLASS_UUIDS = 0x07
|
||||
SHORTENED_LOCAL_NAME = 0x08
|
||||
COMPLETE_LOCAL_NAME = 0x09
|
||||
TX_POWER_LEVEL = 0x0A
|
||||
CLASS_OF_DEVICE = 0x0D
|
||||
SIMPLE_PAIRING_HASH_C = 0x0E
|
||||
SIMPLE_PAIRING_HASH_C_192 = 0x0E
|
||||
SIMPLE_PAIRING_RANDOMIZER_R = 0x0F
|
||||
SIMPLE_PAIRING_RANDOMIZER_R_192 = 0x0F
|
||||
DEVICE_ID = 0x10
|
||||
SECURITY_MANAGER_TK_VALUE = 0x10
|
||||
SECURITY_MANAGER_OUT_OF_BAND_FLAGS = 0x11
|
||||
PERIPHERAL_CONNECTION_INTERVAL_RANGE = 0x12
|
||||
LIST_OF_16_BIT_SERVICE_SOLICITATION_UUIDS = 0x14
|
||||
LIST_OF_128_BIT_SERVICE_SOLICITATION_UUIDS = 0x15
|
||||
SERVICE_DATA = 0x16
|
||||
SERVICE_DATA_16_BIT_UUID = 0x16
|
||||
PUBLIC_TARGET_ADDRESS = 0x17
|
||||
RANDOM_TARGET_ADDRESS = 0x18
|
||||
APPEARANCE = 0x19
|
||||
ADVERTISING_INTERVAL = 0x1A
|
||||
LE_BLUETOOTH_DEVICE_ADDRESS = 0x1B
|
||||
LE_ROLE = 0x1C
|
||||
SIMPLE_PAIRING_HASH_C_256 = 0x1D
|
||||
SIMPLE_PAIRING_RANDOMIZER_R_256 = 0x1E
|
||||
LIST_OF_32_BIT_SERVICE_SOLICITATION_UUIDS = 0x1F
|
||||
SERVICE_DATA_32_BIT_UUID = 0x20
|
||||
SERVICE_DATA_128_BIT_UUID = 0x21
|
||||
LE_SECURE_CONNECTIONS_CONFIRMATION_VALUE = 0x22
|
||||
LE_SECURE_CONNECTIONS_RANDOM_VALUE = 0x23
|
||||
URI = 0x24
|
||||
INDOOR_POSITIONING = 0x25
|
||||
TRANSPORT_DISCOVERY_DATA = 0x26
|
||||
LE_SUPPORTED_FEATURES = 0x27
|
||||
CHANNEL_MAP_UPDATE_INDICATION = 0x28
|
||||
PB_ADV = 0x29
|
||||
MESH_MESSAGE = 0x2A
|
||||
MESH_BEACON = 0x2B
|
||||
BIGINFO = 0x2C
|
||||
BROADCAST_CODE = 0x2D
|
||||
RESOLVABLE_SET_IDENTIFIER = 0x2E
|
||||
ADVERTISING_INTERVAL_LONG = 0x2F
|
||||
BROADCAST_NAME = 0x30
|
||||
ENCRYPTED_ADVERTISING_DATA = 0X31
|
||||
PERIODIC_ADVERTISING_RESPONSE_TIMING_INFORMATION = 0X32
|
||||
ELECTRONIC_SHELF_LABEL = 0X34
|
||||
THREE_D_INFORMATION_DATA = 0x3D
|
||||
MANUFACTURER_SPECIFIC_DATA = 0xFF
|
||||
class Type(OpenIntEnum):
|
||||
FLAGS = 0x01
|
||||
INCOMPLETE_LIST_OF_16_BIT_SERVICE_CLASS_UUIDS = 0x02
|
||||
COMPLETE_LIST_OF_16_BIT_SERVICE_CLASS_UUIDS = 0x03
|
||||
INCOMPLETE_LIST_OF_32_BIT_SERVICE_CLASS_UUIDS = 0x04
|
||||
COMPLETE_LIST_OF_32_BIT_SERVICE_CLASS_UUIDS = 0x05
|
||||
INCOMPLETE_LIST_OF_128_BIT_SERVICE_CLASS_UUIDS = 0x06
|
||||
COMPLETE_LIST_OF_128_BIT_SERVICE_CLASS_UUIDS = 0x07
|
||||
SHORTENED_LOCAL_NAME = 0x08
|
||||
COMPLETE_LOCAL_NAME = 0x09
|
||||
TX_POWER_LEVEL = 0x0A
|
||||
CLASS_OF_DEVICE = 0x0D
|
||||
SIMPLE_PAIRING_HASH_C = 0x0E
|
||||
SIMPLE_PAIRING_HASH_C_192 = 0x0E
|
||||
SIMPLE_PAIRING_RANDOMIZER_R = 0x0F
|
||||
SIMPLE_PAIRING_RANDOMIZER_R_192 = 0x0F
|
||||
DEVICE_ID = 0x10
|
||||
SECURITY_MANAGER_TK_VALUE = 0x10
|
||||
SECURITY_MANAGER_OUT_OF_BAND_FLAGS = 0x11
|
||||
PERIPHERAL_CONNECTION_INTERVAL_RANGE = 0x12
|
||||
LIST_OF_16_BIT_SERVICE_SOLICITATION_UUIDS = 0x14
|
||||
LIST_OF_128_BIT_SERVICE_SOLICITATION_UUIDS = 0x15
|
||||
SERVICE_DATA_16_BIT_UUID = 0x16
|
||||
PUBLIC_TARGET_ADDRESS = 0x17
|
||||
RANDOM_TARGET_ADDRESS = 0x18
|
||||
APPEARANCE = 0x19
|
||||
ADVERTISING_INTERVAL = 0x1A
|
||||
LE_BLUETOOTH_DEVICE_ADDRESS = 0x1B
|
||||
LE_ROLE = 0x1C
|
||||
SIMPLE_PAIRING_HASH_C_256 = 0x1D
|
||||
SIMPLE_PAIRING_RANDOMIZER_R_256 = 0x1E
|
||||
LIST_OF_32_BIT_SERVICE_SOLICITATION_UUIDS = 0x1F
|
||||
SERVICE_DATA_32_BIT_UUID = 0x20
|
||||
SERVICE_DATA_128_BIT_UUID = 0x21
|
||||
LE_SECURE_CONNECTIONS_CONFIRMATION_VALUE = 0x22
|
||||
LE_SECURE_CONNECTIONS_RANDOM_VALUE = 0x23
|
||||
URI = 0x24
|
||||
INDOOR_POSITIONING = 0x25
|
||||
TRANSPORT_DISCOVERY_DATA = 0x26
|
||||
LE_SUPPORTED_FEATURES = 0x27
|
||||
CHANNEL_MAP_UPDATE_INDICATION = 0x28
|
||||
PB_ADV = 0x29
|
||||
MESH_MESSAGE = 0x2A
|
||||
MESH_BEACON = 0x2B
|
||||
BIGINFO = 0x2C
|
||||
BROADCAST_CODE = 0x2D
|
||||
RESOLVABLE_SET_IDENTIFIER = 0x2E
|
||||
ADVERTISING_INTERVAL_LONG = 0x2F
|
||||
BROADCAST_NAME = 0x30
|
||||
ENCRYPTED_ADVERTISING_DATA = 0x31
|
||||
PERIODIC_ADVERTISING_RESPONSE_TIMING_INFORMATION = 0x32
|
||||
ELECTRONIC_SHELF_LABEL = 0x34
|
||||
THREE_D_INFORMATION_DATA = 0x3D
|
||||
MANUFACTURER_SPECIFIC_DATA = 0xFF
|
||||
|
||||
AD_TYPE_NAMES = {
|
||||
FLAGS: 'FLAGS',
|
||||
INCOMPLETE_LIST_OF_16_BIT_SERVICE_CLASS_UUIDS: 'INCOMPLETE_LIST_OF_16_BIT_SERVICE_CLASS_UUIDS',
|
||||
COMPLETE_LIST_OF_16_BIT_SERVICE_CLASS_UUIDS: 'COMPLETE_LIST_OF_16_BIT_SERVICE_CLASS_UUIDS',
|
||||
INCOMPLETE_LIST_OF_32_BIT_SERVICE_CLASS_UUIDS: 'INCOMPLETE_LIST_OF_32_BIT_SERVICE_CLASS_UUIDS',
|
||||
COMPLETE_LIST_OF_32_BIT_SERVICE_CLASS_UUIDS: 'COMPLETE_LIST_OF_32_BIT_SERVICE_CLASS_UUIDS',
|
||||
INCOMPLETE_LIST_OF_128_BIT_SERVICE_CLASS_UUIDS: 'INCOMPLETE_LIST_OF_128_BIT_SERVICE_CLASS_UUIDS',
|
||||
COMPLETE_LIST_OF_128_BIT_SERVICE_CLASS_UUIDS: 'COMPLETE_LIST_OF_128_BIT_SERVICE_CLASS_UUIDS',
|
||||
SHORTENED_LOCAL_NAME: 'SHORTENED_LOCAL_NAME',
|
||||
COMPLETE_LOCAL_NAME: 'COMPLETE_LOCAL_NAME',
|
||||
TX_POWER_LEVEL: 'TX_POWER_LEVEL',
|
||||
CLASS_OF_DEVICE: 'CLASS_OF_DEVICE',
|
||||
SIMPLE_PAIRING_HASH_C: 'SIMPLE_PAIRING_HASH_C',
|
||||
SIMPLE_PAIRING_HASH_C_192: 'SIMPLE_PAIRING_HASH_C_192',
|
||||
SIMPLE_PAIRING_RANDOMIZER_R: 'SIMPLE_PAIRING_RANDOMIZER_R',
|
||||
SIMPLE_PAIRING_RANDOMIZER_R_192: 'SIMPLE_PAIRING_RANDOMIZER_R_192',
|
||||
DEVICE_ID: 'DEVICE_ID',
|
||||
SECURITY_MANAGER_TK_VALUE: 'SECURITY_MANAGER_TK_VALUE',
|
||||
SECURITY_MANAGER_OUT_OF_BAND_FLAGS: 'SECURITY_MANAGER_OUT_OF_BAND_FLAGS',
|
||||
PERIPHERAL_CONNECTION_INTERVAL_RANGE: 'PERIPHERAL_CONNECTION_INTERVAL_RANGE',
|
||||
LIST_OF_16_BIT_SERVICE_SOLICITATION_UUIDS: 'LIST_OF_16_BIT_SERVICE_SOLICITATION_UUIDS',
|
||||
LIST_OF_128_BIT_SERVICE_SOLICITATION_UUIDS: 'LIST_OF_128_BIT_SERVICE_SOLICITATION_UUIDS',
|
||||
SERVICE_DATA_16_BIT_UUID: 'SERVICE_DATA_16_BIT_UUID',
|
||||
PUBLIC_TARGET_ADDRESS: 'PUBLIC_TARGET_ADDRESS',
|
||||
RANDOM_TARGET_ADDRESS: 'RANDOM_TARGET_ADDRESS',
|
||||
APPEARANCE: 'APPEARANCE',
|
||||
ADVERTISING_INTERVAL: 'ADVERTISING_INTERVAL',
|
||||
LE_BLUETOOTH_DEVICE_ADDRESS: 'LE_BLUETOOTH_DEVICE_ADDRESS',
|
||||
LE_ROLE: 'LE_ROLE',
|
||||
SIMPLE_PAIRING_HASH_C_256: 'SIMPLE_PAIRING_HASH_C_256',
|
||||
SIMPLE_PAIRING_RANDOMIZER_R_256: 'SIMPLE_PAIRING_RANDOMIZER_R_256',
|
||||
LIST_OF_32_BIT_SERVICE_SOLICITATION_UUIDS: 'LIST_OF_32_BIT_SERVICE_SOLICITATION_UUIDS',
|
||||
SERVICE_DATA_32_BIT_UUID: 'SERVICE_DATA_32_BIT_UUID',
|
||||
SERVICE_DATA_128_BIT_UUID: 'SERVICE_DATA_128_BIT_UUID',
|
||||
LE_SECURE_CONNECTIONS_CONFIRMATION_VALUE: 'LE_SECURE_CONNECTIONS_CONFIRMATION_VALUE',
|
||||
LE_SECURE_CONNECTIONS_RANDOM_VALUE: 'LE_SECURE_CONNECTIONS_RANDOM_VALUE',
|
||||
URI: 'URI',
|
||||
INDOOR_POSITIONING: 'INDOOR_POSITIONING',
|
||||
TRANSPORT_DISCOVERY_DATA: 'TRANSPORT_DISCOVERY_DATA',
|
||||
LE_SUPPORTED_FEATURES: 'LE_SUPPORTED_FEATURES',
|
||||
CHANNEL_MAP_UPDATE_INDICATION: 'CHANNEL_MAP_UPDATE_INDICATION',
|
||||
PB_ADV: 'PB_ADV',
|
||||
MESH_MESSAGE: 'MESH_MESSAGE',
|
||||
MESH_BEACON: 'MESH_BEACON',
|
||||
BIGINFO: 'BIGINFO',
|
||||
BROADCAST_CODE: 'BROADCAST_CODE',
|
||||
RESOLVABLE_SET_IDENTIFIER: 'RESOLVABLE_SET_IDENTIFIER',
|
||||
ADVERTISING_INTERVAL_LONG: 'ADVERTISING_INTERVAL_LONG',
|
||||
BROADCAST_NAME: 'BROADCAST_NAME',
|
||||
ENCRYPTED_ADVERTISING_DATA: 'ENCRYPTED_ADVERTISING_DATA',
|
||||
PERIODIC_ADVERTISING_RESPONSE_TIMING_INFORMATION: 'PERIODIC_ADVERTISING_RESPONSE_TIMING_INFORMATION',
|
||||
ELECTRONIC_SHELF_LABEL: 'ELECTRONIC_SHELF_LABEL',
|
||||
THREE_D_INFORMATION_DATA: 'THREE_D_INFORMATION_DATA',
|
||||
MANUFACTURER_SPECIFIC_DATA: 'MANUFACTURER_SPECIFIC_DATA'
|
||||
}
|
||||
# For backward-compatibility
|
||||
FLAGS = Type.FLAGS
|
||||
INCOMPLETE_LIST_OF_16_BIT_SERVICE_CLASS_UUIDS = Type.INCOMPLETE_LIST_OF_16_BIT_SERVICE_CLASS_UUIDS
|
||||
COMPLETE_LIST_OF_16_BIT_SERVICE_CLASS_UUIDS = Type.COMPLETE_LIST_OF_16_BIT_SERVICE_CLASS_UUIDS
|
||||
INCOMPLETE_LIST_OF_32_BIT_SERVICE_CLASS_UUIDS = Type.INCOMPLETE_LIST_OF_32_BIT_SERVICE_CLASS_UUIDS
|
||||
COMPLETE_LIST_OF_32_BIT_SERVICE_CLASS_UUIDS = Type.COMPLETE_LIST_OF_32_BIT_SERVICE_CLASS_UUIDS
|
||||
INCOMPLETE_LIST_OF_128_BIT_SERVICE_CLASS_UUIDS = Type.INCOMPLETE_LIST_OF_128_BIT_SERVICE_CLASS_UUIDS
|
||||
COMPLETE_LIST_OF_128_BIT_SERVICE_CLASS_UUIDS = Type.COMPLETE_LIST_OF_128_BIT_SERVICE_CLASS_UUIDS
|
||||
SHORTENED_LOCAL_NAME = Type.SHORTENED_LOCAL_NAME
|
||||
COMPLETE_LOCAL_NAME = Type.COMPLETE_LOCAL_NAME
|
||||
TX_POWER_LEVEL = Type.TX_POWER_LEVEL
|
||||
CLASS_OF_DEVICE = Type.CLASS_OF_DEVICE
|
||||
SIMPLE_PAIRING_HASH_C = Type.SIMPLE_PAIRING_HASH_C
|
||||
SIMPLE_PAIRING_HASH_C_192 = Type.SIMPLE_PAIRING_HASH_C_192
|
||||
SIMPLE_PAIRING_RANDOMIZER_R = Type.SIMPLE_PAIRING_RANDOMIZER_R
|
||||
SIMPLE_PAIRING_RANDOMIZER_R_192 = Type.SIMPLE_PAIRING_RANDOMIZER_R_192
|
||||
DEVICE_ID = Type.DEVICE_ID
|
||||
SECURITY_MANAGER_TK_VALUE = Type.SECURITY_MANAGER_TK_VALUE
|
||||
SECURITY_MANAGER_OUT_OF_BAND_FLAGS = Type.SECURITY_MANAGER_OUT_OF_BAND_FLAGS
|
||||
PERIPHERAL_CONNECTION_INTERVAL_RANGE = Type.PERIPHERAL_CONNECTION_INTERVAL_RANGE
|
||||
LIST_OF_16_BIT_SERVICE_SOLICITATION_UUIDS = Type.LIST_OF_16_BIT_SERVICE_SOLICITATION_UUIDS
|
||||
LIST_OF_128_BIT_SERVICE_SOLICITATION_UUIDS = Type.LIST_OF_128_BIT_SERVICE_SOLICITATION_UUIDS
|
||||
SERVICE_DATA = Type.SERVICE_DATA_16_BIT_UUID
|
||||
SERVICE_DATA_16_BIT_UUID = Type.SERVICE_DATA_16_BIT_UUID
|
||||
PUBLIC_TARGET_ADDRESS = Type.PUBLIC_TARGET_ADDRESS
|
||||
RANDOM_TARGET_ADDRESS = Type.RANDOM_TARGET_ADDRESS
|
||||
APPEARANCE = Type.APPEARANCE
|
||||
ADVERTISING_INTERVAL = Type.ADVERTISING_INTERVAL
|
||||
LE_BLUETOOTH_DEVICE_ADDRESS = Type.LE_BLUETOOTH_DEVICE_ADDRESS
|
||||
LE_ROLE = Type.LE_ROLE
|
||||
SIMPLE_PAIRING_HASH_C_256 = Type.SIMPLE_PAIRING_HASH_C_256
|
||||
SIMPLE_PAIRING_RANDOMIZER_R_256 = Type.SIMPLE_PAIRING_RANDOMIZER_R_256
|
||||
LIST_OF_32_BIT_SERVICE_SOLICITATION_UUIDS = Type.LIST_OF_32_BIT_SERVICE_SOLICITATION_UUIDS
|
||||
SERVICE_DATA_32_BIT_UUID = Type.SERVICE_DATA_32_BIT_UUID
|
||||
SERVICE_DATA_128_BIT_UUID = Type.SERVICE_DATA_128_BIT_UUID
|
||||
LE_SECURE_CONNECTIONS_CONFIRMATION_VALUE = Type.LE_SECURE_CONNECTIONS_CONFIRMATION_VALUE
|
||||
LE_SECURE_CONNECTIONS_RANDOM_VALUE = Type.LE_SECURE_CONNECTIONS_RANDOM_VALUE
|
||||
URI = Type.URI
|
||||
INDOOR_POSITIONING = Type.INDOOR_POSITIONING
|
||||
TRANSPORT_DISCOVERY_DATA = Type.TRANSPORT_DISCOVERY_DATA
|
||||
LE_SUPPORTED_FEATURES = Type.LE_SUPPORTED_FEATURES
|
||||
CHANNEL_MAP_UPDATE_INDICATION = Type.CHANNEL_MAP_UPDATE_INDICATION
|
||||
PB_ADV = Type.PB_ADV
|
||||
MESH_MESSAGE = Type.MESH_MESSAGE
|
||||
MESH_BEACON = Type.MESH_BEACON
|
||||
BIGINFO = Type.BIGINFO
|
||||
BROADCAST_CODE = Type.BROADCAST_CODE
|
||||
RESOLVABLE_SET_IDENTIFIER = Type.RESOLVABLE_SET_IDENTIFIER
|
||||
ADVERTISING_INTERVAL_LONG = Type.ADVERTISING_INTERVAL_LONG
|
||||
BROADCAST_NAME = Type.BROADCAST_NAME
|
||||
ENCRYPTED_ADVERTISING_DATA = Type.ENCRYPTED_ADVERTISING_DATA
|
||||
PERIODIC_ADVERTISING_RESPONSE_TIMING_INFORMATION = Type.PERIODIC_ADVERTISING_RESPONSE_TIMING_INFORMATION
|
||||
ELECTRONIC_SHELF_LABEL = Type.ELECTRONIC_SHELF_LABEL
|
||||
THREE_D_INFORMATION_DATA = Type.THREE_D_INFORMATION_DATA
|
||||
MANUFACTURER_SPECIFIC_DATA = Type.MANUFACTURER_SPECIFIC_DATA
|
||||
|
||||
LE_LIMITED_DISCOVERABLE_MODE_FLAG = 0x01
|
||||
LE_GENERAL_DISCOVERABLE_MODE_FLAG = 0x02
|
||||
@@ -1412,12 +1412,12 @@ class AdvertisingData:
|
||||
BR_EDR_CONTROLLER_FLAG = 0x08
|
||||
BR_EDR_HOST_FLAG = 0x10
|
||||
|
||||
ad_structures: List[Tuple[int, bytes]]
|
||||
ad_structures: list[tuple[int, bytes]]
|
||||
|
||||
# fmt: on
|
||||
# pylint: enable=line-too-long
|
||||
|
||||
def __init__(self, ad_structures: Optional[List[Tuple[int, bytes]]] = None) -> None:
|
||||
def __init__(self, ad_structures: Optional[list[tuple[int, bytes]]] = None) -> None:
|
||||
if ad_structures is None:
|
||||
ad_structures = []
|
||||
self.ad_structures = ad_structures[:]
|
||||
@@ -1444,7 +1444,7 @@ class AdvertisingData:
|
||||
return ','.join(bit_flags_to_strings(flags, flag_names))
|
||||
|
||||
@staticmethod
|
||||
def uuid_list_to_objects(ad_data: bytes, uuid_size: int) -> List[UUID]:
|
||||
def uuid_list_to_objects(ad_data: bytes, uuid_size: int) -> list[UUID]:
|
||||
uuids = []
|
||||
offset = 0
|
||||
while (offset + uuid_size) <= len(ad_data):
|
||||
@@ -1461,8 +1461,8 @@ class AdvertisingData:
|
||||
]
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def ad_data_to_string(ad_type, ad_data):
|
||||
@classmethod
|
||||
def ad_data_to_string(cls, ad_type: int, ad_data: bytes) -> str:
|
||||
if ad_type == AdvertisingData.FLAGS:
|
||||
ad_type_str = 'Flags'
|
||||
ad_data_str = AdvertisingData.flags_to_string(ad_data[0], short=True)
|
||||
@@ -1521,72 +1521,72 @@ class AdvertisingData:
|
||||
ad_type_str = 'Broadcast Name'
|
||||
ad_data_str = ad_data.decode('utf-8')
|
||||
else:
|
||||
ad_type_str = AdvertisingData.AD_TYPE_NAMES.get(ad_type, f'0x{ad_type:02X}')
|
||||
ad_type_str = AdvertisingData.Type(ad_type).name
|
||||
ad_data_str = ad_data.hex()
|
||||
|
||||
return f'[{ad_type_str}]: {ad_data_str}'
|
||||
|
||||
# pylint: disable=too-many-return-statements
|
||||
@staticmethod
|
||||
def ad_data_to_object(ad_type: int, ad_data: bytes) -> AdvertisingDataObject:
|
||||
@classmethod
|
||||
def ad_data_to_object(cls, ad_type: int, ad_data: bytes) -> AdvertisingDataObject:
|
||||
if ad_type in (
|
||||
AdvertisingData.COMPLETE_LIST_OF_16_BIT_SERVICE_CLASS_UUIDS,
|
||||
AdvertisingData.INCOMPLETE_LIST_OF_16_BIT_SERVICE_CLASS_UUIDS,
|
||||
AdvertisingData.LIST_OF_16_BIT_SERVICE_SOLICITATION_UUIDS,
|
||||
AdvertisingData.Type.COMPLETE_LIST_OF_16_BIT_SERVICE_CLASS_UUIDS,
|
||||
AdvertisingData.Type.INCOMPLETE_LIST_OF_16_BIT_SERVICE_CLASS_UUIDS,
|
||||
AdvertisingData.Type.LIST_OF_16_BIT_SERVICE_SOLICITATION_UUIDS,
|
||||
):
|
||||
return AdvertisingData.uuid_list_to_objects(ad_data, 2)
|
||||
|
||||
if ad_type in (
|
||||
AdvertisingData.COMPLETE_LIST_OF_32_BIT_SERVICE_CLASS_UUIDS,
|
||||
AdvertisingData.INCOMPLETE_LIST_OF_32_BIT_SERVICE_CLASS_UUIDS,
|
||||
AdvertisingData.LIST_OF_32_BIT_SERVICE_SOLICITATION_UUIDS,
|
||||
AdvertisingData.Type.COMPLETE_LIST_OF_32_BIT_SERVICE_CLASS_UUIDS,
|
||||
AdvertisingData.Type.INCOMPLETE_LIST_OF_32_BIT_SERVICE_CLASS_UUIDS,
|
||||
AdvertisingData.Type.LIST_OF_32_BIT_SERVICE_SOLICITATION_UUIDS,
|
||||
):
|
||||
return AdvertisingData.uuid_list_to_objects(ad_data, 4)
|
||||
|
||||
if ad_type in (
|
||||
AdvertisingData.COMPLETE_LIST_OF_128_BIT_SERVICE_CLASS_UUIDS,
|
||||
AdvertisingData.INCOMPLETE_LIST_OF_128_BIT_SERVICE_CLASS_UUIDS,
|
||||
AdvertisingData.LIST_OF_128_BIT_SERVICE_SOLICITATION_UUIDS,
|
||||
AdvertisingData.Type.COMPLETE_LIST_OF_128_BIT_SERVICE_CLASS_UUIDS,
|
||||
AdvertisingData.Type.INCOMPLETE_LIST_OF_128_BIT_SERVICE_CLASS_UUIDS,
|
||||
AdvertisingData.Type.LIST_OF_128_BIT_SERVICE_SOLICITATION_UUIDS,
|
||||
):
|
||||
return AdvertisingData.uuid_list_to_objects(ad_data, 16)
|
||||
|
||||
if ad_type == AdvertisingData.SERVICE_DATA_16_BIT_UUID:
|
||||
if ad_type == AdvertisingData.Type.SERVICE_DATA_16_BIT_UUID:
|
||||
return (UUID.from_bytes(ad_data[:2]), ad_data[2:])
|
||||
|
||||
if ad_type == AdvertisingData.SERVICE_DATA_32_BIT_UUID:
|
||||
if ad_type == AdvertisingData.Type.SERVICE_DATA_32_BIT_UUID:
|
||||
return (UUID.from_bytes(ad_data[:4]), ad_data[4:])
|
||||
|
||||
if ad_type == AdvertisingData.SERVICE_DATA_128_BIT_UUID:
|
||||
if ad_type == AdvertisingData.Type.SERVICE_DATA_128_BIT_UUID:
|
||||
return (UUID.from_bytes(ad_data[:16]), ad_data[16:])
|
||||
|
||||
if ad_type in (
|
||||
AdvertisingData.SHORTENED_LOCAL_NAME,
|
||||
AdvertisingData.COMPLETE_LOCAL_NAME,
|
||||
AdvertisingData.URI,
|
||||
AdvertisingData.BROADCAST_NAME,
|
||||
AdvertisingData.Type.SHORTENED_LOCAL_NAME,
|
||||
AdvertisingData.Type.COMPLETE_LOCAL_NAME,
|
||||
AdvertisingData.Type.URI,
|
||||
AdvertisingData.Type.BROADCAST_NAME,
|
||||
):
|
||||
return ad_data.decode("utf-8")
|
||||
|
||||
if ad_type in (AdvertisingData.TX_POWER_LEVEL, AdvertisingData.FLAGS):
|
||||
if ad_type in (AdvertisingData.Type.TX_POWER_LEVEL, AdvertisingData.Type.FLAGS):
|
||||
return cast(int, struct.unpack('B', ad_data)[0])
|
||||
|
||||
if ad_type in (AdvertisingData.ADVERTISING_INTERVAL,):
|
||||
if ad_type in (AdvertisingData.Type.ADVERTISING_INTERVAL,):
|
||||
return cast(int, struct.unpack('<H', ad_data)[0])
|
||||
|
||||
if ad_type == AdvertisingData.CLASS_OF_DEVICE:
|
||||
if ad_type == AdvertisingData.Type.CLASS_OF_DEVICE:
|
||||
return cast(int, struct.unpack('<I', bytes([*ad_data, 0]))[0])
|
||||
|
||||
if ad_type == AdvertisingData.PERIPHERAL_CONNECTION_INTERVAL_RANGE:
|
||||
return cast(Tuple[int, int], struct.unpack('<HH', ad_data))
|
||||
if ad_type == AdvertisingData.Type.PERIPHERAL_CONNECTION_INTERVAL_RANGE:
|
||||
return cast(tuple[int, int], struct.unpack('<HH', ad_data))
|
||||
|
||||
if ad_type == AdvertisingData.MANUFACTURER_SPECIFIC_DATA:
|
||||
return (cast(int, struct.unpack_from('<H', ad_data, 0)[0]), ad_data[2:])
|
||||
|
||||
if ad_type == AdvertisingData.APPEARANCE:
|
||||
if ad_type == AdvertisingData.Type.APPEARANCE:
|
||||
return Appearance.from_int(
|
||||
cast(int, struct.unpack_from('<H', ad_data, 0)[0])
|
||||
)
|
||||
|
||||
if ad_type == AdvertisingData.Type.MANUFACTURER_SPECIFIC_DATA:
|
||||
return (cast(int, struct.unpack_from('<H', ad_data, 0)[0]), ad_data[2:])
|
||||
|
||||
return ad_data
|
||||
|
||||
def append(self, data: bytes) -> None:
|
||||
@@ -1600,7 +1600,80 @@ class AdvertisingData:
|
||||
self.ad_structures.append((ad_type, ad_data))
|
||||
offset += length
|
||||
|
||||
def get_all(self, type_id: int, raw: bool = False) -> List[AdvertisingDataObject]:
|
||||
@overload
|
||||
def get_all(
|
||||
self,
|
||||
type_id: Literal[
|
||||
AdvertisingData.Type.COMPLETE_LIST_OF_16_BIT_SERVICE_CLASS_UUIDS,
|
||||
AdvertisingData.Type.INCOMPLETE_LIST_OF_16_BIT_SERVICE_CLASS_UUIDS,
|
||||
AdvertisingData.Type.LIST_OF_16_BIT_SERVICE_SOLICITATION_UUIDS,
|
||||
AdvertisingData.Type.COMPLETE_LIST_OF_32_BIT_SERVICE_CLASS_UUIDS,
|
||||
AdvertisingData.Type.INCOMPLETE_LIST_OF_32_BIT_SERVICE_CLASS_UUIDS,
|
||||
AdvertisingData.Type.LIST_OF_32_BIT_SERVICE_SOLICITATION_UUIDS,
|
||||
AdvertisingData.Type.COMPLETE_LIST_OF_128_BIT_SERVICE_CLASS_UUIDS,
|
||||
AdvertisingData.Type.INCOMPLETE_LIST_OF_128_BIT_SERVICE_CLASS_UUIDS,
|
||||
AdvertisingData.Type.LIST_OF_128_BIT_SERVICE_SOLICITATION_UUIDS,
|
||||
],
|
||||
raw: Literal[False] = False,
|
||||
) -> list[list[UUID]]: ...
|
||||
@overload
|
||||
def get_all(
|
||||
self,
|
||||
type_id: Literal[
|
||||
AdvertisingData.Type.SERVICE_DATA_16_BIT_UUID,
|
||||
AdvertisingData.Type.SERVICE_DATA_32_BIT_UUID,
|
||||
AdvertisingData.Type.SERVICE_DATA_128_BIT_UUID,
|
||||
],
|
||||
raw: Literal[False] = False,
|
||||
) -> list[tuple[UUID, bytes]]: ...
|
||||
@overload
|
||||
def get_all(
|
||||
self,
|
||||
type_id: Literal[
|
||||
AdvertisingData.Type.SHORTENED_LOCAL_NAME,
|
||||
AdvertisingData.Type.COMPLETE_LOCAL_NAME,
|
||||
AdvertisingData.Type.URI,
|
||||
AdvertisingData.Type.BROADCAST_NAME,
|
||||
],
|
||||
raw: Literal[False] = False,
|
||||
) -> list[str]: ...
|
||||
@overload
|
||||
def get_all(
|
||||
self,
|
||||
type_id: Literal[
|
||||
AdvertisingData.Type.TX_POWER_LEVEL,
|
||||
AdvertisingData.Type.FLAGS,
|
||||
AdvertisingData.Type.ADVERTISING_INTERVAL,
|
||||
AdvertisingData.Type.CLASS_OF_DEVICE,
|
||||
],
|
||||
raw: Literal[False] = False,
|
||||
) -> list[int]: ...
|
||||
@overload
|
||||
def get_all(
|
||||
self,
|
||||
type_id: Literal[AdvertisingData.Type.PERIPHERAL_CONNECTION_INTERVAL_RANGE,],
|
||||
raw: Literal[False] = False,
|
||||
) -> list[tuple[int, int]]: ...
|
||||
@overload
|
||||
def get_all(
|
||||
self,
|
||||
type_id: Literal[AdvertisingData.Type.MANUFACTURER_SPECIFIC_DATA,],
|
||||
raw: Literal[False] = False,
|
||||
) -> list[tuple[int, bytes]]: ...
|
||||
@overload
|
||||
def get_all(
|
||||
self,
|
||||
type_id: Literal[AdvertisingData.Type.APPEARANCE,],
|
||||
raw: Literal[False] = False,
|
||||
) -> list[Appearance]: ...
|
||||
@overload
|
||||
def get_all(self, type_id: int, raw: Literal[True]) -> list[bytes]: ...
|
||||
@overload
|
||||
def get_all(
|
||||
self, type_id: int, raw: bool = False
|
||||
) -> list[AdvertisingDataObject]: ...
|
||||
|
||||
def get_all(self, type_id: int, raw: bool = False) -> list[AdvertisingDataObject]: # type: ignore[misc]
|
||||
'''
|
||||
Get Advertising Data Structure(s) with a given type
|
||||
|
||||
@@ -1612,6 +1685,79 @@ class AdvertisingData:
|
||||
|
||||
return [process_ad_data(ad[1]) for ad in self.ad_structures if ad[0] == type_id]
|
||||
|
||||
@overload
|
||||
def get(
|
||||
self,
|
||||
type_id: Literal[
|
||||
AdvertisingData.Type.COMPLETE_LIST_OF_16_BIT_SERVICE_CLASS_UUIDS,
|
||||
AdvertisingData.Type.INCOMPLETE_LIST_OF_16_BIT_SERVICE_CLASS_UUIDS,
|
||||
AdvertisingData.Type.LIST_OF_16_BIT_SERVICE_SOLICITATION_UUIDS,
|
||||
AdvertisingData.Type.COMPLETE_LIST_OF_32_BIT_SERVICE_CLASS_UUIDS,
|
||||
AdvertisingData.Type.INCOMPLETE_LIST_OF_32_BIT_SERVICE_CLASS_UUIDS,
|
||||
AdvertisingData.Type.LIST_OF_32_BIT_SERVICE_SOLICITATION_UUIDS,
|
||||
AdvertisingData.Type.COMPLETE_LIST_OF_128_BIT_SERVICE_CLASS_UUIDS,
|
||||
AdvertisingData.Type.INCOMPLETE_LIST_OF_128_BIT_SERVICE_CLASS_UUIDS,
|
||||
AdvertisingData.Type.LIST_OF_128_BIT_SERVICE_SOLICITATION_UUIDS,
|
||||
],
|
||||
raw: Literal[False] = False,
|
||||
) -> Optional[list[UUID]]: ...
|
||||
@overload
|
||||
def get(
|
||||
self,
|
||||
type_id: Literal[
|
||||
AdvertisingData.Type.SERVICE_DATA_16_BIT_UUID,
|
||||
AdvertisingData.Type.SERVICE_DATA_32_BIT_UUID,
|
||||
AdvertisingData.Type.SERVICE_DATA_128_BIT_UUID,
|
||||
],
|
||||
raw: Literal[False] = False,
|
||||
) -> Optional[tuple[UUID, bytes]]: ...
|
||||
@overload
|
||||
def get(
|
||||
self,
|
||||
type_id: Literal[
|
||||
AdvertisingData.Type.SHORTENED_LOCAL_NAME,
|
||||
AdvertisingData.Type.COMPLETE_LOCAL_NAME,
|
||||
AdvertisingData.Type.URI,
|
||||
AdvertisingData.Type.BROADCAST_NAME,
|
||||
],
|
||||
raw: Literal[False] = False,
|
||||
) -> Optional[Optional[str]]: ...
|
||||
@overload
|
||||
def get(
|
||||
self,
|
||||
type_id: Literal[
|
||||
AdvertisingData.Type.TX_POWER_LEVEL,
|
||||
AdvertisingData.Type.FLAGS,
|
||||
AdvertisingData.Type.ADVERTISING_INTERVAL,
|
||||
AdvertisingData.Type.CLASS_OF_DEVICE,
|
||||
],
|
||||
raw: Literal[False] = False,
|
||||
) -> Optional[int]: ...
|
||||
@overload
|
||||
def get(
|
||||
self,
|
||||
type_id: Literal[AdvertisingData.Type.PERIPHERAL_CONNECTION_INTERVAL_RANGE,],
|
||||
raw: Literal[False] = False,
|
||||
) -> Optional[tuple[int, int]]: ...
|
||||
@overload
|
||||
def get(
|
||||
self,
|
||||
type_id: Literal[AdvertisingData.Type.MANUFACTURER_SPECIFIC_DATA,],
|
||||
raw: Literal[False] = False,
|
||||
) -> Optional[tuple[int, bytes]]: ...
|
||||
@overload
|
||||
def get(
|
||||
self,
|
||||
type_id: Literal[AdvertisingData.Type.APPEARANCE,],
|
||||
raw: Literal[False] = False,
|
||||
) -> Optional[Appearance]: ...
|
||||
@overload
|
||||
def get(self, type_id: int, raw: Literal[True]) -> Optional[bytes]: ...
|
||||
@overload
|
||||
def get(
|
||||
self, type_id: int, raw: bool = False
|
||||
) -> Optional[AdvertisingDataObject]: ...
|
||||
|
||||
def get(self, type_id: int, raw: bool = False) -> Optional[AdvertisingDataObject]:
|
||||
'''
|
||||
Get Advertising Data Structure(s) with a given type
|
||||
|
||||
125
bumble/device.py
125
bumble/device.py
@@ -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,
|
||||
@@ -1506,6 +1508,49 @@ class BisLink(_IsoLink):
|
||||
self.device = self.big.device
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
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)
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
class Connection(CompositeEventEmitter):
|
||||
device: Device
|
||||
@@ -1524,8 +1569,8 @@ class Connection(CompositeEventEmitter):
|
||||
gatt_client: gatt_client.Client
|
||||
pairing_peer_io_capability: Optional[int]
|
||||
pairing_peer_authentication_requirements: Optional[int]
|
||||
cs_configs: dict[int, ChannelSoundingConfig] = {} # Config ID to Configuration
|
||||
cs_procedures: dict[int, ChannelSoundingProcedure] = {} # Config ID to Procedures
|
||||
cs_configs: dict[int, ChannelSoundingConfig] # Config ID to Configuration
|
||||
cs_procedures: dict[int, ChannelSoundingProcedure] # Config ID to Procedures
|
||||
|
||||
@composite_listener
|
||||
class Listener:
|
||||
@@ -1541,7 +1586,7 @@ class Connection(CompositeEventEmitter):
|
||||
def on_connection_data_length_change(self):
|
||||
pass
|
||||
|
||||
def on_connection_phy_update(self):
|
||||
def on_connection_phy_update(self, phy):
|
||||
pass
|
||||
|
||||
def on_connection_phy_update_failure(self, error):
|
||||
@@ -1567,7 +1612,6 @@ class Connection(CompositeEventEmitter):
|
||||
peer_resolvable_address,
|
||||
role,
|
||||
parameters,
|
||||
phy,
|
||||
):
|
||||
super().__init__()
|
||||
self.device = device
|
||||
@@ -1584,7 +1628,6 @@ class Connection(CompositeEventEmitter):
|
||||
self.authenticated = False
|
||||
self.sc = False
|
||||
self.link_key_type = None
|
||||
self.phy = phy
|
||||
self.att_mtu = ATT_DEFAULT_MTU
|
||||
self.data_length = DEVICE_DEFAULT_DATA_LENGTH
|
||||
self.gatt_client = None # Per-connection client
|
||||
@@ -1594,6 +1637,8 @@ class Connection(CompositeEventEmitter):
|
||||
self.pairing_peer_io_capability = None
|
||||
self.pairing_peer_authentication_requirements = None
|
||||
self.peer_le_features = None
|
||||
self.cs_configs = {}
|
||||
self.cs_procedures = {}
|
||||
|
||||
# [Classic only]
|
||||
@classmethod
|
||||
@@ -1613,7 +1658,6 @@ class Connection(CompositeEventEmitter):
|
||||
None,
|
||||
role,
|
||||
None,
|
||||
None,
|
||||
)
|
||||
|
||||
# [Classic only]
|
||||
@@ -1729,12 +1773,12 @@ class Connection(CompositeEventEmitter):
|
||||
async def set_phy(self, tx_phys=None, rx_phys=None, phy_options=None):
|
||||
return await self.device.set_connection_phy(self, tx_phys, rx_phys, phy_options)
|
||||
|
||||
async def get_phy(self) -> ConnectionPHY:
|
||||
return await self.device.get_connection_phy(self)
|
||||
|
||||
async def get_rssi(self):
|
||||
return await self.device.get_connection_rssi(self)
|
||||
|
||||
async def get_phy(self):
|
||||
return await self.device.get_connection_phy(self)
|
||||
|
||||
async def transfer_periodic_sync(
|
||||
self, sync_handle: int, service_data: int = 0
|
||||
) -> None:
|
||||
@@ -2004,9 +2048,9 @@ class Device(CompositeEventEmitter):
|
||||
legacy_advertiser: Optional[LegacyAdvertiser]
|
||||
sco_links: Dict[int, ScoLink]
|
||||
cis_links: Dict[int, CisLink]
|
||||
bigs = dict[int, Big]()
|
||||
bis_links = dict[int, BisLink]()
|
||||
big_syncs = dict[int, BigSync]()
|
||||
bigs: dict[int, Big]
|
||||
bis_links: dict[int, BisLink]
|
||||
big_syncs: dict[int, BigSync]
|
||||
_pending_cis: Dict[int, tuple[int, int]]
|
||||
gatt_service: gatt_service.GenericAttributeProfileService | None = None
|
||||
|
||||
@@ -2099,6 +2143,9 @@ class Device(CompositeEventEmitter):
|
||||
self.sco_links = {} # ScoLinks, by connection handle (BR/EDR only)
|
||||
self.cis_links = {} # CisLinks, by connection handle (LE only)
|
||||
self._pending_cis = {} # (CIS_ID, CIG_ID), by CIS_handle
|
||||
self.bigs = {}
|
||||
self.bis_links = {}
|
||||
self.big_syncs = {}
|
||||
self.classic_enabled = False
|
||||
self.inquiry_response = None
|
||||
self.address_resolver = None
|
||||
@@ -2475,7 +2522,7 @@ class Device(CompositeEventEmitter):
|
||||
if self.random_address != hci.Address.ANY_RANDOM:
|
||||
logger.debug(
|
||||
color(
|
||||
f'LE Random hci.Address: {self.random_address}',
|
||||
f'LE Random Address: {self.random_address}',
|
||||
'yellow',
|
||||
)
|
||||
)
|
||||
@@ -3892,12 +3939,14 @@ class Device(CompositeEventEmitter):
|
||||
)
|
||||
return result.return_parameters.rssi
|
||||
|
||||
async def get_connection_phy(self, connection):
|
||||
async def get_connection_phy(self, connection: Connection) -> ConnectionPHY:
|
||||
result = await self.send_command(
|
||||
hci.HCI_LE_Read_PHY_Command(connection_handle=connection.handle),
|
||||
check_result=True,
|
||||
)
|
||||
return (result.return_parameters.tx_phy, result.return_parameters.rx_phy)
|
||||
return ConnectionPHY(
|
||||
result.return_parameters.tx_phy, result.return_parameters.rx_phy
|
||||
)
|
||||
|
||||
async def set_connection_phy(
|
||||
self, connection, tx_phys=None, rx_phys=None, phy_options=None
|
||||
@@ -3961,13 +4010,12 @@ class Device(CompositeEventEmitter):
|
||||
# Create a future to wait for an address to be found
|
||||
peer_address = asyncio.get_running_loop().create_future()
|
||||
|
||||
def on_peer_found(address, ad_data):
|
||||
local_name = ad_data.get(AdvertisingData.COMPLETE_LOCAL_NAME, raw=True)
|
||||
if local_name is None:
|
||||
local_name = ad_data.get(AdvertisingData.SHORTENED_LOCAL_NAME, raw=True)
|
||||
if local_name is not None:
|
||||
if local_name.decode('utf-8') == name:
|
||||
peer_address.set_result(address)
|
||||
def on_peer_found(address: hci.Address, ad_data: AdvertisingData) -> None:
|
||||
local_name = ad_data.get(
|
||||
AdvertisingData.Type.COMPLETE_LOCAL_NAME
|
||||
) or ad_data.get(AdvertisingData.Type.SHORTENED_LOCAL_NAME)
|
||||
if local_name == name:
|
||||
peer_address.set_result(address)
|
||||
|
||||
listener = None
|
||||
was_scanning = self.scanning
|
||||
@@ -5056,29 +5104,6 @@ class Device(CompositeEventEmitter):
|
||||
lambda _: self.abort_on('flush', advertising_set.start()),
|
||||
)
|
||||
|
||||
self._emit_le_connection(connection)
|
||||
|
||||
def _emit_le_connection(self, connection: Connection) -> None:
|
||||
# If supported, read which PHY we're connected with before
|
||||
# notifying listeners of the new connection.
|
||||
if self.host.supports_command(hci.HCI_LE_READ_PHY_COMMAND):
|
||||
|
||||
async def read_phy():
|
||||
result = await self.send_command(
|
||||
hci.HCI_LE_Read_PHY_Command(connection_handle=connection.handle),
|
||||
check_result=True,
|
||||
)
|
||||
connection.phy = ConnectionPHY(
|
||||
result.return_parameters.tx_phy, result.return_parameters.rx_phy
|
||||
)
|
||||
# Emit an event to notify listeners of the new connection
|
||||
self.emit('connection', connection)
|
||||
|
||||
# Do so asynchronously to not block the current event handler
|
||||
connection.abort_on('disconnection', read_phy())
|
||||
|
||||
return
|
||||
|
||||
self.emit('connection', connection)
|
||||
|
||||
@host_event_handler
|
||||
@@ -5177,7 +5202,6 @@ class Device(CompositeEventEmitter):
|
||||
peer_resolvable_address,
|
||||
role,
|
||||
connection_parameters,
|
||||
ConnectionPHY(hci.HCI_LE_1M_PHY, hci.HCI_LE_1M_PHY),
|
||||
)
|
||||
self.connections[connection_handle] = connection
|
||||
|
||||
@@ -5193,7 +5217,7 @@ class Device(CompositeEventEmitter):
|
||||
|
||||
if role == hci.HCI_CENTRAL_ROLE or not self.supports_le_extended_advertising:
|
||||
# We can emit now, we have all the info we need
|
||||
self._emit_le_connection(connection)
|
||||
self.emit('connection', connection)
|
||||
return
|
||||
|
||||
if role == hci.HCI_PERIPHERAL_ROLE and self.supports_le_extended_advertising:
|
||||
@@ -5747,14 +5771,13 @@ class Device(CompositeEventEmitter):
|
||||
|
||||
@host_event_handler
|
||||
@with_connection_from_handle
|
||||
def on_connection_phy_update(self, connection, connection_phy):
|
||||
def on_connection_phy_update(self, connection, phy):
|
||||
logger.debug(
|
||||
f'*** Connection PHY Update: [0x{connection.handle:04X}] '
|
||||
f'{connection.peer_address} as {connection.role_name}, '
|
||||
f'{connection_phy}'
|
||||
f'{phy}'
|
||||
)
|
||||
connection.phy = connection_phy
|
||||
connection.emit('connection_phy_update')
|
||||
connection.emit('connection_phy_update', phy)
|
||||
|
||||
@host_event_handler
|
||||
@with_connection_from_handle
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -314,6 +314,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
|
||||
@@ -322,8 +323,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'))
|
||||
@@ -679,10 +678,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)
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
@@ -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})'
|
||||
|
||||
|
||||
@@ -235,7 +235,7 @@ class Host(AbortableEventEmitter):
|
||||
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
|
||||
bigs: dict[int, set[int]]
|
||||
acl_packet_queue: Optional[DataPacketQueue] = None
|
||||
le_acl_packet_queue: Optional[DataPacketQueue] = None
|
||||
iso_packet_queue: Optional[DataPacketQueue] = None
|
||||
@@ -259,6 +259,7 @@ class Host(AbortableEventEmitter):
|
||||
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.bigs = {} # BIG Handle to BIS Handles
|
||||
self.pending_command = None
|
||||
self.pending_response: Optional[asyncio.Future[Any]] = None
|
||||
self.number_of_supported_advertising_sets = 0
|
||||
@@ -1061,8 +1062,10 @@ class Host(AbortableEventEmitter):
|
||||
)
|
||||
|
||||
# Flush the data queues
|
||||
self.acl_packet_queue.flush(handle)
|
||||
self.le_acl_packet_queue.flush(handle)
|
||||
if self.acl_packet_queue:
|
||||
self.acl_packet_queue.flush(handle)
|
||||
if self.le_acl_packet_queue:
|
||||
self.le_acl_packet_queue.flush(handle)
|
||||
if self.iso_packet_queue:
|
||||
self.iso_packet_queue.flush(handle)
|
||||
else:
|
||||
@@ -1098,8 +1101,11 @@ class Host(AbortableEventEmitter):
|
||||
|
||||
# Notify the client
|
||||
if event.status == hci.HCI_SUCCESS:
|
||||
connection_phy = ConnectionPHY(event.tx_phy, event.rx_phy)
|
||||
self.emit('connection_phy_update', connection.handle, connection_phy)
|
||||
self.emit(
|
||||
'connection_phy_update',
|
||||
connection.handle,
|
||||
ConnectionPHY(event.tx_phy, event.rx_phy),
|
||||
)
|
||||
else:
|
||||
self.emit('connection_phy_update_failure', connection.handle, event.status)
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
# Copyright 2021-2023 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.
|
||||
@@ -76,18 +76,18 @@ class OobData:
|
||||
return instance
|
||||
|
||||
def to_ad(self) -> AdvertisingData:
|
||||
ad_structures = []
|
||||
ad_structures: list[tuple[int, bytes]] = []
|
||||
if self.address is not None:
|
||||
ad_structures.append(
|
||||
(AdvertisingData.LE_BLUETOOTH_DEVICE_ADDRESS, bytes(self.address))
|
||||
(AdvertisingData.Type.LE_BLUETOOTH_DEVICE_ADDRESS, bytes(self.address))
|
||||
)
|
||||
if self.role is not None:
|
||||
ad_structures.append((AdvertisingData.LE_ROLE, bytes([self.role])))
|
||||
ad_structures.append((AdvertisingData.Type.LE_ROLE, bytes([self.role])))
|
||||
if self.shared_data is not None:
|
||||
ad_structures.extend(self.shared_data.to_ad().ad_structures)
|
||||
if self.legacy_context is not None:
|
||||
ad_structures.append(
|
||||
(AdvertisingData.SECURITY_MANAGER_TK_VALUE, self.legacy_context.tk)
|
||||
(AdvertisingData.Type.SECURITY_MANAGER_TK_VALUE, self.legacy_context.tk)
|
||||
)
|
||||
|
||||
return AdvertisingData(ad_structures)
|
||||
|
||||
@@ -371,9 +371,7 @@ class HostService(HostServicer):
|
||||
scan_response_data=scan_response_data,
|
||||
)
|
||||
|
||||
pending_connection: asyncio.Future[bumble.device.Connection] = (
|
||||
asyncio.get_running_loop().create_future()
|
||||
)
|
||||
connections: asyncio.Queue[bumble.device.Connection] = asyncio.Queue()
|
||||
|
||||
if request.connectable:
|
||||
|
||||
@@ -382,7 +380,7 @@ class HostService(HostServicer):
|
||||
connection.transport == BT_LE_TRANSPORT
|
||||
and connection.role == BT_PERIPHERAL_ROLE
|
||||
):
|
||||
pending_connection.set_result(connection)
|
||||
connections.put_nowait(connection)
|
||||
|
||||
self.device.on('connection', on_connection)
|
||||
|
||||
@@ -397,8 +395,7 @@ class HostService(HostServicer):
|
||||
await asyncio.sleep(1)
|
||||
continue
|
||||
|
||||
connection = await pending_connection
|
||||
pending_connection = asyncio.get_running_loop().create_future()
|
||||
connection = await connections.get()
|
||||
|
||||
cookie = any_pb2.Any(value=connection.handle.to_bytes(4, 'big'))
|
||||
yield AdvertiseResponse(connection=Connection(cookie=cookie))
|
||||
@@ -492,6 +489,8 @@ class HostService(HostServicer):
|
||||
target = Address(target_bytes, Address.RANDOM_DEVICE_ADDRESS)
|
||||
advertising_type = AdvertisingType.DIRECTED_CONNECTABLE_LOW_DUTY
|
||||
|
||||
connections: asyncio.Queue[bumble.device.Connection] = asyncio.Queue()
|
||||
|
||||
if request.connectable:
|
||||
|
||||
def on_connection(connection: bumble.device.Connection) -> None:
|
||||
@@ -499,7 +498,7 @@ class HostService(HostServicer):
|
||||
connection.transport == BT_LE_TRANSPORT
|
||||
and connection.role == BT_PERIPHERAL_ROLE
|
||||
):
|
||||
pending_connection.set_result(connection)
|
||||
connections.put_nowait(connection)
|
||||
|
||||
self.device.on('connection', on_connection)
|
||||
|
||||
@@ -517,12 +516,8 @@ class HostService(HostServicer):
|
||||
await asyncio.sleep(1)
|
||||
continue
|
||||
|
||||
pending_connection: asyncio.Future[bumble.device.Connection] = (
|
||||
asyncio.get_running_loop().create_future()
|
||||
)
|
||||
|
||||
self.log.debug('Wait for LE connection...')
|
||||
connection = await pending_connection
|
||||
connection = await connections.get()
|
||||
|
||||
self.log.debug(
|
||||
f"Advertise: Connected to {connection.peer_address} (handle={connection.handle})"
|
||||
|
||||
@@ -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])
|
||||
)
|
||||
|
||||
@@ -301,7 +301,7 @@ class AseStateMachine(gatt.Characteristic):
|
||||
presentation_delay = 0
|
||||
|
||||
# Additional parameters in ENABLING, STREAMING, DISABLING State
|
||||
metadata = le_audio.Metadata()
|
||||
metadata: le_audio.Metadata
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
@@ -313,6 +313,7 @@ class AseStateMachine(gatt.Characteristic):
|
||||
self.ase_id = ase_id
|
||||
self._state = AseStateMachine.State.IDLE
|
||||
self.role = role
|
||||
self.metadata = le_audio.Metadata()
|
||||
|
||||
uuid = (
|
||||
gatt.GATT_SINK_ASE_CHARACTERISTIC
|
||||
|
||||
@@ -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),
|
||||
)
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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]),
|
||||
)
|
||||
|
||||
|
||||
@@ -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)})'
|
||||
|
||||
@@ -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]),
|
||||
)
|
||||
|
||||
@@ -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],
|
||||
),
|
||||
|
||||
@@ -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]),
|
||||
)
|
||||
@@ -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])
|
||||
)
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -28,7 +28,7 @@ class OneDeviceBenchTest(base_test.BaseTestClass):
|
||||
|
||||
def test_l2cap_client_ping(self):
|
||||
runner = self.dut.bench.runL2capClient(
|
||||
"ping", "4B:2A:67:76:2B:E3", 128, True, 100, 970, 100
|
||||
"ping", "4B:2A:67:76:2B:E3", 128, True, 100, 970, 100, "HIGH"
|
||||
)
|
||||
print("### Initial status:", runner)
|
||||
final_status = self.dut.bench.waitForRunnerCompletion(runner["id"])
|
||||
@@ -36,12 +36,34 @@ class OneDeviceBenchTest(base_test.BaseTestClass):
|
||||
|
||||
def test_l2cap_client_send(self):
|
||||
runner = self.dut.bench.runL2capClient(
|
||||
"send", "7E:90:D0:F2:7A:11", 131, True, 100, 970, 0
|
||||
"send",
|
||||
"F1:F1:F1:F1:F1:F1",
|
||||
128,
|
||||
True,
|
||||
100,
|
||||
970,
|
||||
0,
|
||||
"HIGH",
|
||||
10000,
|
||||
)
|
||||
print("### Initial status:", runner)
|
||||
final_status = self.dut.bench.waitForRunnerCompletion(runner["id"])
|
||||
print("### Final status:", final_status)
|
||||
|
||||
def test_gatt_client_send(self):
|
||||
runner = self.dut.bench.runGattClient(
|
||||
"send", "F1:F1:F1:F1:F1:F1", 128, True, 100, 970, 100, "HIGH"
|
||||
)
|
||||
print("### Initial status:", runner)
|
||||
final_status = self.dut.bench.waitForRunnerCompletion(runner["id"])
|
||||
print("### Final status:", final_status)
|
||||
|
||||
def test_gatt_server_receive(self):
|
||||
runner = self.dut.bench.runGattServer("receive")
|
||||
print("### Initial status:", runner)
|
||||
final_status = self.dut.bench.waitForRunnerCompletion(runner["id"])
|
||||
print("### Final status:", final_status)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
test_runner.main()
|
||||
|
||||
@@ -2,8 +2,8 @@ TestBeds:
|
||||
- Name: BenchTestBed
|
||||
Controllers:
|
||||
AndroidDevice:
|
||||
- serial: 37211FDJG000DJ
|
||||
- serial: emulator-5554
|
||||
local_bt_address: 94:45:60:5E:03:B0
|
||||
|
||||
- serial: 23071FDEE001F7
|
||||
local_bt_address: DC:E5:5B:E5:51:2C
|
||||
#- serial: 23071FDEE001F7
|
||||
# local_bt_address: DC:E5:5B:E5:51:2C
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -10,7 +10,7 @@ android {
|
||||
|
||||
defaultConfig {
|
||||
applicationId = "com.github.google.bumble.btbench"
|
||||
minSdk = 30
|
||||
minSdk = 33
|
||||
targetSdk = 34
|
||||
versionCode = 1
|
||||
versionName = "1.0"
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
package="com.github.google.bumble.btbench">
|
||||
<uses-sdk android:minSdkVersion="30" android:targetSdkVersion="34" />
|
||||
<uses-sdk android:minSdkVersion="33" android:targetSdkVersion="34" />
|
||||
<!-- Request legacy Bluetooth permissions on older devices. -->
|
||||
<uses-permission android:name="android.permission.BLUETOOTH" android:maxSdkVersion="30" />
|
||||
<uses-permission android:name="android.permission.BLUETOOTH_ADMIN" android:maxSdkVersion="30" />
|
||||
@@ -9,6 +9,8 @@
|
||||
<uses-permission android:name="android.permission.BLUETOOTH_SCAN" android:usesPermissionFlags="neverForLocation"/>
|
||||
<uses-permission android:name="android.permission.BLUETOOTH_ADVERTISE" />
|
||||
<uses-permission android:name="android.permission.BLUETOOTH_CONNECT" />
|
||||
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
|
||||
<uses-feature android:name="android.hardware.bluetooth" android:required="true"/>
|
||||
<uses-feature android:name="android.hardware.bluetooth_le" android:required="true"/>
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
package com.github.google.bumble.btbench
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.bluetooth.BluetoothAdapter
|
||||
import android.bluetooth.le.AdvertiseCallback
|
||||
import android.bluetooth.le.AdvertiseData
|
||||
import android.bluetooth.le.AdvertiseSettings
|
||||
import android.bluetooth.le.AdvertiseSettings.ADVERTISE_MODE_LOW_LATENCY
|
||||
import android.os.Build
|
||||
import java.util.logging.Logger
|
||||
|
||||
private val Log = Logger.getLogger("btbench.advertiser")
|
||||
|
||||
class Advertiser(private val bluetoothAdapter: BluetoothAdapter) : AdvertiseCallback() {
|
||||
@SuppressLint("MissingPermission")
|
||||
fun start() {
|
||||
val advertiseSettingsBuilder = AdvertiseSettings.Builder()
|
||||
.setAdvertiseMode(ADVERTISE_MODE_LOW_LATENCY)
|
||||
.setConnectable(true)
|
||||
advertiseSettingsBuilder.setDiscoverable(true)
|
||||
val advertiseSettings = advertiseSettingsBuilder.build()
|
||||
val advertiseData = AdvertiseData.Builder().build()
|
||||
val scanData = AdvertiseData.Builder().setIncludeDeviceName(true).build()
|
||||
bluetoothAdapter.bluetoothLeAdvertiser.startAdvertising(advertiseSettings, advertiseData, scanData, this)
|
||||
}
|
||||
|
||||
@SuppressLint("MissingPermission")
|
||||
fun stop() {
|
||||
bluetoothAdapter.bluetoothLeAdvertiser.stopAdvertising(this)
|
||||
}
|
||||
|
||||
override fun onStartFailure(errorCode: Int) {
|
||||
Log.warning("failed to start advertising: $errorCode")
|
||||
}
|
||||
|
||||
override fun onStartSuccess(settingsInEffect: AdvertiseSettings) {
|
||||
Log.info("advertising started: $settingsInEffect")
|
||||
}
|
||||
}
|
||||
@@ -22,11 +22,13 @@ import androidx.test.core.app.ApplicationProvider;
|
||||
|
||||
import com.google.android.mobly.snippet.Snippet;
|
||||
import com.google.android.mobly.snippet.rpc.Rpc;
|
||||
import com.google.android.mobly.snippet.rpc.RpcOptional;
|
||||
|
||||
import org.json.JSONArray;
|
||||
import org.json.JSONException;
|
||||
import org.json.JSONObject;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.security.InvalidParameterException;
|
||||
import java.util.ArrayList;
|
||||
import java.util.UUID;
|
||||
@@ -71,12 +73,15 @@ public class AutomationSnippet implements Snippet {
|
||||
private final Context mContext;
|
||||
private final ArrayList<Runner> mRunners = new ArrayList<>();
|
||||
|
||||
public AutomationSnippet() {
|
||||
public AutomationSnippet() throws IOException {
|
||||
mContext = ApplicationProvider.getApplicationContext();
|
||||
BluetoothManager bluetoothManager = mContext.getSystemService(BluetoothManager.class);
|
||||
mBluetoothAdapter = bluetoothManager.getAdapter();
|
||||
if (mBluetoothAdapter == null) {
|
||||
throw new RuntimeException("bluetooth not supported");
|
||||
throw new IOException("bluetooth not supported");
|
||||
}
|
||||
if (!mBluetoothAdapter.isEnabled()) {
|
||||
throw new IOException("bluetooth not enabled");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -85,32 +90,46 @@ public class AutomationSnippet implements Snippet {
|
||||
switch (mode) {
|
||||
case "rfcomm-client":
|
||||
runnable = new RfcommClient(model, mBluetoothAdapter,
|
||||
(PacketIO packetIO) -> createIoClient(model, scenario,
|
||||
packetIO));
|
||||
(PacketIO packetIO) -> createIoClient(model, scenario,
|
||||
packetIO));
|
||||
break;
|
||||
|
||||
case "rfcomm-server":
|
||||
runnable = new RfcommServer(model, mBluetoothAdapter,
|
||||
(PacketIO packetIO) -> createIoClient(model, scenario,
|
||||
packetIO));
|
||||
(PacketIO packetIO) -> createIoClient(model, scenario,
|
||||
packetIO));
|
||||
break;
|
||||
|
||||
case "l2cap-client":
|
||||
runnable = new L2capClient(model, mBluetoothAdapter, mContext,
|
||||
(PacketIO packetIO) -> createIoClient(model, scenario,
|
||||
packetIO));
|
||||
(PacketIO packetIO) -> createIoClient(model, scenario,
|
||||
packetIO));
|
||||
break;
|
||||
|
||||
case "l2cap-server":
|
||||
runnable = new L2capServer(model, mBluetoothAdapter,
|
||||
(PacketIO packetIO) -> createIoClient(model, scenario,
|
||||
packetIO));
|
||||
(PacketIO packetIO) -> createIoClient(model, scenario,
|
||||
packetIO));
|
||||
break;
|
||||
|
||||
case "gatt-client":
|
||||
runnable = new GattClient(model, mBluetoothAdapter, mContext,
|
||||
(PacketIO packetIO) -> createIoClient(model, scenario,
|
||||
packetIO));
|
||||
break;
|
||||
|
||||
case "gatt-server":
|
||||
runnable = new GattServer(model, mBluetoothAdapter, mContext,
|
||||
(PacketIO packetIO) -> createIoClient(model, scenario,
|
||||
packetIO));
|
||||
break;
|
||||
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
|
||||
model.setMode(mode);
|
||||
model.setScenario(scenario);
|
||||
runnable.run();
|
||||
Runner runner = new Runner(runnable, mode, scenario, model);
|
||||
mRunners.add(runner);
|
||||
@@ -140,7 +159,21 @@ public class AutomationSnippet implements Snippet {
|
||||
JSONObject result = new JSONObject();
|
||||
result.put("status", model.getStatus());
|
||||
result.put("running", model.getRunning());
|
||||
result.put("peer_bluetooth_address", model.getPeerBluetoothAddress());
|
||||
result.put("mode", model.getMode());
|
||||
result.put("scenario", model.getScenario());
|
||||
result.put("sender_packet_size", model.getSenderPacketSize());
|
||||
result.put("sender_packet_count", model.getSenderPacketCount());
|
||||
result.put("sender_packet_interval", model.getSenderPacketInterval());
|
||||
result.put("packets_sent", model.getPacketsSent());
|
||||
result.put("packets_received", model.getPacketsReceived());
|
||||
result.put("l2cap_psm", model.getL2capPsm());
|
||||
result.put("use_2m_phy", model.getUse2mPhy());
|
||||
result.put("connection_priority", model.getConnectionPriority());
|
||||
result.put("mtu", model.getMtu());
|
||||
result.put("rx_phy", model.getRxPhy());
|
||||
result.put("tx_phy", model.getTxPhy());
|
||||
result.put("startup_delay", model.getStartupDelay());
|
||||
if (model.getStatus().equals("OK")) {
|
||||
JSONObject stats = new JSONObject();
|
||||
result.put("stats", stats);
|
||||
@@ -167,12 +200,12 @@ public class AutomationSnippet implements Snippet {
|
||||
|
||||
@Rpc(description = "Run a scenario in RFComm Client mode")
|
||||
public JSONObject runRfcommClient(String scenario, String peerBluetoothAddress, int packetCount,
|
||||
int packetSize, int packetInterval) throws JSONException {
|
||||
assert (mBluetoothAdapter != null);
|
||||
|
||||
int packetSize, int packetInterval,
|
||||
@RpcOptional Integer startupDelay) throws JSONException {
|
||||
// We only support "send" and "ping" for this mode for now
|
||||
if (!(scenario.equals("send") || scenario.equals("ping"))) {
|
||||
throw new InvalidParameterException("only 'send' and 'ping' are supported for this mode");
|
||||
throw new InvalidParameterException(
|
||||
"only 'send' and 'ping' are supported for this mode");
|
||||
}
|
||||
|
||||
AppViewModel model = new AppViewModel();
|
||||
@@ -180,6 +213,9 @@ public class AutomationSnippet implements Snippet {
|
||||
model.setSenderPacketCount(packetCount);
|
||||
model.setSenderPacketSize(packetSize);
|
||||
model.setSenderPacketInterval(packetInterval);
|
||||
if (startupDelay != null) {
|
||||
model.setStartupDelay(startupDelay);
|
||||
}
|
||||
|
||||
Runner runner = runScenario(model, "rfcomm-client", scenario);
|
||||
assert runner != null;
|
||||
@@ -187,15 +223,18 @@ public class AutomationSnippet implements Snippet {
|
||||
}
|
||||
|
||||
@Rpc(description = "Run a scenario in RFComm Server mode")
|
||||
public JSONObject runRfcommServer(String scenario) throws JSONException {
|
||||
assert (mBluetoothAdapter != null);
|
||||
|
||||
public JSONObject runRfcommServer(String scenario,
|
||||
@RpcOptional Integer startupDelay) throws JSONException {
|
||||
// We only support "receive" and "pong" for this mode for now
|
||||
if (!(scenario.equals("receive") || scenario.equals("pong"))) {
|
||||
throw new InvalidParameterException("only 'receive' and 'pong' are supported for this mode");
|
||||
throw new InvalidParameterException(
|
||||
"only 'receive' and 'pong' are supported for this mode");
|
||||
}
|
||||
|
||||
AppViewModel model = new AppViewModel();
|
||||
if (startupDelay != null) {
|
||||
model.setStartupDelay(startupDelay);
|
||||
}
|
||||
|
||||
Runner runner = runScenario(model, "rfcomm-server", scenario);
|
||||
assert runner != null;
|
||||
@@ -205,12 +244,12 @@ public class AutomationSnippet implements Snippet {
|
||||
@Rpc(description = "Run a scenario in L2CAP Client mode")
|
||||
public JSONObject runL2capClient(String scenario, String peerBluetoothAddress, int psm,
|
||||
boolean use_2m_phy, int packetCount, int packetSize,
|
||||
int packetInterval) throws JSONException {
|
||||
assert (mBluetoothAdapter != null);
|
||||
|
||||
int packetInterval, @RpcOptional String connectionPriority,
|
||||
@RpcOptional Integer startupDelay) throws JSONException {
|
||||
// We only support "send" and "ping" for this mode for now
|
||||
if (!(scenario.equals("send") || scenario.equals("ping"))) {
|
||||
throw new InvalidParameterException("only 'send' and 'ping' are supported for this mode");
|
||||
throw new InvalidParameterException(
|
||||
"only 'send' and 'ping' are supported for this mode");
|
||||
}
|
||||
|
||||
AppViewModel model = new AppViewModel();
|
||||
@@ -220,28 +259,83 @@ public class AutomationSnippet implements Snippet {
|
||||
model.setSenderPacketCount(packetCount);
|
||||
model.setSenderPacketSize(packetSize);
|
||||
model.setSenderPacketInterval(packetInterval);
|
||||
|
||||
if (connectionPriority != null) {
|
||||
model.setConnectionPriority(connectionPriority);
|
||||
}
|
||||
if (startupDelay != null) {
|
||||
model.setStartupDelay(startupDelay);
|
||||
}
|
||||
Runner runner = runScenario(model, "l2cap-client", scenario);
|
||||
assert runner != null;
|
||||
return runner.toJson();
|
||||
}
|
||||
|
||||
@Rpc(description = "Run a scenario in L2CAP Server mode")
|
||||
public JSONObject runL2capServer(String scenario) throws JSONException {
|
||||
assert (mBluetoothAdapter != null);
|
||||
|
||||
public JSONObject runL2capServer(String scenario,
|
||||
@RpcOptional Integer startupDelay) throws JSONException {
|
||||
// We only support "receive" and "pong" for this mode for now
|
||||
if (!(scenario.equals("receive") || scenario.equals("pong"))) {
|
||||
throw new InvalidParameterException("only 'receive' and 'pong' are supported for this mode");
|
||||
throw new InvalidParameterException(
|
||||
"only 'receive' and 'pong' are supported for this mode");
|
||||
}
|
||||
|
||||
AppViewModel model = new AppViewModel();
|
||||
if (startupDelay != null) {
|
||||
model.setStartupDelay(startupDelay);
|
||||
}
|
||||
|
||||
Runner runner = runScenario(model, "l2cap-server", scenario);
|
||||
assert runner != null;
|
||||
return runner.toJson();
|
||||
}
|
||||
|
||||
@Rpc(description = "Run a scenario in GATT Client mode")
|
||||
public JSONObject runGattClient(String scenario, String peerBluetoothAddress,
|
||||
boolean use_2m_phy, int packetCount, int packetSize,
|
||||
int packetInterval, @RpcOptional String connectionPriority,
|
||||
@RpcOptional Integer startupDelay) throws JSONException {
|
||||
// We only support "send" and "ping" for this mode for now
|
||||
if (!(scenario.equals("send") || scenario.equals("ping"))) {
|
||||
throw new InvalidParameterException(
|
||||
"only 'send' and 'ping' are supported for this mode");
|
||||
}
|
||||
|
||||
AppViewModel model = new AppViewModel();
|
||||
model.setPeerBluetoothAddress(peerBluetoothAddress);
|
||||
model.setUse2mPhy(use_2m_phy);
|
||||
model.setSenderPacketCount(packetCount);
|
||||
model.setSenderPacketSize(packetSize);
|
||||
model.setSenderPacketInterval(packetInterval);
|
||||
if (connectionPriority != null) {
|
||||
model.setConnectionPriority(connectionPriority);
|
||||
}
|
||||
if (startupDelay != null) {
|
||||
model.setStartupDelay(startupDelay);
|
||||
}
|
||||
Runner runner = runScenario(model, "gatt-client", scenario);
|
||||
assert runner != null;
|
||||
return runner.toJson();
|
||||
}
|
||||
|
||||
@Rpc(description = "Run a scenario in GATT Server mode")
|
||||
public JSONObject runGattServer(String scenario,
|
||||
@RpcOptional Integer startupDelay) throws JSONException {
|
||||
// We only support "receive" and "pong" for this mode for now
|
||||
if (!(scenario.equals("receive") || scenario.equals("pong"))) {
|
||||
throw new InvalidParameterException(
|
||||
"only 'receive' and 'pong' are supported for this mode");
|
||||
}
|
||||
|
||||
AppViewModel model = new AppViewModel();
|
||||
if (startupDelay != null) {
|
||||
model.setStartupDelay(startupDelay);
|
||||
}
|
||||
|
||||
Runner runner = runScenario(model, "gatt-server", scenario);
|
||||
assert runner != null;
|
||||
return runner.toJson();
|
||||
}
|
||||
|
||||
@Rpc(description = "Stop a Runner")
|
||||
public JSONObject stopRunner(String runnerId) throws JSONException {
|
||||
Runner runner = findRunner(runnerId);
|
||||
@@ -276,7 +370,7 @@ public class AutomationSnippet implements Snippet {
|
||||
JSONObject result = new JSONObject();
|
||||
JSONArray runners = new JSONArray();
|
||||
result.put("runners", runners);
|
||||
for (Runner runner: mRunners) {
|
||||
for (Runner runner : mRunners) {
|
||||
runners.put(runner.toJson());
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
package com.github.google.bumble.btbench
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.bluetooth.BluetoothAdapter
|
||||
import android.bluetooth.BluetoothDevice
|
||||
import android.bluetooth.BluetoothGatt
|
||||
import android.bluetooth.BluetoothGattCallback
|
||||
import android.bluetooth.BluetoothManager
|
||||
import android.bluetooth.BluetoothProfile
|
||||
import android.content.Context
|
||||
import android.os.Build
|
||||
import androidx.core.content.ContextCompat
|
||||
import java.util.logging.Logger
|
||||
|
||||
private val Log = Logger.getLogger("btbench.connection")
|
||||
|
||||
open class Connection(
|
||||
private val viewModel: AppViewModel,
|
||||
private val bluetoothAdapter: BluetoothAdapter,
|
||||
private val context: Context
|
||||
) : BluetoothGattCallback() {
|
||||
var remoteDevice: BluetoothDevice? = null
|
||||
var gatt: BluetoothGatt? = null
|
||||
|
||||
@SuppressLint("MissingPermission")
|
||||
open fun connect() {
|
||||
val addressIsPublic = viewModel.peerBluetoothAddress.endsWith("/P")
|
||||
val address = viewModel.peerBluetoothAddress.take(17)
|
||||
remoteDevice = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||
bluetoothAdapter.getRemoteLeDevice(
|
||||
address,
|
||||
if (addressIsPublic) {
|
||||
BluetoothDevice.ADDRESS_TYPE_PUBLIC
|
||||
} else {
|
||||
BluetoothDevice.ADDRESS_TYPE_RANDOM
|
||||
}
|
||||
)
|
||||
} else {
|
||||
bluetoothAdapter.getRemoteDevice(address)
|
||||
}
|
||||
|
||||
gatt = remoteDevice?.connectGatt(
|
||||
context,
|
||||
false,
|
||||
this,
|
||||
BluetoothDevice.TRANSPORT_LE,
|
||||
if (viewModel.use2mPhy) BluetoothDevice.PHY_LE_2M_MASK else BluetoothDevice.PHY_LE_1M_MASK
|
||||
)
|
||||
}
|
||||
|
||||
@SuppressLint("MissingPermission")
|
||||
open fun disconnect() {
|
||||
gatt?.disconnect()
|
||||
}
|
||||
|
||||
override fun onMtuChanged(gatt: BluetoothGatt, mtu: Int, status: Int) {
|
||||
Log.info("MTU update: mtu=$mtu status=$status")
|
||||
viewModel.mtu = mtu
|
||||
}
|
||||
|
||||
override fun onPhyUpdate(gatt: BluetoothGatt, txPhy: Int, rxPhy: Int, status: Int) {
|
||||
Log.info("PHY update: tx=$txPhy, rx=$rxPhy, status=$status")
|
||||
viewModel.txPhy = txPhy
|
||||
viewModel.rxPhy = rxPhy
|
||||
}
|
||||
|
||||
override fun onPhyRead(gatt: BluetoothGatt, txPhy: Int, rxPhy: Int, status: Int) {
|
||||
Log.info("PHY: tx=$txPhy, rx=$rxPhy, status=$status")
|
||||
viewModel.txPhy = txPhy
|
||||
viewModel.rxPhy = rxPhy
|
||||
}
|
||||
|
||||
@SuppressLint("MissingPermission")
|
||||
override fun onConnectionStateChange(
|
||||
gatt: BluetoothGatt?, status: Int, newState: Int
|
||||
) {
|
||||
if (status != BluetoothGatt.GATT_SUCCESS) {
|
||||
Log.warning("onConnectionStateChange status=$status")
|
||||
}
|
||||
|
||||
if (gatt != null && newState == BluetoothProfile.STATE_CONNECTED) {
|
||||
if (viewModel.use2mPhy) {
|
||||
Log.info("requesting 2M PHY")
|
||||
gatt.setPreferredPhy(
|
||||
BluetoothDevice.PHY_LE_2M_MASK,
|
||||
BluetoothDevice.PHY_LE_2M_MASK,
|
||||
BluetoothDevice.PHY_OPTION_NO_PREFERRED
|
||||
)
|
||||
}
|
||||
gatt.readPhy()
|
||||
|
||||
// Request an MTU update, even though we don't use GATT, because Android
|
||||
// won't request a larger link layer maximum data length otherwise.
|
||||
gatt.requestMtu(517)
|
||||
|
||||
// Request a specific connection priority
|
||||
val connectionPriority = when (viewModel.connectionPriority) {
|
||||
"BALANCED" -> BluetoothGatt.CONNECTION_PRIORITY_BALANCED
|
||||
"LOW_POWER" -> BluetoothGatt.CONNECTION_PRIORITY_LOW_POWER
|
||||
"HIGH" -> BluetoothGatt.CONNECTION_PRIORITY_HIGH
|
||||
"DCK" -> BluetoothGatt.CONNECTION_PRIORITY_DCK
|
||||
else -> 0
|
||||
}
|
||||
if (!gatt.requestConnectionPriority(connectionPriority)) {
|
||||
Log.warning("requestConnectionPriority failed")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
// Copyright 2024 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.
|
||||
|
||||
package com.github.google.bumble.btbench
|
||||
|
||||
import java.util.UUID
|
||||
|
||||
var CCCD_UUID = UUID.fromString("00002902-0000-1000-8000-00805F9B34FB")
|
||||
|
||||
val BENCH_SERVICE_UUID = UUID.fromString("50DB505C-8AC4-4738-8448-3B1D9CC09CC5")
|
||||
val BENCH_TX_UUID = UUID.fromString("E789C754-41A1-45F4-A948-A0A1A90DBA53")
|
||||
val BENCH_RX_UUID = UUID.fromString("016A2CC7-E14B-4819-935F-1F56EAE4098D")
|
||||
@@ -0,0 +1,224 @@
|
||||
// Copyright 2024 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.
|
||||
|
||||
package com.github.google.bumble.btbench
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.bluetooth.BluetoothAdapter
|
||||
import android.bluetooth.BluetoothGatt
|
||||
import android.bluetooth.BluetoothGattCharacteristic
|
||||
import android.bluetooth.BluetoothGattDescriptor
|
||||
import android.bluetooth.BluetoothProfile
|
||||
import android.content.Context
|
||||
import java.io.IOException
|
||||
import java.util.UUID
|
||||
import java.util.concurrent.CountDownLatch
|
||||
import java.util.concurrent.Semaphore
|
||||
import java.util.logging.Logger
|
||||
import kotlin.concurrent.thread
|
||||
|
||||
private val Log = Logger.getLogger("btbench.gatt-client")
|
||||
|
||||
|
||||
class GattClientConnection(
|
||||
viewModel: AppViewModel,
|
||||
bluetoothAdapter: BluetoothAdapter,
|
||||
context: Context
|
||||
) : Connection(viewModel, bluetoothAdapter, context), PacketIO {
|
||||
override var packetSink: PacketSink? = null
|
||||
private val discoveryDone: CountDownLatch = CountDownLatch(1)
|
||||
private val writeSemaphore: Semaphore = Semaphore(1)
|
||||
var rxCharacteristic: BluetoothGattCharacteristic? = null
|
||||
var txCharacteristic: BluetoothGattCharacteristic? = null
|
||||
|
||||
override fun connect() {
|
||||
super.connect()
|
||||
|
||||
// Check if we're already connected and have discovered the services
|
||||
if (gatt?.getService(BENCH_SERVICE_UUID) != null) {
|
||||
Log.fine("already connected")
|
||||
onServicesDiscovered(gatt, BluetoothGatt.GATT_SUCCESS)
|
||||
}
|
||||
}
|
||||
|
||||
@SuppressLint("MissingPermission")
|
||||
override fun onConnectionStateChange(
|
||||
gatt: BluetoothGatt?, status: Int, newState: Int
|
||||
) {
|
||||
super.onConnectionStateChange(gatt, status, newState)
|
||||
if (status != BluetoothGatt.GATT_SUCCESS) {
|
||||
Log.warning("onConnectionStateChange status=$status")
|
||||
discoveryDone.countDown()
|
||||
return
|
||||
}
|
||||
if (gatt != null && newState == BluetoothProfile.STATE_CONNECTED) {
|
||||
if (!gatt.discoverServices()) {
|
||||
Log.warning("discoverServices could not start")
|
||||
discoveryDone.countDown()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@SuppressLint("MissingPermission")
|
||||
override fun onServicesDiscovered(gatt: BluetoothGatt?, status: Int) {
|
||||
Log.fine("onServicesDiscovered")
|
||||
|
||||
if (status != BluetoothGatt.GATT_SUCCESS) {
|
||||
Log.warning("failed to discover services: ${status}")
|
||||
discoveryDone.countDown()
|
||||
return
|
||||
}
|
||||
|
||||
// Find the service
|
||||
val service = gatt!!.getService(BENCH_SERVICE_UUID)
|
||||
if (service == null) {
|
||||
Log.warning("GATT Service not found")
|
||||
discoveryDone.countDown()
|
||||
return
|
||||
}
|
||||
|
||||
// Find the RX and TX characteristics
|
||||
rxCharacteristic = service.getCharacteristic(BENCH_RX_UUID)
|
||||
if (rxCharacteristic == null) {
|
||||
Log.warning("GATT RX Characteristics not found")
|
||||
discoveryDone.countDown()
|
||||
return
|
||||
}
|
||||
txCharacteristic = service.getCharacteristic(BENCH_TX_UUID)
|
||||
if (txCharacteristic == null) {
|
||||
Log.warning("GATT TX Characteristics not found")
|
||||
discoveryDone.countDown()
|
||||
return
|
||||
}
|
||||
|
||||
// Subscribe to the RX characteristic
|
||||
Log.fine("subscribing to RX")
|
||||
gatt.setCharacteristicNotification(rxCharacteristic, true)
|
||||
val cccdDescriptor = rxCharacteristic!!.getDescriptor(CCCD_UUID)
|
||||
gatt.writeDescriptor(cccdDescriptor, BluetoothGattDescriptor.ENABLE_NOTIFICATION_VALUE);
|
||||
|
||||
Log.info("GATT discovery complete")
|
||||
discoveryDone.countDown()
|
||||
}
|
||||
|
||||
override fun onCharacteristicWrite(
|
||||
gatt: BluetoothGatt?,
|
||||
characteristic: BluetoothGattCharacteristic?,
|
||||
status: Int
|
||||
) {
|
||||
// Now we can write again
|
||||
writeSemaphore.release()
|
||||
|
||||
if (status != BluetoothGatt.GATT_SUCCESS) {
|
||||
Log.warning("onCharacteristicWrite failed: $status")
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
override fun onCharacteristicChanged(
|
||||
gatt: BluetoothGatt,
|
||||
characteristic: BluetoothGattCharacteristic,
|
||||
value: ByteArray
|
||||
) {
|
||||
if (characteristic.uuid == BENCH_RX_UUID && packetSink != null) {
|
||||
val packet = Packet.from(value)
|
||||
packetSink!!.onPacket(packet)
|
||||
}
|
||||
}
|
||||
|
||||
@SuppressLint("MissingPermission")
|
||||
override fun sendPacket(packet: Packet) {
|
||||
if (txCharacteristic == null) {
|
||||
Log.warning("No TX characteristic, dropping")
|
||||
return
|
||||
}
|
||||
|
||||
// Wait until we can write
|
||||
writeSemaphore.acquire()
|
||||
|
||||
// Write the data
|
||||
val data = packet.toBytes()
|
||||
val clampedData = if (data.size > 512) {
|
||||
// Clamp the data to the maximum allowed characteristic data size
|
||||
data.copyOf(512)
|
||||
} else {
|
||||
data
|
||||
}
|
||||
gatt?.writeCharacteristic(
|
||||
txCharacteristic!!,
|
||||
clampedData,
|
||||
BluetoothGattCharacteristic.WRITE_TYPE_NO_RESPONSE
|
||||
)
|
||||
}
|
||||
|
||||
override
|
||||
fun disconnect() {
|
||||
super.disconnect()
|
||||
discoveryDone.countDown()
|
||||
}
|
||||
|
||||
fun waitForDiscoveryCompletion() {
|
||||
discoveryDone.await()
|
||||
}
|
||||
}
|
||||
|
||||
class GattClient(
|
||||
private val viewModel: AppViewModel,
|
||||
bluetoothAdapter: BluetoothAdapter,
|
||||
context: Context,
|
||||
private val createIoClient: (packetIo: PacketIO) -> IoClient
|
||||
) : Mode {
|
||||
private var connection: GattClientConnection =
|
||||
GattClientConnection(viewModel, bluetoothAdapter, context)
|
||||
private var clientThread: Thread? = null
|
||||
|
||||
@SuppressLint("MissingPermission")
|
||||
override fun run() {
|
||||
viewModel.running = true
|
||||
|
||||
clientThread = thread(name = "GattClient") {
|
||||
connection.connect()
|
||||
|
||||
viewModel.aborter = {
|
||||
connection.disconnect()
|
||||
}
|
||||
|
||||
// Discover the rx and tx characteristics
|
||||
connection.waitForDiscoveryCompletion()
|
||||
if (connection.rxCharacteristic == null || connection.txCharacteristic == null) {
|
||||
connection.disconnect()
|
||||
viewModel.running = false
|
||||
return@thread
|
||||
}
|
||||
|
||||
val ioClient = createIoClient(connection)
|
||||
|
||||
try {
|
||||
ioClient.run()
|
||||
viewModel.status = "OK"
|
||||
} catch (error: IOException) {
|
||||
Log.info("run ended abruptly")
|
||||
viewModel.status = "ABORTED"
|
||||
viewModel.lastError = "IO_ERROR"
|
||||
} finally {
|
||||
connection.disconnect()
|
||||
viewModel.running = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun waitForCompletion() {
|
||||
clientThread?.join()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,243 @@
|
||||
// Copyright 2024 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.
|
||||
|
||||
package com.github.google.bumble.btbench
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.bluetooth.BluetoothAdapter
|
||||
import android.bluetooth.BluetoothDevice
|
||||
import android.bluetooth.BluetoothGatt
|
||||
import android.bluetooth.BluetoothGattCharacteristic
|
||||
import android.bluetooth.BluetoothGattDescriptor
|
||||
import android.bluetooth.BluetoothGattServer
|
||||
import android.bluetooth.BluetoothGattServerCallback
|
||||
import android.bluetooth.BluetoothGattService
|
||||
import android.bluetooth.BluetoothManager
|
||||
import android.bluetooth.BluetoothStatusCodes
|
||||
import android.content.Context
|
||||
import androidx.core.content.ContextCompat
|
||||
import java.io.IOException
|
||||
import java.util.concurrent.CountDownLatch
|
||||
import java.util.concurrent.LinkedBlockingQueue
|
||||
import java.util.concurrent.Semaphore
|
||||
import java.util.logging.Logger
|
||||
import kotlin.concurrent.thread
|
||||
import kotlin.experimental.and
|
||||
|
||||
private val Log = Logger.getLogger("btbench.gatt-server")
|
||||
|
||||
@SuppressLint("MissingPermission")
|
||||
class GattServer(
|
||||
private val viewModel: AppViewModel,
|
||||
private val bluetoothAdapter: BluetoothAdapter,
|
||||
context: Context,
|
||||
private val createIoClient: (packetIo: PacketIO) -> IoClient
|
||||
) : Mode, PacketIO, BluetoothGattServerCallback() {
|
||||
override var packetSink: PacketSink? = null
|
||||
private val gattServer: BluetoothGattServer
|
||||
private val rxCharacteristic: BluetoothGattCharacteristic?
|
||||
private val txCharacteristic: BluetoothGattCharacteristic?
|
||||
private val notifySemaphore: Semaphore = Semaphore(1)
|
||||
private val ready: CountDownLatch = CountDownLatch(1)
|
||||
private var peerDevice: BluetoothDevice? = null
|
||||
private var clientThread: Thread? = null
|
||||
private var sinkQueue: LinkedBlockingQueue<Packet>? = null
|
||||
|
||||
init {
|
||||
val bluetoothManager = ContextCompat.getSystemService(context, BluetoothManager::class.java)
|
||||
gattServer = bluetoothManager!!.openGattServer(context, this)
|
||||
val benchService = gattServer.getService(BENCH_SERVICE_UUID)
|
||||
if (benchService == null) {
|
||||
rxCharacteristic = BluetoothGattCharacteristic(
|
||||
BENCH_RX_UUID,
|
||||
BluetoothGattCharacteristic.PROPERTY_NOTIFY,
|
||||
0
|
||||
)
|
||||
txCharacteristic = BluetoothGattCharacteristic(
|
||||
BENCH_TX_UUID,
|
||||
BluetoothGattCharacteristic.PROPERTY_WRITE_NO_RESPONSE,
|
||||
BluetoothGattCharacteristic.PERMISSION_WRITE
|
||||
)
|
||||
val rxCCCD = BluetoothGattDescriptor(
|
||||
CCCD_UUID,
|
||||
BluetoothGattDescriptor.PERMISSION_READ or BluetoothGattDescriptor.PERMISSION_WRITE
|
||||
)
|
||||
rxCharacteristic.addDescriptor(rxCCCD)
|
||||
|
||||
val service =
|
||||
BluetoothGattService(BENCH_SERVICE_UUID, BluetoothGattService.SERVICE_TYPE_PRIMARY)
|
||||
service.addCharacteristic(rxCharacteristic)
|
||||
service.addCharacteristic(txCharacteristic)
|
||||
|
||||
gattServer.addService(service)
|
||||
} else {
|
||||
rxCharacteristic = benchService.getCharacteristic(BENCH_RX_UUID)
|
||||
txCharacteristic = benchService.getCharacteristic(BENCH_TX_UUID)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onCharacteristicWriteRequest(
|
||||
device: BluetoothDevice?,
|
||||
requestId: Int,
|
||||
characteristic: BluetoothGattCharacteristic?,
|
||||
preparedWrite: Boolean,
|
||||
responseNeeded: Boolean,
|
||||
offset: Int,
|
||||
value: ByteArray?
|
||||
) {
|
||||
Log.info("onCharacteristicWriteRequest")
|
||||
if (characteristic != null && characteristic.uuid == BENCH_TX_UUID) {
|
||||
if (packetSink == null) {
|
||||
Log.warning("no sink, dropping")
|
||||
} else if (offset != 0) {
|
||||
Log.warning("offset != 0")
|
||||
} else if (value == null) {
|
||||
Log.warning("no value")
|
||||
} else {
|
||||
// Deliver the packet in a separate thread so that we don't block this
|
||||
// callback.
|
||||
sinkQueue?.put(Packet.from(value))
|
||||
}
|
||||
}
|
||||
|
||||
if (responseNeeded) {
|
||||
gattServer.sendResponse(device, requestId, BluetoothGatt.GATT_SUCCESS, offset, value)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onNotificationSent(device: BluetoothDevice?, status: Int) {
|
||||
if (status == BluetoothGatt.GATT_SUCCESS) {
|
||||
notifySemaphore.release()
|
||||
}
|
||||
}
|
||||
|
||||
override fun onDescriptorWriteRequest(
|
||||
device: BluetoothDevice?,
|
||||
requestId: Int,
|
||||
descriptor: BluetoothGattDescriptor?,
|
||||
preparedWrite: Boolean,
|
||||
responseNeeded: Boolean,
|
||||
offset: Int,
|
||||
value: ByteArray?
|
||||
) {
|
||||
if (descriptor?.uuid == CCCD_UUID && descriptor?.characteristic?.uuid == BENCH_RX_UUID) {
|
||||
if (offset == 0 && value?.size == 2) {
|
||||
if (value[0].and(1).toInt() != 0) {
|
||||
// Subscription
|
||||
Log.fine("peer subscribed to RX")
|
||||
peerDevice = device
|
||||
ready.countDown()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (responseNeeded) {
|
||||
gattServer.sendResponse(device, requestId, BluetoothGatt.GATT_SUCCESS, offset, value)
|
||||
}
|
||||
}
|
||||
|
||||
@SuppressLint("MissingPermission")
|
||||
override fun sendPacket(packet: Packet) {
|
||||
if (peerDevice == null) {
|
||||
Log.warning("no peer device, cannot send")
|
||||
return
|
||||
}
|
||||
if (rxCharacteristic == null) {
|
||||
Log.warning("no RX characteristic, cannot send")
|
||||
return
|
||||
}
|
||||
|
||||
// Wait until we can notify
|
||||
notifySemaphore.acquire()
|
||||
|
||||
// Send the packet via a notification
|
||||
val result = gattServer.notifyCharacteristicChanged(
|
||||
peerDevice!!,
|
||||
rxCharacteristic,
|
||||
false,
|
||||
packet.toBytes()
|
||||
)
|
||||
if (result != BluetoothStatusCodes.SUCCESS) {
|
||||
Log.warning("notifyCharacteristicChanged failed: $result")
|
||||
notifySemaphore.release()
|
||||
}
|
||||
}
|
||||
|
||||
override fun run() {
|
||||
viewModel.running = true
|
||||
|
||||
// Start advertising
|
||||
Log.fine("starting advertiser")
|
||||
val advertiser = Advertiser(bluetoothAdapter)
|
||||
advertiser.start()
|
||||
|
||||
clientThread = thread(name = "GattServer") {
|
||||
// Wait for a subscriber
|
||||
Log.info("waiting for RX subscriber")
|
||||
viewModel.aborter = {
|
||||
ready.countDown()
|
||||
}
|
||||
ready.await()
|
||||
if (peerDevice == null) {
|
||||
Log.warning("server interrupted")
|
||||
viewModel.running = false
|
||||
gattServer.close()
|
||||
return@thread
|
||||
}
|
||||
Log.info("RX subscriber accepted")
|
||||
|
||||
// Stop advertising
|
||||
Log.info("stopping advertiser")
|
||||
advertiser.stop()
|
||||
|
||||
sinkQueue = LinkedBlockingQueue()
|
||||
val sinkWriterThread = thread(name = "SinkWriter") {
|
||||
while (true) {
|
||||
try {
|
||||
val packet = sinkQueue!!.take()
|
||||
if (packetSink == null) {
|
||||
Log.warning("no sink, dropping packet")
|
||||
continue
|
||||
}
|
||||
packetSink!!.onPacket(packet)
|
||||
} catch (error: InterruptedException) {
|
||||
Log.warning("sink writer interrupted")
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val ioClient = createIoClient(this)
|
||||
|
||||
try {
|
||||
ioClient.run()
|
||||
viewModel.status = "OK"
|
||||
} catch (error: IOException) {
|
||||
Log.info("run ended abruptly")
|
||||
viewModel.status = "ABORTED"
|
||||
viewModel.lastError = "IO_ERROR"
|
||||
} finally {
|
||||
sinkWriterThread.interrupt()
|
||||
sinkWriterThread.join()
|
||||
gattServer.close()
|
||||
viewModel.running = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun waitForCompletion() {
|
||||
clientThread?.join()
|
||||
Log.info("server thread completed")
|
||||
}
|
||||
}
|
||||
@@ -16,89 +16,25 @@ package com.github.google.bumble.btbench
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.bluetooth.BluetoothAdapter
|
||||
import android.bluetooth.BluetoothDevice
|
||||
import android.bluetooth.BluetoothGatt
|
||||
import android.bluetooth.BluetoothGattCallback
|
||||
import android.bluetooth.BluetoothProfile
|
||||
import android.content.Context
|
||||
import android.os.Build
|
||||
import java.util.logging.Logger
|
||||
|
||||
private val Log = Logger.getLogger("btbench.l2cap-client")
|
||||
|
||||
class L2capClient(
|
||||
private val viewModel: AppViewModel,
|
||||
private val bluetoothAdapter: BluetoothAdapter,
|
||||
private val context: Context,
|
||||
bluetoothAdapter: BluetoothAdapter,
|
||||
context: Context,
|
||||
private val createIoClient: (packetIo: PacketIO) -> IoClient
|
||||
) : Mode {
|
||||
private var connection: Connection = Connection(viewModel, bluetoothAdapter, context)
|
||||
private var socketClient: SocketClient? = null
|
||||
|
||||
@SuppressLint("MissingPermission")
|
||||
override fun run() {
|
||||
viewModel.running = true
|
||||
val addressIsPublic = viewModel.peerBluetoothAddress.endsWith("/P")
|
||||
val address = viewModel.peerBluetoothAddress.take(17)
|
||||
val remoteDevice = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||
bluetoothAdapter.getRemoteLeDevice(
|
||||
address,
|
||||
if (addressIsPublic) {
|
||||
BluetoothDevice.ADDRESS_TYPE_PUBLIC
|
||||
} else {
|
||||
BluetoothDevice.ADDRESS_TYPE_RANDOM
|
||||
}
|
||||
)
|
||||
} else {
|
||||
bluetoothAdapter.getRemoteDevice(address)
|
||||
}
|
||||
|
||||
val gatt = remoteDevice.connectGatt(
|
||||
context,
|
||||
false,
|
||||
object : BluetoothGattCallback() {
|
||||
override fun onMtuChanged(gatt: BluetoothGatt, mtu: Int, status: Int) {
|
||||
Log.info("MTU update: mtu=$mtu status=$status")
|
||||
viewModel.mtu = mtu
|
||||
}
|
||||
|
||||
override fun onPhyUpdate(gatt: BluetoothGatt, txPhy: Int, rxPhy: Int, status: Int) {
|
||||
Log.info("PHY update: tx=$txPhy, rx=$rxPhy, status=$status")
|
||||
viewModel.txPhy = txPhy
|
||||
viewModel.rxPhy = rxPhy
|
||||
}
|
||||
|
||||
override fun onPhyRead(gatt: BluetoothGatt, txPhy: Int, rxPhy: Int, status: Int) {
|
||||
Log.info("PHY: tx=$txPhy, rx=$rxPhy, status=$status")
|
||||
viewModel.txPhy = txPhy
|
||||
viewModel.rxPhy = rxPhy
|
||||
}
|
||||
|
||||
override fun onConnectionStateChange(
|
||||
gatt: BluetoothGatt?, status: Int, newState: Int
|
||||
) {
|
||||
if (gatt != null && newState == BluetoothProfile.STATE_CONNECTED) {
|
||||
if (viewModel.use2mPhy) {
|
||||
Log.info("requesting 2M PHY")
|
||||
gatt.setPreferredPhy(
|
||||
BluetoothDevice.PHY_LE_2M_MASK,
|
||||
BluetoothDevice.PHY_LE_2M_MASK,
|
||||
BluetoothDevice.PHY_OPTION_NO_PREFERRED
|
||||
)
|
||||
}
|
||||
gatt.readPhy()
|
||||
|
||||
// Request an MTU update, even though we don't use GATT, because Android
|
||||
// won't request a larger link layer maximum data length otherwise.
|
||||
gatt.requestMtu(517)
|
||||
}
|
||||
}
|
||||
},
|
||||
BluetoothDevice.TRANSPORT_LE,
|
||||
if (viewModel.use2mPhy) BluetoothDevice.PHY_LE_2M_MASK else BluetoothDevice.PHY_LE_1M_MASK
|
||||
)
|
||||
|
||||
val socket = remoteDevice.createInsecureL2capChannel(viewModel.l2capPsm)
|
||||
|
||||
connection.connect()
|
||||
val socket = connection.remoteDevice!!.createInsecureL2capChannel(viewModel.l2capPsm)
|
||||
socketClient = SocketClient(viewModel, socket, createIoClient)
|
||||
socketClient!!.run()
|
||||
}
|
||||
|
||||
@@ -37,34 +37,15 @@ class L2capServer(
|
||||
@SuppressLint("MissingPermission")
|
||||
override fun run() {
|
||||
// Advertise so that the peer can find us and connect.
|
||||
val callback = object : AdvertiseCallback() {
|
||||
override fun onStartFailure(errorCode: Int) {
|
||||
Log.warning("failed to start advertising: $errorCode")
|
||||
}
|
||||
|
||||
override fun onStartSuccess(settingsInEffect: AdvertiseSettings) {
|
||||
Log.info("advertising started: $settingsInEffect")
|
||||
}
|
||||
}
|
||||
val advertiseSettingsBuilder = AdvertiseSettings.Builder()
|
||||
.setAdvertiseMode(ADVERTISE_MODE_LOW_LATENCY)
|
||||
.setConnectable(true)
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
|
||||
advertiseSettingsBuilder.setDiscoverable(true)
|
||||
}
|
||||
val advertiseSettings = advertiseSettingsBuilder.build()
|
||||
val advertiseData = AdvertiseData.Builder().build()
|
||||
val scanData = AdvertiseData.Builder().setIncludeDeviceName(true).build()
|
||||
val advertiser = bluetoothAdapter.bluetoothLeAdvertiser
|
||||
|
||||
val advertiser = Advertiser(bluetoothAdapter)
|
||||
val serverSocket = bluetoothAdapter.listenUsingInsecureL2capChannel()
|
||||
viewModel.l2capPsm = serverSocket.psm
|
||||
Log.info("psm = $serverSocket.psm")
|
||||
|
||||
socketServer = SocketServer(viewModel, serverSocket, createIoClient)
|
||||
socketServer!!.run(
|
||||
{ advertiser.stopAdvertising(callback) },
|
||||
{ advertiser.startAdvertising(advertiseSettings, advertiseData, scanData, callback) }
|
||||
{ advertiser.stop() },
|
||||
{ advertiser.start() }
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -17,9 +17,12 @@ package com.github.google.bumble.btbench
|
||||
import android.Manifest
|
||||
import android.annotation.SuppressLint
|
||||
import android.bluetooth.BluetoothAdapter
|
||||
import android.bluetooth.BluetoothDevice
|
||||
import android.bluetooth.BluetoothManager
|
||||
import android.content.BroadcastReceiver
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.IntentFilter
|
||||
import android.content.pm.PackageManager
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
@@ -66,6 +69,7 @@ import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.core.content.ContextCompat
|
||||
import com.github.google.bumble.btbench.ui.theme.BTBenchTheme
|
||||
import java.io.IOException
|
||||
import java.util.logging.Logger
|
||||
|
||||
private val Log = Logger.getLogger("bumble.main-activity")
|
||||
@@ -76,6 +80,7 @@ const val SENDER_PACKET_SIZE_PREF_KEY = "sender_packet_size"
|
||||
const val SENDER_PACKET_INTERVAL_PREF_KEY = "sender_packet_interval"
|
||||
const val SCENARIO_PREF_KEY = "scenario"
|
||||
const val MODE_PREF_KEY = "mode"
|
||||
const val CONNECTION_PRIORITY_PREF_KEY = "connection_priority"
|
||||
|
||||
class MainActivity : ComponentActivity() {
|
||||
private val appViewModel = AppViewModel()
|
||||
@@ -84,6 +89,47 @@ class MainActivity : ComponentActivity() {
|
||||
super.onCreate(savedInstanceState)
|
||||
appViewModel.loadPreferences(getPreferences(Context.MODE_PRIVATE))
|
||||
checkPermissions()
|
||||
registerReceivers()
|
||||
}
|
||||
|
||||
private fun registerReceivers() {
|
||||
val pairingRequestIntentFilter = IntentFilter(BluetoothDevice.ACTION_PAIRING_REQUEST)
|
||||
registerReceiver(object: BroadcastReceiver() {
|
||||
@SuppressLint("MissingPermission")
|
||||
override fun onReceive(context: Context, intent: Intent) {
|
||||
Log.info("ACTION_PAIRING_REQUEST")
|
||||
val extras = intent.extras
|
||||
if (extras != null) {
|
||||
for (key in extras.keySet()) {
|
||||
Log.info("$key: ${extras.get(key)}")
|
||||
}
|
||||
}
|
||||
val device: BluetoothDevice? = intent.getParcelableExtra(BluetoothDevice.EXTRA_DEVICE)
|
||||
if (device != null) {
|
||||
if (checkSelfPermission(Manifest.permission.BLUETOOTH_PRIVILEGED) == PackageManager.PERMISSION_GRANTED) {
|
||||
Log.info("confirming pairing")
|
||||
device.setPairingConfirmation(true)
|
||||
} else {
|
||||
Log.info("we don't have BLUETOOTH_PRIVILEGED, not confirming")
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
}, pairingRequestIntentFilter)
|
||||
|
||||
val bondStateChangedIntentFilter = IntentFilter(BluetoothDevice.ACTION_BOND_STATE_CHANGED)
|
||||
registerReceiver(object: BroadcastReceiver() {
|
||||
@SuppressLint("MissingPermission")
|
||||
override fun onReceive(context: Context, intent: Intent) {
|
||||
Log.info("ACTION_BOND_STATE_CHANGED")
|
||||
val extras = intent.extras
|
||||
if (extras != null) {
|
||||
for (key in extras.keySet()) {
|
||||
Log.info("$key: ${extras.get(key)}")
|
||||
}
|
||||
}
|
||||
}
|
||||
}, bondStateChangedIntentFilter)
|
||||
}
|
||||
|
||||
private fun checkPermissions() {
|
||||
@@ -144,9 +190,7 @@ class MainActivity : ComponentActivity() {
|
||||
initBluetooth()
|
||||
setContent {
|
||||
MainView(
|
||||
appViewModel,
|
||||
::becomeDiscoverable,
|
||||
::runScenario
|
||||
appViewModel, ::becomeDiscoverable, ::runScenario
|
||||
)
|
||||
}
|
||||
|
||||
@@ -182,6 +226,8 @@ class MainActivity : ComponentActivity() {
|
||||
"rfcomm-server" -> appViewModel.mode = RFCOMM_SERVER_MODE
|
||||
"l2cap-client" -> appViewModel.mode = L2CAP_CLIENT_MODE
|
||||
"l2cap-server" -> appViewModel.mode = L2CAP_SERVER_MODE
|
||||
"gatt-client" -> appViewModel.mode = GATT_CLIENT_MODE
|
||||
"gatt-server" -> appViewModel.mode = GATT_SERVER_MODE
|
||||
}
|
||||
}
|
||||
intent.getStringExtra("autostart")?.let {
|
||||
@@ -195,19 +241,24 @@ class MainActivity : ComponentActivity() {
|
||||
|
||||
private fun runScenario() {
|
||||
if (bluetoothAdapter == null) {
|
||||
return
|
||||
throw IOException("bluetooth not enabled")
|
||||
}
|
||||
|
||||
val runner = when (appViewModel.mode) {
|
||||
RFCOMM_CLIENT_MODE -> RfcommClient(appViewModel, bluetoothAdapter!!, ::createIoClient)
|
||||
RFCOMM_SERVER_MODE -> RfcommServer(appViewModel, bluetoothAdapter!!, ::createIoClient)
|
||||
L2CAP_CLIENT_MODE -> L2capClient(
|
||||
appViewModel,
|
||||
bluetoothAdapter!!,
|
||||
baseContext,
|
||||
::createIoClient
|
||||
appViewModel, bluetoothAdapter!!, baseContext, ::createIoClient
|
||||
)
|
||||
|
||||
L2CAP_SERVER_MODE -> L2capServer(appViewModel, bluetoothAdapter!!, ::createIoClient)
|
||||
GATT_CLIENT_MODE -> GattClient(
|
||||
appViewModel, bluetoothAdapter!!, baseContext, ::createIoClient
|
||||
)
|
||||
GATT_SERVER_MODE -> GattServer(
|
||||
appViewModel, bluetoothAdapter!!, baseContext, ::createIoClient
|
||||
)
|
||||
|
||||
else -> throw IllegalStateException()
|
||||
}
|
||||
runner.run()
|
||||
@@ -281,7 +332,7 @@ fun MainView(
|
||||
keyboardController?.hide()
|
||||
focusManager.clearFocus()
|
||||
}),
|
||||
enabled = (appViewModel.mode == RFCOMM_CLIENT_MODE) or (appViewModel.mode == L2CAP_CLIENT_MODE)
|
||||
enabled = (appViewModel.mode == RFCOMM_CLIENT_MODE || appViewModel.mode == L2CAP_CLIENT_MODE || appViewModel.mode == GATT_CLIENT_MODE)
|
||||
)
|
||||
Divider()
|
||||
TextField(
|
||||
@@ -349,24 +400,45 @@ fun MainView(
|
||||
keyboardController?.hide()
|
||||
focusManager.clearFocus()
|
||||
}),
|
||||
enabled = (appViewModel.scenario == PING_SCENARIO)
|
||||
enabled = (appViewModel.scenario == PING_SCENARIO || appViewModel.scenario == SEND_SCENARIO)
|
||||
)
|
||||
Divider()
|
||||
ActionButton(
|
||||
text = "Become Discoverable", onClick = becomeDiscoverable, true
|
||||
)
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Text(text = "2M PHY")
|
||||
Spacer(modifier = Modifier.padding(start = 8.dp))
|
||||
Switch(
|
||||
enabled = (appViewModel.mode == L2CAP_CLIENT_MODE || appViewModel.mode == L2CAP_SERVER_MODE),
|
||||
Switch(enabled = (appViewModel.mode == L2CAP_CLIENT_MODE || appViewModel.mode == L2CAP_SERVER_MODE || appViewModel.mode == GATT_CLIENT_MODE || appViewModel.mode == GATT_SERVER_MODE),
|
||||
checked = appViewModel.use2mPhy,
|
||||
onCheckedChange = { appViewModel.use2mPhy = it }
|
||||
)
|
||||
|
||||
onCheckedChange = { appViewModel.use2mPhy = it })
|
||||
Column(Modifier.selectableGroup()) {
|
||||
listOf(
|
||||
"BALANCED", "LOW", "HIGH", "DCK"
|
||||
).forEach { text ->
|
||||
Row(
|
||||
Modifier
|
||||
.selectable(
|
||||
selected = (text == appViewModel.connectionPriority),
|
||||
onClick = { appViewModel.updateConnectionPriority(text) },
|
||||
role = Role.RadioButton,
|
||||
)
|
||||
.padding(horizontal = 16.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
RadioButton(
|
||||
selected = (text == appViewModel.connectionPriority),
|
||||
onClick = null,
|
||||
enabled = (appViewModel.mode == L2CAP_CLIENT_MODE || appViewModel.mode == L2CAP_SERVER_MODE || appViewModel.mode == GATT_CLIENT_MODE || appViewModel.mode == GATT_SERVER_MODE)
|
||||
)
|
||||
Text(
|
||||
text = text,
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
modifier = Modifier.padding(start = 16.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Row {
|
||||
Column(Modifier.selectableGroup()) {
|
||||
@@ -374,7 +446,9 @@ fun MainView(
|
||||
RFCOMM_CLIENT_MODE,
|
||||
RFCOMM_SERVER_MODE,
|
||||
L2CAP_CLIENT_MODE,
|
||||
L2CAP_SERVER_MODE
|
||||
L2CAP_SERVER_MODE,
|
||||
GATT_CLIENT_MODE,
|
||||
GATT_SERVER_MODE
|
||||
).forEach { text ->
|
||||
Row(
|
||||
Modifier
|
||||
@@ -387,8 +461,7 @@ fun MainView(
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
RadioButton(
|
||||
selected = (text == appViewModel.mode),
|
||||
onClick = null
|
||||
selected = (text == appViewModel.mode), onClick = null
|
||||
)
|
||||
Text(
|
||||
text = text,
|
||||
@@ -400,10 +473,7 @@ fun MainView(
|
||||
}
|
||||
Column(Modifier.selectableGroup()) {
|
||||
listOf(
|
||||
SEND_SCENARIO,
|
||||
RECEIVE_SCENARIO,
|
||||
PING_SCENARIO,
|
||||
PONG_SCENARIO
|
||||
SEND_SCENARIO, RECEIVE_SCENARIO, PING_SCENARIO, PONG_SCENARIO
|
||||
).forEach { text ->
|
||||
Row(
|
||||
Modifier
|
||||
@@ -416,8 +486,7 @@ fun MainView(
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
RadioButton(
|
||||
selected = (text == appViewModel.scenario),
|
||||
onClick = null
|
||||
selected = (text == appViewModel.scenario), onClick = null
|
||||
)
|
||||
Text(
|
||||
text = text,
|
||||
@@ -435,20 +504,29 @@ fun MainView(
|
||||
ActionButton(
|
||||
text = "Stop", onClick = appViewModel::abort, enabled = appViewModel.running
|
||||
)
|
||||
ActionButton(
|
||||
text = "Become Discoverable", onClick = becomeDiscoverable, true
|
||||
)
|
||||
}
|
||||
Divider()
|
||||
Text(
|
||||
text = if (appViewModel.mtu != 0) "MTU: ${appViewModel.mtu}" else ""
|
||||
)
|
||||
Text(
|
||||
text = if (appViewModel.rxPhy != 0 || appViewModel.txPhy != 0) "PHY: tx=${appViewModel.txPhy}, rx=${appViewModel.rxPhy}" else ""
|
||||
)
|
||||
if (appViewModel.mtu != 0) {
|
||||
Text(
|
||||
text = "MTU: ${appViewModel.mtu}"
|
||||
)
|
||||
}
|
||||
if (appViewModel.rxPhy != 0) {
|
||||
Text(
|
||||
text = "PHY: tx=${appViewModel.txPhy}, rx=${appViewModel.rxPhy}"
|
||||
)
|
||||
}
|
||||
Text(
|
||||
text = "Status: ${appViewModel.status}"
|
||||
)
|
||||
Text(
|
||||
text = "Last Error: ${appViewModel.lastError}"
|
||||
)
|
||||
if (appViewModel.lastError.isNotEmpty()) {
|
||||
Text(
|
||||
text = "Last Error: ${appViewModel.lastError}"
|
||||
)
|
||||
}
|
||||
Text(
|
||||
text = "Packets Sent: ${appViewModel.packetsSent}"
|
||||
)
|
||||
|
||||
@@ -25,6 +25,7 @@ import java.util.UUID
|
||||
|
||||
val DEFAULT_RFCOMM_UUID: UUID = UUID.fromString("E6D55659-C8B4-4B85-96BB-B1143AF6D3AE")
|
||||
const val DEFAULT_PEER_BLUETOOTH_ADDRESS = "AA:BB:CC:DD:EE:FF"
|
||||
const val DEFAULT_STARTUP_DELAY = 3000
|
||||
const val DEFAULT_SENDER_PACKET_COUNT = 100
|
||||
const val DEFAULT_SENDER_PACKET_SIZE = 1024
|
||||
const val DEFAULT_SENDER_PACKET_INTERVAL = 100
|
||||
@@ -34,6 +35,8 @@ const val L2CAP_CLIENT_MODE = "L2CAP Client"
|
||||
const val L2CAP_SERVER_MODE = "L2CAP Server"
|
||||
const val RFCOMM_CLIENT_MODE = "RFCOMM Client"
|
||||
const val RFCOMM_SERVER_MODE = "RFCOMM Server"
|
||||
const val GATT_CLIENT_MODE = "GATT Client"
|
||||
const val GATT_SERVER_MODE = "GATT Server"
|
||||
|
||||
const val SEND_SCENARIO = "Send"
|
||||
const val RECEIVE_SCENARIO = "Receive"
|
||||
@@ -47,8 +50,10 @@ class AppViewModel : ViewModel() {
|
||||
var mode by mutableStateOf(RFCOMM_SERVER_MODE)
|
||||
var scenario by mutableStateOf(RECEIVE_SCENARIO)
|
||||
var peerBluetoothAddress by mutableStateOf(DEFAULT_PEER_BLUETOOTH_ADDRESS)
|
||||
var startupDelay by mutableIntStateOf(DEFAULT_STARTUP_DELAY)
|
||||
var l2capPsm by mutableIntStateOf(DEFAULT_PSM)
|
||||
var use2mPhy by mutableStateOf(true)
|
||||
var connectionPriority by mutableStateOf("BALANCED")
|
||||
var mtu by mutableIntStateOf(0)
|
||||
var rxPhy by mutableIntStateOf(0)
|
||||
var txPhy by mutableIntStateOf(0)
|
||||
@@ -98,6 +103,11 @@ class AppViewModel : ViewModel() {
|
||||
if (savedScenario != null) {
|
||||
scenario = savedScenario
|
||||
}
|
||||
|
||||
val savedConnectionPriority = preferences.getString(CONNECTION_PRIORITY_PREF_KEY, null)
|
||||
if (savedConnectionPriority != null) {
|
||||
connectionPriority = savedConnectionPriority
|
||||
}
|
||||
}
|
||||
|
||||
fun updatePeerBluetoothAddress(peerBluetoothAddress: String) {
|
||||
@@ -220,6 +230,14 @@ class AppViewModel : ViewModel() {
|
||||
}
|
||||
}
|
||||
|
||||
fun updateConnectionPriority(connectionPriority: String) {
|
||||
this.connectionPriority = connectionPriority
|
||||
with(preferences!!.edit()) {
|
||||
putString(CONNECTION_PRIORITY_PREF_KEY, connectionPriority)
|
||||
apply()
|
||||
}
|
||||
}
|
||||
|
||||
fun clear() {
|
||||
status = ""
|
||||
lastError = ""
|
||||
|
||||
@@ -17,6 +17,7 @@ package com.github.google.bumble.btbench
|
||||
import android.bluetooth.BluetoothSocket
|
||||
import java.io.IOException
|
||||
import java.nio.ByteBuffer
|
||||
import java.nio.ByteOrder
|
||||
import java.util.logging.Logger
|
||||
import kotlin.math.min
|
||||
|
||||
@@ -37,11 +38,16 @@ abstract class Packet(val type: Int, val payload: ByteArray = ByteArray(0)) {
|
||||
RESET -> ResetPacket()
|
||||
SEQUENCE -> SequencePacket(
|
||||
data[1].toInt(),
|
||||
ByteBuffer.wrap(data, 2, 4).getInt(),
|
||||
data.sliceArray(6..<data.size)
|
||||
ByteBuffer.wrap(data, 2, 4).order(ByteOrder.LITTLE_ENDIAN).getInt(),
|
||||
ByteBuffer.wrap(data, 6, 4).order(ByteOrder.LITTLE_ENDIAN).getInt(),
|
||||
data.sliceArray(10..<data.size)
|
||||
)
|
||||
|
||||
ACK -> AckPacket(
|
||||
data[1].toInt(),
|
||||
ByteBuffer.wrap(data, 2, 4).order(ByteOrder.LITTLE_ENDIAN).getInt()
|
||||
)
|
||||
|
||||
ACK -> AckPacket(data[1].toInt(), ByteBuffer.wrap(data, 2, 4).getInt())
|
||||
else -> GenericPacket(data[0].toInt(), data.sliceArray(1..<data.size))
|
||||
}
|
||||
}
|
||||
@@ -57,16 +63,24 @@ class ResetPacket : Packet(RESET)
|
||||
|
||||
class AckPacket(val flags: Int, val sequenceNumber: Int) : Packet(ACK) {
|
||||
override fun toBytes(): ByteArray {
|
||||
return ByteBuffer.allocate(1 + 1 + 4).put(type.toByte()).put(flags.toByte())
|
||||
return ByteBuffer.allocate(6).order(
|
||||
ByteOrder.LITTLE_ENDIAN
|
||||
).put(type.toByte()).put(flags.toByte())
|
||||
.putInt(sequenceNumber).array()
|
||||
}
|
||||
}
|
||||
|
||||
class SequencePacket(val flags: Int, val sequenceNumber: Int, payload: ByteArray) :
|
||||
class SequencePacket(
|
||||
val flags: Int,
|
||||
val sequenceNumber: Int,
|
||||
val timestamp: Int,
|
||||
payload: ByteArray
|
||||
) :
|
||||
Packet(SEQUENCE, payload) {
|
||||
override fun toBytes(): ByteArray {
|
||||
return ByteBuffer.allocate(1 + 1 + 4 + payload.size).put(type.toByte()).put(flags.toByte())
|
||||
.putInt(sequenceNumber).put(payload).array()
|
||||
return ByteBuffer.allocate(10 + payload.size).order(ByteOrder.LITTLE_ENDIAN)
|
||||
.put(type.toByte()).put(flags.toByte())
|
||||
.putInt(sequenceNumber).putInt(timestamp).put(payload).array()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -19,8 +19,6 @@ import java.util.logging.Logger
|
||||
import kotlin.time.Duration.Companion.milliseconds
|
||||
import kotlin.time.TimeSource
|
||||
|
||||
private const val DEFAULT_STARTUP_DELAY = 3000
|
||||
|
||||
private val Log = Logger.getLogger("btbench.pinger")
|
||||
|
||||
class Pinger(private val viewModel: AppViewModel, private val packetIO: PacketIO) : IoClient,
|
||||
@@ -36,8 +34,8 @@ class Pinger(private val viewModel: AppViewModel, private val packetIO: PacketIO
|
||||
override fun run() {
|
||||
viewModel.clear()
|
||||
|
||||
Log.info("startup delay: $DEFAULT_STARTUP_DELAY")
|
||||
Thread.sleep(DEFAULT_STARTUP_DELAY.toLong());
|
||||
Log.info("startup delay: ${viewModel.startupDelay}")
|
||||
Thread.sleep(viewModel.startupDelay.toLong());
|
||||
Log.info("running")
|
||||
|
||||
Log.info("sending reset")
|
||||
@@ -48,19 +46,23 @@ class Pinger(private val viewModel: AppViewModel, private val packetIO: PacketIO
|
||||
|
||||
val startTime = TimeSource.Monotonic.markNow()
|
||||
for (i in 0..<packetCount) {
|
||||
val now = TimeSource.Monotonic.markNow()
|
||||
val targetTime = startTime + (i * viewModel.senderPacketInterval).milliseconds
|
||||
val delay = targetTime - now
|
||||
if (delay.isPositive()) {
|
||||
Log.info("sleeping ${delay.inWholeMilliseconds} ms")
|
||||
Thread.sleep(delay.inWholeMilliseconds)
|
||||
var now = TimeSource.Monotonic.markNow()
|
||||
if (viewModel.senderPacketInterval > 0) {
|
||||
val targetTime = startTime + (i * viewModel.senderPacketInterval).milliseconds
|
||||
val delay = targetTime - now
|
||||
if (delay.isPositive()) {
|
||||
Log.info("sleeping ${delay.inWholeMilliseconds} ms")
|
||||
Thread.sleep(delay.inWholeMilliseconds)
|
||||
now = TimeSource.Monotonic.markNow()
|
||||
}
|
||||
}
|
||||
pingTimes.add(TimeSource.Monotonic.markNow())
|
||||
packetIO.sendPacket(
|
||||
SequencePacket(
|
||||
if (i < packetCount - 1) 0 else Packet.LAST_FLAG,
|
||||
i,
|
||||
ByteArray(packetSize - 6)
|
||||
(now - startTime).inWholeMicroseconds.toInt(),
|
||||
ByteArray(packetSize - 10)
|
||||
)
|
||||
)
|
||||
viewModel.packetsSent = i + 1
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
|
||||
package com.github.google.bumble.btbench
|
||||
|
||||
import java.util.concurrent.CountDownLatch
|
||||
import java.util.logging.Logger
|
||||
import kotlin.time.TimeSource
|
||||
|
||||
@@ -23,6 +24,7 @@ class Ponger(private val viewModel: AppViewModel, private val packetIO: PacketIO
|
||||
private var startTime: TimeSource.Monotonic.ValueTimeMark = TimeSource.Monotonic.markNow()
|
||||
private var lastPacketTime: TimeSource.Monotonic.ValueTimeMark = TimeSource.Monotonic.markNow()
|
||||
private var expectedSequenceNumber: Int = 0
|
||||
private val done = CountDownLatch(1)
|
||||
|
||||
init {
|
||||
packetIO.packetSink = this
|
||||
@@ -30,6 +32,7 @@ class Ponger(private val viewModel: AppViewModel, private val packetIO: PacketIO
|
||||
|
||||
override fun run() {
|
||||
viewModel.clear()
|
||||
done.await()
|
||||
}
|
||||
|
||||
override fun abort() {}
|
||||
@@ -58,5 +61,10 @@ class Ponger(private val viewModel: AppViewModel, private val packetIO: PacketIO
|
||||
|
||||
packetIO.sendPacket(AckPacket(packet.flags, packet.sequenceNumber))
|
||||
viewModel.packetsSent += 1
|
||||
|
||||
if (packet.flags and Packet.LAST_FLAG != 0) {
|
||||
Log.info("received last packet")
|
||||
done.countDown()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
|
||||
package com.github.google.bumble.btbench
|
||||
|
||||
import java.util.concurrent.CountDownLatch
|
||||
import java.util.logging.Logger
|
||||
import kotlin.time.DurationUnit
|
||||
import kotlin.time.TimeSource
|
||||
@@ -24,6 +25,7 @@ class Receiver(private val viewModel: AppViewModel, private val packetIO: Packet
|
||||
private var startTime: TimeSource.Monotonic.ValueTimeMark = TimeSource.Monotonic.markNow()
|
||||
private var lastPacketTime: TimeSource.Monotonic.ValueTimeMark = TimeSource.Monotonic.markNow()
|
||||
private var bytesReceived = 0
|
||||
private val done = CountDownLatch(1)
|
||||
|
||||
init {
|
||||
packetIO.packetSink = this
|
||||
@@ -31,6 +33,7 @@ class Receiver(private val viewModel: AppViewModel, private val packetIO: Packet
|
||||
|
||||
override fun run() {
|
||||
viewModel.clear()
|
||||
done.await()
|
||||
}
|
||||
|
||||
override fun abort() {}
|
||||
@@ -62,6 +65,7 @@ class Receiver(private val viewModel: AppViewModel, private val packetIO: Packet
|
||||
Log.info("throughput: $throughput")
|
||||
viewModel.throughput = throughput
|
||||
packetIO.sendPacket(AckPacket(packet.flags, packet.sequenceNumber))
|
||||
done.countDown()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,11 +16,10 @@ package com.github.google.bumble.btbench
|
||||
|
||||
import java.util.concurrent.Semaphore
|
||||
import java.util.logging.Logger
|
||||
import kotlin.time.Duration.Companion.milliseconds
|
||||
import kotlin.time.DurationUnit
|
||||
import kotlin.time.TimeSource
|
||||
|
||||
private const val DEFAULT_STARTUP_DELAY = 3000
|
||||
|
||||
private val Log = Logger.getLogger("btbench.sender")
|
||||
|
||||
class Sender(private val viewModel: AppViewModel, private val packetIO: PacketIO) : IoClient,
|
||||
@@ -36,8 +35,8 @@ class Sender(private val viewModel: AppViewModel, private val packetIO: PacketIO
|
||||
override fun run() {
|
||||
viewModel.clear()
|
||||
|
||||
Log.info("startup delay: $DEFAULT_STARTUP_DELAY")
|
||||
Thread.sleep(DEFAULT_STARTUP_DELAY.toLong());
|
||||
Log.info("startup delay: ${viewModel.startupDelay}")
|
||||
Thread.sleep(viewModel.startupDelay.toLong());
|
||||
Log.info("running")
|
||||
|
||||
Log.info("sending reset")
|
||||
@@ -47,20 +46,32 @@ class Sender(private val viewModel: AppViewModel, private val packetIO: PacketIO
|
||||
|
||||
val packetCount = viewModel.senderPacketCount
|
||||
val packetSize = viewModel.senderPacketSize
|
||||
for (i in 0..<packetCount - 1) {
|
||||
packetIO.sendPacket(SequencePacket(0, i, ByteArray(packetSize - 6)))
|
||||
for (i in 0..<packetCount) {
|
||||
var now = TimeSource.Monotonic.markNow()
|
||||
if (viewModel.senderPacketInterval > 0) {
|
||||
val targetTime = startTime + (i * viewModel.senderPacketInterval).milliseconds
|
||||
val delay = targetTime - now
|
||||
if (delay.isPositive()) {
|
||||
Log.info("sleeping ${delay.inWholeMilliseconds} ms")
|
||||
Thread.sleep(delay.inWholeMilliseconds)
|
||||
}
|
||||
now = TimeSource.Monotonic.markNow()
|
||||
}
|
||||
val flags = when (i) {
|
||||
packetCount - 1 -> Packet.LAST_FLAG
|
||||
else -> 0
|
||||
}
|
||||
packetIO.sendPacket(
|
||||
SequencePacket(
|
||||
flags,
|
||||
i,
|
||||
(now - startTime).inWholeMicroseconds.toInt(),
|
||||
ByteArray(packetSize - 10)
|
||||
)
|
||||
)
|
||||
bytesSent += packetSize
|
||||
viewModel.packetsSent = i + 1
|
||||
}
|
||||
packetIO.sendPacket(
|
||||
SequencePacket(
|
||||
Packet.LAST_FLAG,
|
||||
packetCount - 1,
|
||||
ByteArray(packetSize - 6)
|
||||
)
|
||||
)
|
||||
bytesSent += packetSize
|
||||
viewModel.packetsSent = packetCount
|
||||
|
||||
// Wait for the ACK
|
||||
Log.info("waiting for ACK")
|
||||
|
||||
@@ -57,19 +57,21 @@ development = [
|
||||
]
|
||||
avatar = [
|
||||
"pandora-avatar == 0.0.10",
|
||||
"rootcanal == 1.10.0 ; python_version>='3.10'",
|
||||
"rootcanal == 1.11.1 ; python_version>='3.10'",
|
||||
]
|
||||
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 >= 1.1.3; python_version>='3.10' and ((platform_system=='Linux' and platform_machine=='x86_64') or (platform_system=='Darwin' and platform_machine=='arm64'))",
|
||||
"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"
|
||||
@@ -153,7 +155,7 @@ disable = [
|
||||
]
|
||||
|
||||
[tool.pylint.main]
|
||||
ignore = "pandora" # FIXME: pylint does not support stubs yet:
|
||||
ignore=["pandora", "mobly"] # FIXME: pylint does not support stubs yet
|
||||
|
||||
[tool.pylint.typecheck]
|
||||
signature-mutators = "AsyncRunner.run_in_task"
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user