forked from auracaster/bumble_mirror
Compare commits
26 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| d6100755b1 | |||
| f368b5e518 | |||
| 5293d32dc6 | |||
| 6d9a0bf4e1 | |||
| 3c7b5df7c5 | |||
| dedc0aca54 | |||
| 7c019b574f | |||
| 9b485fd943 | |||
| fdee8269ec | |||
| 0767f2d4ae | |||
| c4a0846727 | |||
| 83ac70e426 | |||
| 01cce3525f | |||
| b9d35aea47 | |||
| 079cf6b896 | |||
| 180655088c | |||
| a1bade6f20 | |||
| 5d80e7fd80 | |||
| 2198692961 | |||
| afee659ca6 | |||
| 6fe7931d7d | |||
| 9023407ee4 | |||
| 54d961bbe5 | |||
| cbd46adbcf | |||
| 745e107849 | |||
| 1eb9d8d055 |
@@ -44,7 +44,7 @@ jobs:
|
||||
run: cat rootcanal.log
|
||||
- name: Upload Mobly logs
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v3
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: mobly-logs
|
||||
name: mobly-logs-${{ strategy.job-index }}
|
||||
path: /tmp/logs/mobly/bumble.bumbles/
|
||||
|
||||
+15
-1
@@ -1,4 +1,4 @@
|
||||
# Copyright 2024 Google LLC
|
||||
# Copyright 2025 Google LLC
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
@@ -825,10 +825,24 @@ async def run_broadcast(
|
||||
),
|
||||
)
|
||||
print('Setup ISO Data Path')
|
||||
|
||||
def on_flow(packet_queue):
|
||||
print(
|
||||
f'\rPACKETS: pending={packet_queue.pending}, '
|
||||
f'queued={packet_queue.queued}, completed={packet_queue.completed}',
|
||||
end='',
|
||||
)
|
||||
|
||||
packet_queue = None
|
||||
for bis_link in big.bis_links:
|
||||
await bis_link.setup_data_path(
|
||||
direction=bis_link.Direction.HOST_TO_CONTROLLER
|
||||
)
|
||||
if packet_queue is None:
|
||||
packet_queue = bis_link.data_packet_queue
|
||||
|
||||
if packet_queue:
|
||||
packet_queue.on('flow', lambda: on_flow(packet_queue))
|
||||
|
||||
for frame in itertools.cycle(frames):
|
||||
mid = len(frame) // 2
|
||||
|
||||
+224
-146
@@ -16,6 +16,7 @@
|
||||
# Imports
|
||||
# -----------------------------------------------------------------------------
|
||||
import asyncio
|
||||
import dataclasses
|
||||
import enum
|
||||
import logging
|
||||
import os
|
||||
@@ -97,34 +98,6 @@ 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)
|
||||
@@ -225,13 +198,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 +376,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 +415,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 +426,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 +455,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 +517,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 +587,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 +646,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 +686,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()
|
||||
@@ -1471,7 +1548,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 +1607,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 +1683,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
-6
@@ -37,6 +37,8 @@ from bumble.hci import (
|
||||
HCI_Command_Status_Event,
|
||||
HCI_READ_BUFFER_SIZE_COMMAND,
|
||||
HCI_Read_Buffer_Size_Command,
|
||||
HCI_LE_READ_BUFFER_SIZE_V2_COMMAND,
|
||||
HCI_LE_Read_Buffer_Size_V2_Command,
|
||||
HCI_READ_BD_ADDR_COMMAND,
|
||||
HCI_Read_BD_ADDR_Command,
|
||||
HCI_READ_LOCAL_NAME_COMMAND,
|
||||
@@ -147,7 +149,7 @@ async def get_le_info(host: Host) -> None:
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
async def get_acl_flow_control_info(host: Host) -> None:
|
||||
async def get_flow_control_info(host: Host) -> None:
|
||||
print()
|
||||
|
||||
if host.supports_command(HCI_READ_BUFFER_SIZE_COMMAND):
|
||||
@@ -160,14 +162,28 @@ async def get_acl_flow_control_info(host: Host) -> None:
|
||||
f'packets of size {response.return_parameters.hc_acl_data_packet_length}',
|
||||
)
|
||||
|
||||
if host.supports_command(HCI_LE_READ_BUFFER_SIZE_COMMAND):
|
||||
if host.supports_command(HCI_LE_READ_BUFFER_SIZE_V2_COMMAND):
|
||||
response = await host.send_command(
|
||||
HCI_LE_Read_Buffer_Size_V2_Command(), check_result=True
|
||||
)
|
||||
print(
|
||||
color('LE ACL Flow Control:', 'yellow'),
|
||||
f'{response.return_parameters.total_num_le_acl_data_packets} '
|
||||
f'packets of size {response.return_parameters.le_acl_data_packet_length}',
|
||||
)
|
||||
print(
|
||||
color('LE ISO Flow Control:', 'yellow'),
|
||||
f'{response.return_parameters.total_num_iso_data_packets} '
|
||||
f'packets of size {response.return_parameters.iso_data_packet_length}',
|
||||
)
|
||||
elif host.supports_command(HCI_LE_READ_BUFFER_SIZE_COMMAND):
|
||||
response = await host.send_command(
|
||||
HCI_LE_Read_Buffer_Size_Command(), check_result=True
|
||||
)
|
||||
print(
|
||||
color('LE ACL Flow Control:', 'yellow'),
|
||||
f'{response.return_parameters.hc_total_num_le_acl_data_packets} '
|
||||
f'packets of size {response.return_parameters.hc_le_acl_data_packet_length}',
|
||||
f'{response.return_parameters.total_num_le_acl_data_packets} '
|
||||
f'packets of size {response.return_parameters.le_acl_data_packet_length}',
|
||||
)
|
||||
|
||||
|
||||
@@ -274,8 +290,8 @@ async def async_main(latency_probes, transport):
|
||||
# Get the LE info
|
||||
await get_le_info(host)
|
||||
|
||||
# Print the ACL flow control info
|
||||
await get_acl_flow_control_info(host)
|
||||
# Print the flow control info
|
||||
await get_flow_control_info(host)
|
||||
|
||||
# Get codec info
|
||||
await get_codecs_info(host)
|
||||
|
||||
+24
-9
@@ -154,15 +154,17 @@ class Controller:
|
||||
'0000000060000000'
|
||||
) # BR/EDR Not Supported, LE Supported (Controller)
|
||||
self.manufacturer_name = 0xFFFF
|
||||
self.hc_data_packet_length = 27
|
||||
self.hc_total_num_data_packets = 64
|
||||
self.hc_le_data_packet_length = 27
|
||||
self.hc_total_num_le_data_packets = 64
|
||||
self.acl_data_packet_length = 27
|
||||
self.total_num_acl_data_packets = 64
|
||||
self.le_acl_data_packet_length = 27
|
||||
self.total_num_le_acl_data_packets = 64
|
||||
self.iso_data_packet_length = 960
|
||||
self.total_num_iso_data_packets = 64
|
||||
self.event_mask = 0
|
||||
self.event_mask_page_2 = 0
|
||||
self.supported_commands = bytes.fromhex(
|
||||
'2000800000c000000000e4000000a822000000000000040000f7ffff7f000000'
|
||||
'30f0f9ff01008004000000000000000000000000000000000000000000000000'
|
||||
'30f0f9ff01008004002000000000000000000000000000000000000000000000'
|
||||
)
|
||||
self.le_event_mask = 0
|
||||
self.advertising_parameters = None
|
||||
@@ -1181,9 +1183,9 @@ class Controller:
|
||||
return struct.pack(
|
||||
'<BHBHH',
|
||||
HCI_SUCCESS,
|
||||
self.hc_data_packet_length,
|
||||
self.acl_data_packet_length,
|
||||
0,
|
||||
self.hc_total_num_data_packets,
|
||||
self.total_num_acl_data_packets,
|
||||
0,
|
||||
)
|
||||
|
||||
@@ -1212,8 +1214,21 @@ class Controller:
|
||||
return struct.pack(
|
||||
'<BHB',
|
||||
HCI_SUCCESS,
|
||||
self.hc_le_data_packet_length,
|
||||
self.hc_total_num_le_data_packets,
|
||||
self.le_acl_data_packet_length,
|
||||
self.total_num_le_acl_data_packets,
|
||||
)
|
||||
|
||||
def on_hci_le_read_buffer_size_v2_command(self, _command):
|
||||
'''
|
||||
See Bluetooth spec Vol 4, Part E - 7.8.2 LE Read Buffer Size Command
|
||||
'''
|
||||
return struct.pack(
|
||||
'<BHBHB',
|
||||
HCI_SUCCESS,
|
||||
self.le_acl_data_packet_length,
|
||||
self.total_num_le_acl_data_packets,
|
||||
self.iso_data_packet_length,
|
||||
self.total_num_iso_data_packets,
|
||||
)
|
||||
|
||||
def on_hci_le_read_local_supported_features_command(self, _command):
|
||||
|
||||
+453
-33
@@ -52,7 +52,7 @@ from pyee import EventEmitter
|
||||
from .colors import color
|
||||
from .att import ATT_CID, ATT_DEFAULT_MTU, ATT_PDU
|
||||
from .gatt import Characteristic, Descriptor, Service
|
||||
from .host import Host
|
||||
from .host import DataPacketQueue, Host
|
||||
from .profiles.gap import GenericAccessService
|
||||
from .core import (
|
||||
BT_BR_EDR_TRANSPORT,
|
||||
@@ -93,6 +93,7 @@ from bumble import smp
|
||||
from bumble import sdp
|
||||
from bumble import l2cap
|
||||
from bumble import core
|
||||
from bumble.profiles import gatt_service
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from .transport.common import TransportSource, TransportSink
|
||||
@@ -119,6 +120,8 @@ DEVICE_MIN_EXTENDED_ADVERTISING_SET_HANDLE = 0x00
|
||||
DEVICE_MAX_EXTENDED_ADVERTISING_SET_HANDLE = 0xEF
|
||||
DEVICE_MIN_BIG_HANDLE = 0x00
|
||||
DEVICE_MAX_BIG_HANDLE = 0xEF
|
||||
DEVICE_MIN_CS_CONFIG_ID = 0x00
|
||||
DEVICE_MAX_CS_CONFIG_ID = 0x03
|
||||
|
||||
DEVICE_DEFAULT_ADDRESS = '00:00:00:00:00:00'
|
||||
DEVICE_DEFAULT_ADVERTISING_INTERVAL = 1000 # ms
|
||||
@@ -380,8 +383,12 @@ class LegacyAdvertiser:
|
||||
# Set the advertising parameters
|
||||
await self.device.send_command(
|
||||
hci.HCI_LE_Set_Advertising_Parameters_Command(
|
||||
advertising_interval_min=self.device.advertising_interval_min,
|
||||
advertising_interval_max=self.device.advertising_interval_max,
|
||||
advertising_interval_min=int(
|
||||
self.device.advertising_interval_min / 0.625
|
||||
),
|
||||
advertising_interval_max=int(
|
||||
self.device.advertising_interval_max / 0.625
|
||||
),
|
||||
advertising_type=int(self.advertising_type),
|
||||
own_address_type=self.own_address_type,
|
||||
peer_address_type=self.peer_address.address_type,
|
||||
@@ -1116,6 +1123,72 @@ class BigSync(EventEmitter):
|
||||
await terminated.wait()
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
@dataclass
|
||||
class ChannelSoundingCapabilities:
|
||||
num_config_supported: int
|
||||
max_consecutive_procedures_supported: int
|
||||
num_antennas_supported: int
|
||||
max_antenna_paths_supported: int
|
||||
roles_supported: int
|
||||
modes_supported: int
|
||||
rtt_capability: int
|
||||
rtt_aa_only_n: int
|
||||
rtt_sounding_n: int
|
||||
rtt_random_payload_n: int
|
||||
nadm_sounding_capability: int
|
||||
nadm_random_capability: int
|
||||
cs_sync_phys_supported: int
|
||||
subfeatures_supported: int
|
||||
t_ip1_times_supported: int
|
||||
t_ip2_times_supported: int
|
||||
t_fcs_times_supported: int
|
||||
t_pm_times_supported: int
|
||||
t_sw_time_supported: int
|
||||
tx_snr_capability: int
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
@dataclass
|
||||
class ChannelSoundingConfig:
|
||||
config_id: int
|
||||
main_mode_type: int
|
||||
sub_mode_type: int
|
||||
min_main_mode_steps: int
|
||||
max_main_mode_steps: int
|
||||
main_mode_repetition: int
|
||||
mode_0_steps: int
|
||||
role: int
|
||||
rtt_type: int
|
||||
cs_sync_phy: int
|
||||
channel_map: bytes
|
||||
channel_map_repetition: int
|
||||
channel_selection_type: int
|
||||
ch3c_shape: int
|
||||
ch3c_jump: int
|
||||
reserved: int
|
||||
t_ip1_time: int
|
||||
t_ip2_time: int
|
||||
t_fcs_time: int
|
||||
t_pm_time: int
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
@dataclass
|
||||
class ChannelSoundingProcedure:
|
||||
config_id: int
|
||||
state: int
|
||||
tone_antenna_config_selection: int
|
||||
selected_tx_power: int
|
||||
subevent_len: int
|
||||
subevents_per_event: int
|
||||
subevent_interval: int
|
||||
event_interval: int
|
||||
procedure_interval: int
|
||||
procedure_count: int
|
||||
max_procedure_len: int
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
class LePhyOptions:
|
||||
# Coded PHY preference
|
||||
@@ -1296,8 +1369,8 @@ class Peer:
|
||||
@dataclass
|
||||
class ConnectionParametersPreferences:
|
||||
default: ClassVar[ConnectionParametersPreferences]
|
||||
connection_interval_min: int = DEVICE_DEFAULT_CONNECTION_INTERVAL_MIN
|
||||
connection_interval_max: int = DEVICE_DEFAULT_CONNECTION_INTERVAL_MAX
|
||||
connection_interval_min: float = DEVICE_DEFAULT_CONNECTION_INTERVAL_MIN
|
||||
connection_interval_max: float = DEVICE_DEFAULT_CONNECTION_INTERVAL_MAX
|
||||
max_latency: int = DEVICE_DEFAULT_CONNECTION_MAX_LATENCY
|
||||
supervision_timeout: int = DEVICE_DEFAULT_CONNECTION_SUPERVISION_TIMEOUT
|
||||
min_ce_length: int = DEVICE_DEFAULT_CONNECTION_MIN_CE_LENGTH
|
||||
@@ -1329,7 +1402,6 @@ class ScoLink(CompositeEventEmitter):
|
||||
class _IsoLink:
|
||||
handle: int
|
||||
device: Device
|
||||
packet_sequence_number: int
|
||||
sink: Callable[[hci.HCI_IsoDataPacket], Any] | None = None
|
||||
|
||||
class Direction(IntEnum):
|
||||
@@ -1391,22 +1463,12 @@ class _IsoLink:
|
||||
return response.return_parameters.status
|
||||
|
||||
def write(self, sdu: bytes) -> None:
|
||||
"""Write an ISO SDU.
|
||||
"""Write an ISO SDU."""
|
||||
self.device.host.send_iso_sdu(connection_handle=self.handle, sdu=sdu)
|
||||
|
||||
This will automatically increase the packet sequence number.
|
||||
"""
|
||||
self.device.host.send_hci_packet(
|
||||
hci.HCI_IsoDataPacket(
|
||||
connection_handle=self.handle,
|
||||
data_total_length=len(sdu) + 4,
|
||||
packet_sequence_number=self.packet_sequence_number,
|
||||
pb_flag=0b10,
|
||||
packet_status_flag=0,
|
||||
iso_sdu_length=len(sdu),
|
||||
iso_sdu_fragment=sdu,
|
||||
)
|
||||
)
|
||||
self.packet_sequence_number = (self.packet_sequence_number + 1) % 0x10000
|
||||
@property
|
||||
def data_packet_queue(self) -> DataPacketQueue | None:
|
||||
return self.device.host.get_data_packet_queue(self.handle)
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
@@ -1426,7 +1488,6 @@ class CisLink(CompositeEventEmitter, _IsoLink):
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
super().__init__()
|
||||
self.packet_sequence_number = 0
|
||||
|
||||
async def disconnect(
|
||||
self, reason: int = hci.HCI_REMOTE_USER_TERMINATED_CONNECTION_ERROR
|
||||
@@ -1443,7 +1504,6 @@ class BisLink(_IsoLink):
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
self.device = self.big.device
|
||||
self.packet_sequence_number = 0
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
@@ -1464,6 +1524,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
|
||||
|
||||
@composite_listener
|
||||
class Listener:
|
||||
@@ -1691,6 +1753,10 @@ class Connection(CompositeEventEmitter):
|
||||
self.peer_le_features = await self.device.get_remote_le_features(self)
|
||||
return self.peer_le_features
|
||||
|
||||
@property
|
||||
def data_packet_queue(self) -> DataPacketQueue | None:
|
||||
return self.device.host.get_data_packet_queue(self.handle)
|
||||
|
||||
async def __aenter__(self):
|
||||
return self
|
||||
|
||||
@@ -1754,8 +1820,11 @@ class DeviceConfiguration:
|
||||
address_resolution_offload: bool = False
|
||||
address_generation_offload: bool = False
|
||||
cis_enabled: bool = False
|
||||
channel_sounding_enabled: bool = False
|
||||
identity_address_type: Optional[int] = None
|
||||
io_capability: int = pairing.PairingDelegate.IoCapability.NO_OUTPUT_NO_INPUT
|
||||
gap_service_enabled: bool = True
|
||||
gatt_service_enabled: bool = True
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
self.gatt_services: list[Dict[str, Any]] = []
|
||||
@@ -1922,6 +1991,7 @@ class Device(CompositeEventEmitter):
|
||||
gatt_server: gatt_server.Server
|
||||
advertising_data: bytes
|
||||
scan_response_data: bytes
|
||||
cs_capabilities: ChannelSoundingCapabilities | None = None
|
||||
connections: Dict[int, Connection]
|
||||
pending_connections: Dict[hci.Address, Connection]
|
||||
classic_pending_accepts: Dict[
|
||||
@@ -1938,6 +2008,7 @@ class Device(CompositeEventEmitter):
|
||||
bis_links = dict[int, BisLink]()
|
||||
big_syncs = dict[int, BigSync]()
|
||||
_pending_cis: Dict[int, tuple[int, int]]
|
||||
gatt_service: gatt_service.GenericAttributeProfileService | None = None
|
||||
|
||||
@composite_listener
|
||||
class Listener:
|
||||
@@ -2004,7 +2075,6 @@ class Device(CompositeEventEmitter):
|
||||
address: Optional[hci.Address] = None,
|
||||
config: Optional[DeviceConfiguration] = None,
|
||||
host: Optional[Host] = None,
|
||||
generic_access_service: bool = True,
|
||||
) -> None:
|
||||
super().__init__()
|
||||
|
||||
@@ -2151,7 +2221,10 @@ class Device(CompositeEventEmitter):
|
||||
# Register the SDP server with the L2CAP Channel Manager
|
||||
self.sdp_server.register(self.l2cap_channel_manager)
|
||||
|
||||
self.add_default_services(generic_access_service)
|
||||
self.add_default_services(
|
||||
add_gap_service=config.gap_service_enabled,
|
||||
add_gatt_service=config.gatt_service_enabled,
|
||||
)
|
||||
self.l2cap_channel_manager.register_fixed_channel(ATT_CID, self.on_gatt_pdu)
|
||||
|
||||
# Forward some events
|
||||
@@ -2402,7 +2475,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',
|
||||
)
|
||||
)
|
||||
@@ -2435,6 +2508,41 @@ class Device(CompositeEventEmitter):
|
||||
check_result=True,
|
||||
)
|
||||
|
||||
if self.config.channel_sounding_enabled:
|
||||
await self.send_command(
|
||||
hci.HCI_LE_Set_Host_Feature_Command(
|
||||
bit_number=hci.LeFeature.CHANNEL_SOUNDING_HOST_SUPPORT,
|
||||
bit_value=1,
|
||||
),
|
||||
check_result=True,
|
||||
)
|
||||
result = await self.send_command(
|
||||
hci.HCI_LE_CS_Read_Local_Supported_Capabilities_Command(),
|
||||
check_result=True,
|
||||
)
|
||||
self.cs_capabilities = ChannelSoundingCapabilities(
|
||||
num_config_supported=result.return_parameters.num_config_supported,
|
||||
max_consecutive_procedures_supported=result.return_parameters.max_consecutive_procedures_supported,
|
||||
num_antennas_supported=result.return_parameters.num_antennas_supported,
|
||||
max_antenna_paths_supported=result.return_parameters.max_antenna_paths_supported,
|
||||
roles_supported=result.return_parameters.roles_supported,
|
||||
modes_supported=result.return_parameters.modes_supported,
|
||||
rtt_capability=result.return_parameters.rtt_capability,
|
||||
rtt_aa_only_n=result.return_parameters.rtt_aa_only_n,
|
||||
rtt_sounding_n=result.return_parameters.rtt_sounding_n,
|
||||
rtt_random_payload_n=result.return_parameters.rtt_random_payload_n,
|
||||
nadm_sounding_capability=result.return_parameters.nadm_sounding_capability,
|
||||
nadm_random_capability=result.return_parameters.nadm_random_capability,
|
||||
cs_sync_phys_supported=result.return_parameters.cs_sync_phys_supported,
|
||||
subfeatures_supported=result.return_parameters.subfeatures_supported,
|
||||
t_ip1_times_supported=result.return_parameters.t_ip1_times_supported,
|
||||
t_ip2_times_supported=result.return_parameters.t_ip2_times_supported,
|
||||
t_fcs_times_supported=result.return_parameters.t_fcs_times_supported,
|
||||
t_pm_times_supported=result.return_parameters.t_pm_times_supported,
|
||||
t_sw_time_supported=result.return_parameters.t_sw_time_supported,
|
||||
tx_snr_capability=result.return_parameters.tx_snr_capability,
|
||||
)
|
||||
|
||||
if self.classic_enabled:
|
||||
await self.send_command(
|
||||
hci.HCI_Write_Local_Name_Command(local_name=self.name.encode('utf8'))
|
||||
@@ -2518,7 +2626,7 @@ class Device(CompositeEventEmitter):
|
||||
"""Update the RPA periodically"""
|
||||
while self.le_rpa_timeout != 0:
|
||||
await asyncio.sleep(self.le_rpa_timeout)
|
||||
if not self.update_rpa():
|
||||
if not await self.update_rpa():
|
||||
logger.debug("periodic RPA update failed")
|
||||
|
||||
async def refresh_resolving_list(self) -> None:
|
||||
@@ -2858,8 +2966,8 @@ class Device(CompositeEventEmitter):
|
||||
self,
|
||||
legacy: bool = False,
|
||||
active: bool = True,
|
||||
scan_interval: int = DEVICE_DEFAULT_SCAN_INTERVAL, # Scan interval in ms
|
||||
scan_window: int = DEVICE_DEFAULT_SCAN_WINDOW, # Scan window in ms
|
||||
scan_interval: float = DEVICE_DEFAULT_SCAN_INTERVAL, # Scan interval in ms
|
||||
scan_window: float = DEVICE_DEFAULT_SCAN_WINDOW, # Scan window in ms
|
||||
own_address_type: int = hci.OwnAddressType.RANDOM,
|
||||
filter_duplicates: bool = False,
|
||||
scanning_phys: Sequence[int] = (hci.HCI_LE_1M_PHY, hci.HCI_LE_CODED_PHY),
|
||||
@@ -4481,6 +4589,213 @@ class Device(CompositeEventEmitter):
|
||||
)
|
||||
return await read_feature_future
|
||||
|
||||
@experimental('Only for testing.')
|
||||
async def get_remote_cs_capabilities(
|
||||
self, connection: Connection
|
||||
) -> ChannelSoundingCapabilities:
|
||||
complete_future: asyncio.Future[ChannelSoundingCapabilities] = (
|
||||
asyncio.get_running_loop().create_future()
|
||||
)
|
||||
|
||||
with closing(EventWatcher()) as watcher:
|
||||
watcher.once(
|
||||
connection, 'channel_sounding_capabilities', complete_future.set_result
|
||||
)
|
||||
watcher.once(
|
||||
connection,
|
||||
'channel_sounding_capabilities_failure',
|
||||
lambda status: complete_future.set_exception(hci.HCI_Error(status)),
|
||||
)
|
||||
await self.send_command(
|
||||
hci.HCI_LE_CS_Read_Remote_Supported_Capabilities_Command(
|
||||
connection_handle=connection.handle
|
||||
),
|
||||
check_result=True,
|
||||
)
|
||||
return await complete_future
|
||||
|
||||
@experimental('Only for testing.')
|
||||
async def set_default_cs_settings(
|
||||
self,
|
||||
connection: Connection,
|
||||
role_enable: int = (
|
||||
hci.CsRoleMask.INITIATOR | hci.CsRoleMask.REFLECTOR
|
||||
), # Both role
|
||||
cs_sync_antenna_selection: int = 0xFF, # No Preference
|
||||
max_tx_power: int = 0x04, # 4 dB
|
||||
) -> None:
|
||||
await self.send_command(
|
||||
hci.HCI_LE_CS_Set_Default_Settings_Command(
|
||||
connection_handle=connection.handle,
|
||||
role_enable=role_enable,
|
||||
cs_sync_antenna_selection=cs_sync_antenna_selection,
|
||||
max_tx_power=max_tx_power,
|
||||
),
|
||||
check_result=True,
|
||||
)
|
||||
|
||||
@experimental('Only for testing.')
|
||||
async def create_cs_config(
|
||||
self,
|
||||
connection: Connection,
|
||||
config_id: int | None = None,
|
||||
create_context: int = 0x01,
|
||||
main_mode_type: int = 0x02,
|
||||
sub_mode_type: int = 0xFF,
|
||||
min_main_mode_steps: int = 0x02,
|
||||
max_main_mode_steps: int = 0x05,
|
||||
main_mode_repetition: int = 0x00,
|
||||
mode_0_steps: int = 0x03,
|
||||
role: int = hci.CsRole.INITIATOR,
|
||||
rtt_type: int = hci.RttType.AA_ONLY,
|
||||
cs_sync_phy: int = hci.CsSyncPhy.LE_1M,
|
||||
channel_map: bytes = b'\x54\x55\x55\x54\x55\x55\x55\x55\x55\x15',
|
||||
channel_map_repetition: int = 0x01,
|
||||
channel_selection_type: int = hci.HCI_LE_CS_Create_Config_Command.ChannelSelectionType.ALGO_3B,
|
||||
ch3c_shape: int = hci.HCI_LE_CS_Create_Config_Command.Ch3cShape.HAT,
|
||||
ch3c_jump: int = 0x03,
|
||||
) -> ChannelSoundingConfig:
|
||||
complete_future: asyncio.Future[ChannelSoundingConfig] = (
|
||||
asyncio.get_running_loop().create_future()
|
||||
)
|
||||
if config_id is None:
|
||||
# Allocate an ID.
|
||||
config_id = next(
|
||||
(
|
||||
i
|
||||
for i in range(DEVICE_MIN_CS_CONFIG_ID, DEVICE_MAX_CS_CONFIG_ID + 1)
|
||||
if i not in connection.cs_configs
|
||||
),
|
||||
None,
|
||||
)
|
||||
if config_id is None:
|
||||
raise OutOfResourcesError("No available config ID on this connection!")
|
||||
|
||||
with closing(EventWatcher()) as watcher:
|
||||
watcher.once(
|
||||
connection, 'channel_sounding_config', complete_future.set_result
|
||||
)
|
||||
watcher.once(
|
||||
connection,
|
||||
'channel_sounding_config_failure',
|
||||
lambda status: complete_future.set_exception(hci.HCI_Error(status)),
|
||||
)
|
||||
await self.send_command(
|
||||
hci.HCI_LE_CS_Create_Config_Command(
|
||||
connection_handle=connection.handle,
|
||||
config_id=config_id,
|
||||
create_context=create_context,
|
||||
main_mode_type=main_mode_type,
|
||||
sub_mode_type=sub_mode_type,
|
||||
min_main_mode_steps=min_main_mode_steps,
|
||||
max_main_mode_steps=max_main_mode_steps,
|
||||
main_mode_repetition=main_mode_repetition,
|
||||
mode_0_steps=mode_0_steps,
|
||||
role=role,
|
||||
rtt_type=rtt_type,
|
||||
cs_sync_phy=cs_sync_phy,
|
||||
channel_map=channel_map,
|
||||
channel_map_repetition=channel_map_repetition,
|
||||
channel_selection_type=channel_selection_type,
|
||||
ch3c_shape=ch3c_shape,
|
||||
ch3c_jump=ch3c_jump,
|
||||
reserved=0x00,
|
||||
),
|
||||
check_result=True,
|
||||
)
|
||||
return await complete_future
|
||||
|
||||
@experimental('Only for testing.')
|
||||
async def enable_cs_security(self, connection: Connection) -> None:
|
||||
complete_future: asyncio.Future[None] = (
|
||||
asyncio.get_running_loop().create_future()
|
||||
)
|
||||
with closing(EventWatcher()) as watcher:
|
||||
|
||||
def on_event(event: hci.HCI_LE_CS_Security_Enable_Complete_Event) -> None:
|
||||
if event.connection_handle != connection.handle:
|
||||
return
|
||||
if event.status == hci.HCI_SUCCESS:
|
||||
complete_future.set_result(None)
|
||||
else:
|
||||
complete_future.set_exception(hci.HCI_Error(event.status))
|
||||
|
||||
watcher.once(self.host, 'cs_security', on_event)
|
||||
await self.send_command(
|
||||
hci.HCI_LE_CS_Security_Enable_Command(
|
||||
connection_handle=connection.handle
|
||||
),
|
||||
check_result=True,
|
||||
)
|
||||
return await complete_future
|
||||
|
||||
@experimental('Only for testing.')
|
||||
async def set_cs_procedure_parameters(
|
||||
self,
|
||||
connection: Connection,
|
||||
config: ChannelSoundingConfig,
|
||||
tone_antenna_config_selection=0x00,
|
||||
preferred_peer_antenna=0x00,
|
||||
max_procedure_len=0x2710, # 6.25s
|
||||
min_procedure_interval=0x01,
|
||||
max_procedure_interval=0xFF,
|
||||
max_procedure_count=0x01,
|
||||
min_subevent_len=0x0004E2, # 1250us
|
||||
max_subevent_len=0x1E8480, # 2s
|
||||
phy=hci.CsSyncPhy.LE_1M,
|
||||
tx_power_delta=0x00,
|
||||
snr_control_initiator=hci.CsSnr.NOT_APPLIED,
|
||||
snr_control_reflector=hci.CsSnr.NOT_APPLIED,
|
||||
) -> None:
|
||||
await self.send_command(
|
||||
hci.HCI_LE_CS_Set_Procedure_Parameters_Command(
|
||||
connection_handle=connection.handle,
|
||||
config_id=config.config_id,
|
||||
max_procedure_len=max_procedure_len,
|
||||
min_procedure_interval=min_procedure_interval,
|
||||
max_procedure_interval=max_procedure_interval,
|
||||
max_procedure_count=max_procedure_count,
|
||||
min_subevent_len=min_subevent_len,
|
||||
max_subevent_len=max_subevent_len,
|
||||
tone_antenna_config_selection=tone_antenna_config_selection,
|
||||
phy=phy,
|
||||
tx_power_delta=tx_power_delta,
|
||||
preferred_peer_antenna=preferred_peer_antenna,
|
||||
snr_control_initiator=snr_control_initiator,
|
||||
snr_control_reflector=snr_control_reflector,
|
||||
),
|
||||
check_result=True,
|
||||
)
|
||||
|
||||
@experimental('Only for testing.')
|
||||
async def enable_cs_procedure(
|
||||
self,
|
||||
connection: Connection,
|
||||
config: ChannelSoundingConfig,
|
||||
enabled: bool = True,
|
||||
) -> ChannelSoundingProcedure:
|
||||
complete_future: asyncio.Future[ChannelSoundingProcedure] = (
|
||||
asyncio.get_running_loop().create_future()
|
||||
)
|
||||
with closing(EventWatcher()) as watcher:
|
||||
watcher.once(
|
||||
connection, 'channel_sounding_procedure', complete_future.set_result
|
||||
)
|
||||
watcher.once(
|
||||
connection,
|
||||
'channel_sounding_procedure_failure',
|
||||
lambda x: complete_future.set_exception(hci.HCI_Error(x)),
|
||||
)
|
||||
await self.send_command(
|
||||
hci.HCI_LE_CS_Procedure_Enable_Command(
|
||||
connection_handle=connection.handle,
|
||||
config_id=config.config_id,
|
||||
enable=enabled,
|
||||
),
|
||||
check_result=True,
|
||||
)
|
||||
return await complete_future
|
||||
|
||||
@host_event_handler
|
||||
def on_flush(self):
|
||||
self.emit('flush')
|
||||
@@ -4515,10 +4830,15 @@ class Device(CompositeEventEmitter):
|
||||
def add_services(self, services):
|
||||
self.gatt_server.add_services(services)
|
||||
|
||||
def add_default_services(self, generic_access_service=True):
|
||||
def add_default_services(
|
||||
self, add_gap_service: bool = True, add_gatt_service: bool = True
|
||||
) -> None:
|
||||
# Add a GAP Service if requested
|
||||
if generic_access_service:
|
||||
if add_gap_service:
|
||||
self.gatt_server.add_service(GenericAccessService(self.name))
|
||||
if add_gatt_service:
|
||||
self.gatt_service = gatt_service.GenericAttributeProfileService()
|
||||
self.gatt_server.add_service(self.gatt_service)
|
||||
|
||||
async def notify_subscriber(self, connection, attribute, value=None, force=False):
|
||||
await self.gatt_server.notify_subscriber(connection, attribute, value, force)
|
||||
@@ -5439,6 +5759,106 @@ class Device(CompositeEventEmitter):
|
||||
)
|
||||
connection.emit('connection_data_length_change')
|
||||
|
||||
@host_event_handler
|
||||
def on_cs_remote_supported_capabilities(
|
||||
self, event: hci.HCI_LE_CS_Read_Remote_Supported_Capabilities_Complete_Event
|
||||
):
|
||||
if not (connection := self.lookup_connection(event.connection_handle)):
|
||||
return
|
||||
|
||||
if event.status != hci.HCI_SUCCESS:
|
||||
connection.emit('channel_sounding_capabilities_failure', event.status)
|
||||
return
|
||||
|
||||
capabilities = ChannelSoundingCapabilities(
|
||||
num_config_supported=event.num_config_supported,
|
||||
max_consecutive_procedures_supported=event.max_consecutive_procedures_supported,
|
||||
num_antennas_supported=event.num_antennas_supported,
|
||||
max_antenna_paths_supported=event.max_antenna_paths_supported,
|
||||
roles_supported=event.roles_supported,
|
||||
modes_supported=event.modes_supported,
|
||||
rtt_capability=event.rtt_capability,
|
||||
rtt_aa_only_n=event.rtt_aa_only_n,
|
||||
rtt_sounding_n=event.rtt_sounding_n,
|
||||
rtt_random_payload_n=event.rtt_random_payload_n,
|
||||
nadm_sounding_capability=event.nadm_sounding_capability,
|
||||
nadm_random_capability=event.nadm_random_capability,
|
||||
cs_sync_phys_supported=event.cs_sync_phys_supported,
|
||||
subfeatures_supported=event.subfeatures_supported,
|
||||
t_ip1_times_supported=event.t_ip1_times_supported,
|
||||
t_ip2_times_supported=event.t_ip2_times_supported,
|
||||
t_fcs_times_supported=event.t_fcs_times_supported,
|
||||
t_pm_times_supported=event.t_pm_times_supported,
|
||||
t_sw_time_supported=event.t_sw_time_supported,
|
||||
tx_snr_capability=event.tx_snr_capability,
|
||||
)
|
||||
connection.emit('channel_sounding_capabilities', capabilities)
|
||||
|
||||
@host_event_handler
|
||||
def on_cs_config(self, event: hci.HCI_LE_CS_Config_Complete_Event):
|
||||
if not (connection := self.lookup_connection(event.connection_handle)):
|
||||
return
|
||||
|
||||
if event.status != hci.HCI_SUCCESS:
|
||||
connection.emit('channel_sounding_config_failure', event.status)
|
||||
return
|
||||
if event.action == hci.HCI_LE_CS_Config_Complete_Event.Action.CREATED:
|
||||
config = ChannelSoundingConfig(
|
||||
config_id=event.config_id,
|
||||
main_mode_type=event.main_mode_type,
|
||||
sub_mode_type=event.sub_mode_type,
|
||||
min_main_mode_steps=event.min_main_mode_steps,
|
||||
max_main_mode_steps=event.max_main_mode_steps,
|
||||
main_mode_repetition=event.main_mode_repetition,
|
||||
mode_0_steps=event.mode_0_steps,
|
||||
role=event.role,
|
||||
rtt_type=event.rtt_type,
|
||||
cs_sync_phy=event.cs_sync_phy,
|
||||
channel_map=event.channel_map,
|
||||
channel_map_repetition=event.channel_map_repetition,
|
||||
channel_selection_type=event.channel_selection_type,
|
||||
ch3c_shape=event.ch3c_shape,
|
||||
ch3c_jump=event.ch3c_jump,
|
||||
reserved=event.reserved,
|
||||
t_ip1_time=event.t_ip1_time,
|
||||
t_ip2_time=event.t_ip2_time,
|
||||
t_fcs_time=event.t_fcs_time,
|
||||
t_pm_time=event.t_pm_time,
|
||||
)
|
||||
connection.cs_configs[event.config_id] = config
|
||||
connection.emit('channel_sounding_config', config)
|
||||
elif event.action == hci.HCI_LE_CS_Config_Complete_Event.Action.REMOVED:
|
||||
try:
|
||||
config = connection.cs_configs.pop(event.config_id)
|
||||
connection.emit('channel_sounding_config_removed', config.config_id)
|
||||
except KeyError:
|
||||
logger.error('Removing unknown config %d', event.config_id)
|
||||
|
||||
@host_event_handler
|
||||
def on_cs_procedure(self, event: hci.HCI_LE_CS_Procedure_Enable_Complete_Event):
|
||||
if not (connection := self.lookup_connection(event.connection_handle)):
|
||||
return
|
||||
|
||||
if event.status != hci.HCI_SUCCESS:
|
||||
connection.emit('channel_sounding_procedure_failure', event.status)
|
||||
return
|
||||
|
||||
procedure = ChannelSoundingProcedure(
|
||||
config_id=event.config_id,
|
||||
state=event.state,
|
||||
tone_antenna_config_selection=event.tone_antenna_config_selection,
|
||||
selected_tx_power=event.selected_tx_power,
|
||||
subevent_len=event.subevent_len,
|
||||
subevents_per_event=event.subevents_per_event,
|
||||
subevent_interval=event.subevent_interval,
|
||||
event_interval=event.event_interval,
|
||||
procedure_interval=event.procedure_interval,
|
||||
procedure_count=event.procedure_count,
|
||||
max_procedure_len=event.max_procedure_len,
|
||||
)
|
||||
connection.cs_procedures[procedure.config_id] = procedure
|
||||
connection.emit('channel_sounding_procedure', procedure)
|
||||
|
||||
# [Classic only]
|
||||
@host_event_handler
|
||||
@with_connection_from_address
|
||||
@@ -5496,14 +5916,14 @@ class Device(CompositeEventEmitter):
|
||||
if att_pdu.op_code & 1:
|
||||
if connection.gatt_client is None:
|
||||
logger.warning(
|
||||
color('no GATT client for connection 0x{connection_handle:04X}')
|
||||
'No GATT client for connection 0x%04X', connection.handle
|
||||
)
|
||||
return
|
||||
connection.gatt_client.on_gatt_pdu(att_pdu)
|
||||
else:
|
||||
if connection.gatt_server is None:
|
||||
logger.warning(
|
||||
color('no GATT server for connection 0x{connection_handle:04X}')
|
||||
'No GATT server for connection 0x%04X', connection.handle
|
||||
)
|
||||
return
|
||||
connection.gatt_server.on_gatt_pdu(connection, att_pdu)
|
||||
|
||||
+20
-1
@@ -48,7 +48,6 @@ from bumble.utils import ByteSerializable
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from bumble.gatt_client import AttributeProxy
|
||||
from bumble.device import Connection
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
@@ -802,3 +801,23 @@ class ClientCharacteristicConfigurationBits(enum.IntFlag):
|
||||
DEFAULT = 0x0000
|
||||
NOTIFICATION = 0x0001
|
||||
INDICATION = 0x0002
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
class ClientSupportedFeatures(enum.IntFlag):
|
||||
'''
|
||||
See Vol 3, Part G - 7.2 - Table 7.6: Client Supported Features bit assignments.
|
||||
'''
|
||||
|
||||
ROBUST_CACHING = 0x01
|
||||
ENHANCED_ATT_BEARER = 0x02
|
||||
MULTIPLE_HANDLE_VALUE_NOTIFICATIONS = 0x04
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
class ServerSupportedFeatures(enum.IntFlag):
|
||||
'''
|
||||
See Vol 3, Part G - 7.4 - Table 7.11: Server Supported Features bit assignments.
|
||||
'''
|
||||
|
||||
EATT_SUPPORTED = 0x01
|
||||
|
||||
+47
-9
@@ -791,6 +791,27 @@ class CsSnr(OpenIntEnum):
|
||||
NOT_APPLIED = 0xFF
|
||||
|
||||
|
||||
class CsDoneStatus(OpenIntEnum):
|
||||
ALL_RESULTS_COMPLETED = 0x00
|
||||
PARTIAL = 0x01
|
||||
ABORTED = 0x0F
|
||||
|
||||
|
||||
class CsProcedureAbortReason(OpenIntEnum):
|
||||
NO_ABORT = 0x00
|
||||
LOCAL_HOST_OR_REMOTE_REQUEST = 0x01
|
||||
CHANNEL_MAP_UPDATE_INSTANT_PASSED = 0x02
|
||||
UNSPECIFIED = 0x0F
|
||||
|
||||
|
||||
class CsSubeventAbortReason(OpenIntEnum):
|
||||
NO_ABORT = 0x00
|
||||
LOCAL_HOST_OR_REMOTE_REQUEST = 0x01
|
||||
NO_CS_SYNC_RECEIVED = 0x02
|
||||
SCHEDULING_CONFLICT_OR_LIMITED_RESOURCES = 0x03
|
||||
UNSPECIFIED = 0x0F
|
||||
|
||||
|
||||
# Connection Parameters
|
||||
HCI_CONNECTION_INTERVAL_MS_PER_UNIT = 1.25
|
||||
HCI_CONNECTION_LATENCY_MS_PER_UNIT = 1.25
|
||||
@@ -3585,8 +3606,8 @@ class HCI_LE_Set_Event_Mask_Command(HCI_Command):
|
||||
@HCI_Command.command(
|
||||
return_parameters_fields=[
|
||||
('status', STATUS_SPEC),
|
||||
('hc_le_acl_data_packet_length', 2),
|
||||
('hc_total_num_le_acl_data_packets', 1),
|
||||
('le_acl_data_packet_length', 2),
|
||||
('total_num_le_acl_data_packets', 1),
|
||||
]
|
||||
)
|
||||
class HCI_LE_Read_Buffer_Size_Command(HCI_Command):
|
||||
@@ -3595,6 +3616,22 @@ class HCI_LE_Read_Buffer_Size_Command(HCI_Command):
|
||||
'''
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
@HCI_Command.command(
|
||||
return_parameters_fields=[
|
||||
('status', STATUS_SPEC),
|
||||
('le_acl_data_packet_length', 2),
|
||||
('total_num_le_acl_data_packets', 1),
|
||||
('iso_data_packet_length', 2),
|
||||
('total_num_iso_data_packets', 1),
|
||||
]
|
||||
)
|
||||
class HCI_LE_Read_Buffer_Size_V2_Command(HCI_Command):
|
||||
'''
|
||||
See Bluetooth spec @ 7.8.2 LE Read Buffer Size V2 Command
|
||||
'''
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
@HCI_Command.command(
|
||||
return_parameters_fields=[('status', STATUS_SPEC), ('le_features', 8)]
|
||||
@@ -6490,7 +6527,7 @@ class HCI_LE_CS_Config_Complete_Event(HCI_LE_Meta_Event):
|
||||
('config_id', 1),
|
||||
('state', 1),
|
||||
('tone_antenna_config_selection', 1),
|
||||
('selected_tx_power', 1),
|
||||
('selected_tx_power', -1),
|
||||
('subevent_len', 3),
|
||||
('subevents_per_event', 1),
|
||||
('subevent_interval', 2),
|
||||
@@ -6532,7 +6569,7 @@ class HCI_LE_CS_Procedure_Enable_Complete_Event(HCI_LE_Meta_Event):
|
||||
('start_acl_conn_event_counter', 2),
|
||||
('procedure_counter', 2),
|
||||
('frequency_compensation', 2),
|
||||
('reference_power_level', 1),
|
||||
('reference_power_level', -1),
|
||||
('procedure_done_status', 1),
|
||||
('subevent_done_status', 1),
|
||||
('abort_reason', 1),
|
||||
@@ -6549,7 +6586,7 @@ class HCI_LE_CS_Subevent_Result_Event(HCI_LE_Meta_Event):
|
||||
See Bluetooth spec @ 7.7.65.44 LE CS Subevent Result event
|
||||
'''
|
||||
|
||||
status: int
|
||||
connection_handle: int
|
||||
config_id: int
|
||||
start_acl_conn_event_counter: int
|
||||
procedure_counter: int
|
||||
@@ -6585,7 +6622,7 @@ class HCI_LE_CS_Subevent_Result_Continue_Event(HCI_LE_Meta_Event):
|
||||
See Bluetooth spec @ 7.7.65.45 LE CS Subevent Result Continue event
|
||||
'''
|
||||
|
||||
status: int
|
||||
connection_handle: int
|
||||
config_id: int
|
||||
procedure_done_status: int
|
||||
subevent_done_status: int
|
||||
@@ -7555,7 +7592,7 @@ class HCI_IsoDataPacket(HCI_Packet):
|
||||
if should_include_sdu_info:
|
||||
packet_sequence_number, sdu_info = struct.unpack_from('<HH', packet, pos)
|
||||
iso_sdu_length = sdu_info & 0xFFF
|
||||
packet_status_flag = sdu_info >> 14
|
||||
packet_status_flag = (sdu_info >> 15) & 1
|
||||
pos += 4
|
||||
|
||||
iso_sdu_fragment = packet[pos:]
|
||||
@@ -7589,7 +7626,7 @@ class HCI_IsoDataPacket(HCI_Packet):
|
||||
fmt += 'HH'
|
||||
args += [
|
||||
self.packet_sequence_number,
|
||||
self.iso_sdu_length | self.packet_status_flag << 14,
|
||||
self.iso_sdu_length | self.packet_status_flag << 15,
|
||||
]
|
||||
return struct.pack(fmt, *args) + self.iso_sdu_fragment
|
||||
|
||||
@@ -7597,9 +7634,10 @@ class HCI_IsoDataPacket(HCI_Packet):
|
||||
return (
|
||||
f'{color("ISO", "blue")}: '
|
||||
f'handle=0x{self.connection_handle:04x}, '
|
||||
f'pb={self.pb_flag}, '
|
||||
f'ps={self.packet_status_flag}, '
|
||||
f'data_total_length={self.data_total_length}, '
|
||||
f'sdu={self.iso_sdu_fragment.hex()}'
|
||||
f'sdu_fragment={self.iso_sdu_fragment.hex()}'
|
||||
)
|
||||
|
||||
|
||||
|
||||
+294
-71
@@ -1,4 +1,4 @@
|
||||
# Copyright 2021-2022 Google LLC
|
||||
# Copyright 2021-2025 Google LLC
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
@@ -21,7 +21,6 @@ import collections
|
||||
import dataclasses
|
||||
import logging
|
||||
import struct
|
||||
import itertools
|
||||
|
||||
from typing import (
|
||||
Any,
|
||||
@@ -35,6 +34,8 @@ from typing import (
|
||||
TYPE_CHECKING,
|
||||
)
|
||||
|
||||
import pyee
|
||||
|
||||
from bumble.colors import color
|
||||
from bumble.l2cap import L2CAP_PDU
|
||||
from bumble.snoop import Snooper
|
||||
@@ -60,7 +61,19 @@ logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
class AclPacketQueue:
|
||||
class DataPacketQueue(pyee.EventEmitter):
|
||||
"""
|
||||
Flow-control queue for host->controller data packets (ACL, ISO).
|
||||
|
||||
The queue holds packets associated with a connection handle. The packets
|
||||
are sent to the controller, up to a maximum total number of packets in flight.
|
||||
A packet is considered to be "in flight" when it has been sent to the controller
|
||||
but not completed yet. Packets are no longer "in flight" when the controller
|
||||
declares them as completed.
|
||||
|
||||
The queue emits a 'flow' event whenever one or more packets are completed.
|
||||
"""
|
||||
|
||||
max_packet_size: int
|
||||
|
||||
def __init__(
|
||||
@@ -69,40 +82,105 @@ class AclPacketQueue:
|
||||
max_in_flight: int,
|
||||
send: Callable[[hci.HCI_Packet], None],
|
||||
) -> None:
|
||||
super().__init__()
|
||||
self.max_packet_size = max_packet_size
|
||||
self.max_in_flight = max_in_flight
|
||||
self.in_flight = 0
|
||||
self.send = send
|
||||
self.packets: Deque[hci.HCI_AclDataPacket] = collections.deque()
|
||||
self._in_flight = 0 # Total number of packets in flight across all connections
|
||||
self._in_flight_per_connection: dict[int, int] = collections.defaultdict(
|
||||
int
|
||||
) # Number of packets in flight per connection
|
||||
self._send = send
|
||||
self._packets: Deque[tuple[hci.HCI_Packet, int]] = collections.deque()
|
||||
self._queued = 0
|
||||
self._completed = 0
|
||||
|
||||
def enqueue(self, packet: hci.HCI_AclDataPacket) -> None:
|
||||
self.packets.appendleft(packet)
|
||||
self.check_queue()
|
||||
@property
|
||||
def queued(self) -> int:
|
||||
"""Total number of packets queued since creation."""
|
||||
return self._queued
|
||||
|
||||
if self.packets:
|
||||
@property
|
||||
def completed(self) -> int:
|
||||
"""Total number of packets completed since creation."""
|
||||
return self._completed
|
||||
|
||||
@property
|
||||
def pending(self) -> int:
|
||||
"""Number of packets that have been queued but not completed."""
|
||||
return self._queued - self._completed
|
||||
|
||||
def enqueue(self, packet: hci.HCI_Packet, connection_handle: int) -> None:
|
||||
"""Enqueue a packet associated with a connection"""
|
||||
self._packets.appendleft((packet, connection_handle))
|
||||
self._queued += 1
|
||||
self._check_queue()
|
||||
|
||||
if self._packets:
|
||||
logger.debug(
|
||||
f'{self.in_flight} ACL packets in flight, '
|
||||
f'{len(self.packets)} in queue'
|
||||
f'{self._in_flight} packets in flight, '
|
||||
f'{len(self._packets)} in queue'
|
||||
)
|
||||
|
||||
def check_queue(self) -> None:
|
||||
while self.packets and self.in_flight < self.max_in_flight:
|
||||
packet = self.packets.pop()
|
||||
self.send(packet)
|
||||
self.in_flight += 1
|
||||
def flush(self, connection_handle: int) -> None:
|
||||
"""
|
||||
Remove all packets associated with a connection.
|
||||
|
||||
def on_packets_completed(self, packet_count: int) -> None:
|
||||
if packet_count > self.in_flight:
|
||||
All packets associated with the connection that are in flight are implicitly
|
||||
marked as completed, but no 'flow' event is emitted.
|
||||
"""
|
||||
|
||||
packets_to_keep = [
|
||||
(packet, handle)
|
||||
for (packet, handle) in self._packets
|
||||
if handle != connection_handle
|
||||
]
|
||||
if flushed_count := len(self._packets) - len(packets_to_keep):
|
||||
self._completed += flushed_count
|
||||
self._packets = collections.deque(packets_to_keep)
|
||||
|
||||
if connection_handle in self._in_flight_per_connection:
|
||||
in_flight = self._in_flight_per_connection[connection_handle]
|
||||
self._completed += in_flight
|
||||
self._in_flight -= in_flight
|
||||
del self._in_flight_per_connection[connection_handle]
|
||||
|
||||
def _check_queue(self) -> None:
|
||||
while self._packets and self._in_flight < self.max_in_flight:
|
||||
packet, connection_handle = self._packets.pop()
|
||||
self._send(packet)
|
||||
self._in_flight += 1
|
||||
self._in_flight_per_connection[connection_handle] += 1
|
||||
|
||||
def on_packets_completed(self, packet_count: int, connection_handle: int) -> None:
|
||||
"""Mark one or more packets associated with a connection as completed."""
|
||||
if connection_handle not in self._in_flight_per_connection:
|
||||
logger.warning(
|
||||
color(
|
||||
'!!! {packet_count} completed but only '
|
||||
f'{self.in_flight} in flight'
|
||||
)
|
||||
f'received completion for unknown connection {connection_handle}'
|
||||
)
|
||||
packet_count = self.in_flight
|
||||
return
|
||||
|
||||
self.in_flight -= packet_count
|
||||
self.check_queue()
|
||||
in_flight_for_connection = self._in_flight_per_connection[connection_handle]
|
||||
if packet_count <= in_flight_for_connection:
|
||||
self._in_flight_per_connection[connection_handle] -= packet_count
|
||||
else:
|
||||
logger.warning(
|
||||
f'{packet_count} completed for {connection_handle} '
|
||||
f'but only {in_flight_for_connection} in flight'
|
||||
)
|
||||
self._in_flight_per_connection[connection_handle] = 0
|
||||
|
||||
if packet_count <= self._in_flight:
|
||||
self._in_flight -= packet_count
|
||||
self._completed += packet_count
|
||||
else:
|
||||
logger.warning(
|
||||
f'{packet_count} completed but only {self._in_flight} in flight'
|
||||
)
|
||||
self._in_flight = 0
|
||||
self._completed = self._queued
|
||||
|
||||
self._check_queue()
|
||||
self.emit('flow')
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
@@ -115,7 +193,7 @@ class Connection:
|
||||
self.peer_address = peer_address
|
||||
self.assembler = hci.HCI_AclDataPacketAssembler(self.on_acl_pdu)
|
||||
self.transport = transport
|
||||
acl_packet_queue: Optional[AclPacketQueue] = (
|
||||
acl_packet_queue: Optional[DataPacketQueue] = (
|
||||
host.le_acl_packet_queue
|
||||
if transport == BT_LE_TRANSPORT
|
||||
else host.acl_packet_queue
|
||||
@@ -130,29 +208,37 @@ class Connection:
|
||||
l2cap_pdu = L2CAP_PDU.from_bytes(pdu)
|
||||
self.host.on_l2cap_pdu(self, l2cap_pdu.cid, l2cap_pdu.payload)
|
||||
|
||||
def __str__(self) -> str:
|
||||
return (
|
||||
f'Connection(transport={self.transport}, peer_address={self.peer_address})'
|
||||
)
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
@dataclasses.dataclass
|
||||
class ScoLink:
|
||||
peer_address: hci.Address
|
||||
handle: int
|
||||
connection_handle: int
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
@dataclasses.dataclass
|
||||
class CisLink:
|
||||
peer_address: hci.Address
|
||||
class IsoLink:
|
||||
handle: int
|
||||
packet_queue: DataPacketQueue = dataclasses.field(repr=False)
|
||||
packet_sequence_number: int = 0
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
class Host(AbortableEventEmitter):
|
||||
connections: Dict[int, Connection]
|
||||
cis_links: Dict[int, CisLink]
|
||||
cis_links: Dict[int, IsoLink]
|
||||
bis_links: Dict[int, IsoLink]
|
||||
sco_links: Dict[int, ScoLink]
|
||||
bigs: dict[int, set[int]] = {} # BIG Handle to BIS Handles
|
||||
acl_packet_queue: Optional[AclPacketQueue] = None
|
||||
le_acl_packet_queue: Optional[AclPacketQueue] = None
|
||||
acl_packet_queue: Optional[DataPacketQueue] = None
|
||||
le_acl_packet_queue: Optional[DataPacketQueue] = None
|
||||
iso_packet_queue: Optional[DataPacketQueue] = None
|
||||
hci_sink: Optional[TransportSink] = None
|
||||
hci_metadata: Dict[str, Any]
|
||||
long_term_key_provider: Optional[
|
||||
@@ -171,6 +257,7 @@ class Host(AbortableEventEmitter):
|
||||
self.ready = False # True when we can accept incoming packets
|
||||
self.connections = {} # Connections, by connection handle
|
||||
self.cis_links = {} # CIS links, by connection handle
|
||||
self.bis_links = {} # BIS links, by connection handle
|
||||
self.sco_links = {} # SCO links, by connection handle
|
||||
self.pending_command = None
|
||||
self.pending_response: Optional[asyncio.Future[Any]] = None
|
||||
@@ -389,6 +476,12 @@ class Host(AbortableEventEmitter):
|
||||
hci.HCI_LE_TRANSMIT_POWER_REPORTING_EVENT,
|
||||
hci.HCI_LE_BIGINFO_ADVERTISING_REPORT_EVENT,
|
||||
hci.HCI_LE_SUBRATE_CHANGE_EVENT,
|
||||
hci.HCI_LE_CS_READ_REMOTE_SUPPORTED_CAPABILITIES_COMPLETE_EVENT,
|
||||
hci.HCI_LE_CS_PROCEDURE_ENABLE_COMPLETE_EVENT,
|
||||
hci.HCI_LE_CS_SECURITY_ENABLE_COMPLETE_EVENT,
|
||||
hci.HCI_LE_CS_CONFIG_COMPLETE_EVENT,
|
||||
hci.HCI_LE_CS_SUBEVENT_RESULT_EVENT,
|
||||
hci.HCI_LE_CS_SUBEVENT_RESULT_CONTINUE_EVENT,
|
||||
]
|
||||
)
|
||||
|
||||
@@ -413,39 +506,70 @@ class Host(AbortableEventEmitter):
|
||||
f'hc_total_num_acl_data_packets={hc_total_num_acl_data_packets}'
|
||||
)
|
||||
|
||||
self.acl_packet_queue = AclPacketQueue(
|
||||
self.acl_packet_queue = DataPacketQueue(
|
||||
max_packet_size=hc_acl_data_packet_length,
|
||||
max_in_flight=hc_total_num_acl_data_packets,
|
||||
send=self.send_hci_packet,
|
||||
)
|
||||
|
||||
hc_le_acl_data_packet_length = 0
|
||||
hc_total_num_le_acl_data_packets = 0
|
||||
if self.supports_command(hci.HCI_LE_READ_BUFFER_SIZE_COMMAND):
|
||||
le_acl_data_packet_length = 0
|
||||
total_num_le_acl_data_packets = 0
|
||||
iso_data_packet_length = 0
|
||||
total_num_iso_data_packets = 0
|
||||
if self.supports_command(hci.HCI_LE_READ_BUFFER_SIZE_V2_COMMAND):
|
||||
response = await self.send_command(
|
||||
hci.HCI_LE_Read_Buffer_Size_V2_Command(), check_result=True
|
||||
)
|
||||
le_acl_data_packet_length = (
|
||||
response.return_parameters.le_acl_data_packet_length
|
||||
)
|
||||
total_num_le_acl_data_packets = (
|
||||
response.return_parameters.total_num_le_acl_data_packets
|
||||
)
|
||||
iso_data_packet_length = response.return_parameters.iso_data_packet_length
|
||||
total_num_iso_data_packets = (
|
||||
response.return_parameters.total_num_iso_data_packets
|
||||
)
|
||||
|
||||
logger.debug(
|
||||
'HCI LE flow control: '
|
||||
f'le_acl_data_packet_length={le_acl_data_packet_length},'
|
||||
f'total_num_le_acl_data_packets={total_num_le_acl_data_packets}'
|
||||
f'iso_data_packet_length={iso_data_packet_length},'
|
||||
f'total_num_iso_data_packets={total_num_iso_data_packets}'
|
||||
)
|
||||
elif self.supports_command(hci.HCI_LE_READ_BUFFER_SIZE_COMMAND):
|
||||
response = await self.send_command(
|
||||
hci.HCI_LE_Read_Buffer_Size_Command(), check_result=True
|
||||
)
|
||||
hc_le_acl_data_packet_length = (
|
||||
response.return_parameters.hc_le_acl_data_packet_length
|
||||
le_acl_data_packet_length = (
|
||||
response.return_parameters.le_acl_data_packet_length
|
||||
)
|
||||
hc_total_num_le_acl_data_packets = (
|
||||
response.return_parameters.hc_total_num_le_acl_data_packets
|
||||
total_num_le_acl_data_packets = (
|
||||
response.return_parameters.total_num_le_acl_data_packets
|
||||
)
|
||||
|
||||
logger.debug(
|
||||
'HCI LE ACL flow control: '
|
||||
f'hc_le_acl_data_packet_length={hc_le_acl_data_packet_length},'
|
||||
f'hc_total_num_le_acl_data_packets={hc_total_num_le_acl_data_packets}'
|
||||
f'le_acl_data_packet_length={le_acl_data_packet_length},'
|
||||
f'total_num_le_acl_data_packets={total_num_le_acl_data_packets}'
|
||||
)
|
||||
|
||||
if hc_le_acl_data_packet_length == 0 or hc_total_num_le_acl_data_packets == 0:
|
||||
if le_acl_data_packet_length == 0 or total_num_le_acl_data_packets == 0:
|
||||
# LE and Classic share the same queue
|
||||
self.le_acl_packet_queue = self.acl_packet_queue
|
||||
else:
|
||||
# Create a separate queue for LE
|
||||
self.le_acl_packet_queue = AclPacketQueue(
|
||||
max_packet_size=hc_le_acl_data_packet_length,
|
||||
max_in_flight=hc_total_num_le_acl_data_packets,
|
||||
self.le_acl_packet_queue = DataPacketQueue(
|
||||
max_packet_size=le_acl_data_packet_length,
|
||||
max_in_flight=total_num_le_acl_data_packets,
|
||||
send=self.send_hci_packet,
|
||||
)
|
||||
|
||||
if iso_data_packet_length and total_num_iso_data_packets:
|
||||
self.iso_packet_queue = DataPacketQueue(
|
||||
max_packet_size=iso_data_packet_length,
|
||||
max_in_flight=total_num_iso_data_packets,
|
||||
send=self.send_hci_packet,
|
||||
)
|
||||
|
||||
@@ -597,11 +721,78 @@ class Host(AbortableEventEmitter):
|
||||
data=l2cap_pdu[offset : offset + data_total_length],
|
||||
)
|
||||
logger.debug(f'>>> ACL packet enqueue: (CID={cid}) {acl_packet}')
|
||||
packet_queue.enqueue(acl_packet)
|
||||
packet_queue.enqueue(acl_packet, connection_handle)
|
||||
pb_flag = 1
|
||||
offset += data_total_length
|
||||
bytes_remaining -= data_total_length
|
||||
|
||||
def get_data_packet_queue(self, connection_handle: int) -> DataPacketQueue | None:
|
||||
if connection := self.connections.get(connection_handle):
|
||||
return connection.acl_packet_queue
|
||||
|
||||
if iso_link := self.cis_links.get(connection_handle) or self.bis_links.get(
|
||||
connection_handle
|
||||
):
|
||||
return iso_link.packet_queue
|
||||
|
||||
return None
|
||||
|
||||
def send_iso_sdu(self, connection_handle: int, sdu: bytes) -> None:
|
||||
if not (
|
||||
iso_link := self.cis_links.get(connection_handle)
|
||||
or self.bis_links.get(connection_handle)
|
||||
):
|
||||
logger.warning(f"no ISO link for connection handle {connection_handle}")
|
||||
return
|
||||
|
||||
if iso_link.packet_queue is None:
|
||||
logger.warning("ISO link has no data packet queue")
|
||||
return
|
||||
|
||||
bytes_remaining = len(sdu)
|
||||
offset = 0
|
||||
while bytes_remaining:
|
||||
is_first_fragment = offset == 0
|
||||
header_length = 4 if is_first_fragment else 0
|
||||
assert iso_link.packet_queue.max_packet_size > header_length
|
||||
fragment_length = min(
|
||||
bytes_remaining, iso_link.packet_queue.max_packet_size - header_length
|
||||
)
|
||||
is_last_fragment = bytes_remaining == fragment_length
|
||||
iso_sdu_fragment = sdu[offset : offset + fragment_length]
|
||||
iso_link.packet_queue.enqueue(
|
||||
(
|
||||
hci.HCI_IsoDataPacket(
|
||||
connection_handle=connection_handle,
|
||||
data_total_length=header_length + fragment_length,
|
||||
packet_sequence_number=iso_link.packet_sequence_number,
|
||||
pb_flag=0b10 if is_last_fragment else 0b00,
|
||||
packet_status_flag=0,
|
||||
iso_sdu_length=len(sdu),
|
||||
iso_sdu_fragment=iso_sdu_fragment,
|
||||
)
|
||||
if is_first_fragment
|
||||
else hci.HCI_IsoDataPacket(
|
||||
connection_handle=connection_handle,
|
||||
data_total_length=fragment_length,
|
||||
pb_flag=0b11 if is_last_fragment else 0b01,
|
||||
iso_sdu_fragment=iso_sdu_fragment,
|
||||
)
|
||||
),
|
||||
connection_handle,
|
||||
)
|
||||
|
||||
offset += fragment_length
|
||||
bytes_remaining -= fragment_length
|
||||
|
||||
iso_link.packet_sequence_number = (iso_link.packet_sequence_number + 1) & 0xFFFF
|
||||
|
||||
def remove_big(self, big_handle: int) -> None:
|
||||
if big := self.bigs.pop(big_handle, None):
|
||||
for connection_handle in big:
|
||||
if bis_link := self.bis_links.pop(connection_handle, None):
|
||||
bis_link.packet_queue.flush(bis_link.handle)
|
||||
|
||||
def supports_command(self, op_code: int) -> bool:
|
||||
return (
|
||||
self.local_supported_commands
|
||||
@@ -729,17 +920,17 @@ class Host(AbortableEventEmitter):
|
||||
def on_hci_command_status_event(self, event):
|
||||
return self.on_command_processed(event)
|
||||
|
||||
def on_hci_number_of_completed_packets_event(self, event):
|
||||
def on_hci_number_of_completed_packets_event(
|
||||
self, event: hci.HCI_Number_Of_Completed_Packets_Event
|
||||
) -> None:
|
||||
for connection_handle, num_completed_packets in zip(
|
||||
event.connection_handles, event.num_completed_packets
|
||||
):
|
||||
if connection := self.connections.get(connection_handle):
|
||||
connection.acl_packet_queue.on_packets_completed(num_completed_packets)
|
||||
elif connection_handle not in itertools.chain(
|
||||
self.cis_links.keys(),
|
||||
self.sco_links.keys(),
|
||||
itertools.chain.from_iterable(self.bigs.values()),
|
||||
):
|
||||
if queue := self.get_data_packet_queue(connection_handle):
|
||||
queue.on_packets_completed(num_completed_packets, connection_handle)
|
||||
continue
|
||||
|
||||
if connection_handle not in self.sco_links:
|
||||
logger.warning(
|
||||
'received packet completion event for unknown handle '
|
||||
f'0x{connection_handle:04X}'
|
||||
@@ -857,11 +1048,7 @@ class Host(AbortableEventEmitter):
|
||||
return
|
||||
|
||||
if event.status == hci.HCI_SUCCESS:
|
||||
logger.debug(
|
||||
f'### DISCONNECTION: [0x{handle:04X}] '
|
||||
f'{connection.peer_address} '
|
||||
f'reason={event.reason}'
|
||||
)
|
||||
logger.debug(f'### DISCONNECTION: {connection}, reason={event.reason}')
|
||||
|
||||
# Notify the listeners
|
||||
self.emit('disconnection', handle, event.reason)
|
||||
@@ -872,6 +1059,12 @@ class Host(AbortableEventEmitter):
|
||||
or self.cis_links.pop(handle, 0)
|
||||
or self.sco_links.pop(handle, 0)
|
||||
)
|
||||
|
||||
# Flush the data queues
|
||||
self.acl_packet_queue.flush(handle)
|
||||
self.le_acl_packet_queue.flush(handle)
|
||||
if self.iso_packet_queue:
|
||||
self.iso_packet_queue.flush(handle)
|
||||
else:
|
||||
logger.debug(f'### DISCONNECTION FAILED: {event.status}')
|
||||
|
||||
@@ -958,6 +1151,14 @@ class Host(AbortableEventEmitter):
|
||||
|
||||
def on_hci_le_create_big_complete_event(self, event):
|
||||
self.bigs[event.big_handle] = set(event.connection_handle)
|
||||
if self.iso_packet_queue is None:
|
||||
logger.warning("BIS established but ISO packets not supported")
|
||||
|
||||
for connection_handle in event.connection_handle:
|
||||
self.bis_links[connection_handle] = IsoLink(
|
||||
connection_handle, self.iso_packet_queue
|
||||
)
|
||||
|
||||
self.emit(
|
||||
'big_establishment',
|
||||
event.status,
|
||||
@@ -975,6 +1176,12 @@ class Host(AbortableEventEmitter):
|
||||
)
|
||||
|
||||
def on_hci_le_big_sync_established_event(self, event):
|
||||
self.bigs[event.big_handle] = set(event.connection_handle)
|
||||
for connection_handle in event.connection_handle:
|
||||
self.bis_links[connection_handle] = IsoLink(
|
||||
connection_handle, self.iso_packet_queue
|
||||
)
|
||||
|
||||
self.emit(
|
||||
'big_sync_establishment',
|
||||
event.status,
|
||||
@@ -990,22 +1197,20 @@ class Host(AbortableEventEmitter):
|
||||
)
|
||||
|
||||
def on_hci_le_big_sync_lost_event(self, event):
|
||||
self.emit(
|
||||
'big_sync_lost',
|
||||
event.big_handle,
|
||||
event.reason,
|
||||
)
|
||||
self.remove_big(event.big_handle)
|
||||
self.emit('big_sync_lost', event.big_handle, event.reason)
|
||||
|
||||
def on_hci_le_terminate_big_complete_event(self, event):
|
||||
self.bigs.pop(event.big_handle)
|
||||
self.remove_big(event.big_handle)
|
||||
self.emit('big_termination', event.reason, event.big_handle)
|
||||
|
||||
def on_hci_le_cis_established_event(self, event):
|
||||
# The remaining parameters are unused for now.
|
||||
if event.status == hci.HCI_SUCCESS:
|
||||
self.cis_links[event.connection_handle] = CisLink(
|
||||
handle=event.connection_handle,
|
||||
peer_address=hci.Address.ANY,
|
||||
if self.iso_packet_queue is None:
|
||||
logger.warning("CIS established but ISO packets not supported")
|
||||
self.cis_links[event.connection_handle] = IsoLink(
|
||||
handle=event.connection_handle, packet_queue=self.iso_packet_queue
|
||||
)
|
||||
self.emit('cis_establishment', event.connection_handle)
|
||||
else:
|
||||
@@ -1075,7 +1280,7 @@ class Host(AbortableEventEmitter):
|
||||
|
||||
self.sco_links[event.connection_handle] = ScoLink(
|
||||
peer_address=event.bd_addr,
|
||||
handle=event.connection_handle,
|
||||
connection_handle=event.connection_handle,
|
||||
)
|
||||
|
||||
# Notify the client
|
||||
@@ -1296,5 +1501,23 @@ class Host(AbortableEventEmitter):
|
||||
int.from_bytes(event.le_features, 'little'),
|
||||
)
|
||||
|
||||
def on_hci_le_cs_read_remote_supported_capabilities_complete_event(self, event):
|
||||
self.emit('cs_remote_supported_capabilities', event)
|
||||
|
||||
def on_hci_le_cs_security_enable_complete_event(self, event):
|
||||
self.emit('cs_security', event)
|
||||
|
||||
def on_hci_le_cs_config_complete_event(self, event):
|
||||
self.emit('cs_config', event)
|
||||
|
||||
def on_hci_le_cs_procedure_enable_complete_event(self, event):
|
||||
self.emit('cs_procedure', event)
|
||||
|
||||
def on_hci_le_cs_subevent_result_event(self, event):
|
||||
self.emit('cs_subevent_result', event)
|
||||
|
||||
def on_hci_le_cs_subevent_result_continue_event(self, event):
|
||||
self.emit('cs_subevent_result_continue', event)
|
||||
|
||||
def on_hci_vendor_event(self, event):
|
||||
self.emit('vendor_event', event)
|
||||
|
||||
@@ -0,0 +1,166 @@
|
||||
# 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.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# https://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import struct
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from bumble import att
|
||||
from bumble import gatt
|
||||
from bumble import gatt_client
|
||||
from bumble import crypto
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from bumble import device
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
class GenericAttributeProfileService(gatt.TemplateService):
|
||||
'''See Vol 3, Part G - 7 - DEFINED GENERIC ATTRIBUTE PROFILE SERVICE.'''
|
||||
|
||||
UUID = gatt.GATT_GENERIC_ATTRIBUTE_SERVICE
|
||||
|
||||
client_supported_features_characteristic: gatt.Characteristic | None = None
|
||||
server_supported_features_characteristic: gatt.Characteristic | None = None
|
||||
database_hash_characteristic: gatt.Characteristic | None = None
|
||||
service_changed_characteristic: gatt.Characteristic | None = None
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
server_supported_features: gatt.ServerSupportedFeatures | None = None,
|
||||
database_hash_enabled: bool = True,
|
||||
service_change_enabled: bool = True,
|
||||
) -> None:
|
||||
|
||||
if server_supported_features is not None:
|
||||
self.server_supported_features_characteristic = gatt.Characteristic(
|
||||
uuid=gatt.GATT_SERVER_SUPPORTED_FEATURES_CHARACTERISTIC,
|
||||
properties=gatt.Characteristic.Properties.READ,
|
||||
permissions=gatt.Characteristic.Permissions.READABLE,
|
||||
value=bytes([server_supported_features]),
|
||||
)
|
||||
|
||||
if database_hash_enabled:
|
||||
self.database_hash_characteristic = gatt.Characteristic(
|
||||
uuid=gatt.GATT_DATABASE_HASH_CHARACTERISTIC,
|
||||
properties=gatt.Characteristic.Properties.READ,
|
||||
permissions=gatt.Characteristic.Permissions.READABLE,
|
||||
value=gatt.CharacteristicValue(read=self.get_database_hash),
|
||||
)
|
||||
|
||||
if service_change_enabled:
|
||||
self.service_changed_characteristic = gatt.Characteristic(
|
||||
uuid=gatt.GATT_SERVICE_CHANGED_CHARACTERISTIC,
|
||||
properties=gatt.Characteristic.Properties.INDICATE,
|
||||
permissions=gatt.Characteristic.Permissions(0),
|
||||
value=b'',
|
||||
)
|
||||
|
||||
if (database_hash_enabled and service_change_enabled) or (
|
||||
server_supported_features
|
||||
and (
|
||||
server_supported_features & gatt.ServerSupportedFeatures.EATT_SUPPORTED
|
||||
)
|
||||
): # TODO: Support Multiple Handle Value Notifications
|
||||
self.client_supported_features_characteristic = gatt.Characteristic(
|
||||
uuid=gatt.GATT_CLIENT_SUPPORTED_FEATURES_CHARACTERISTIC,
|
||||
properties=(
|
||||
gatt.Characteristic.Properties.READ
|
||||
| gatt.Characteristic.Properties.WRITE
|
||||
),
|
||||
permissions=(
|
||||
gatt.Characteristic.Permissions.READABLE
|
||||
| gatt.Characteristic.Permissions.WRITEABLE
|
||||
),
|
||||
value=bytes(1),
|
||||
)
|
||||
|
||||
super().__init__(
|
||||
characteristics=[
|
||||
c
|
||||
for c in (
|
||||
self.service_changed_characteristic,
|
||||
self.client_supported_features_characteristic,
|
||||
self.database_hash_characteristic,
|
||||
self.server_supported_features_characteristic,
|
||||
)
|
||||
if c is not None
|
||||
],
|
||||
primary=True,
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def get_attribute_data(cls, attribute: att.Attribute) -> bytes:
|
||||
if attribute.type in (
|
||||
gatt.GATT_PRIMARY_SERVICE_ATTRIBUTE_TYPE,
|
||||
gatt.GATT_SECONDARY_SERVICE_ATTRIBUTE_TYPE,
|
||||
gatt.GATT_INCLUDE_ATTRIBUTE_TYPE,
|
||||
gatt.GATT_CHARACTERISTIC_ATTRIBUTE_TYPE,
|
||||
gatt.GATT_CHARACTERISTIC_EXTENDED_PROPERTIES_DESCRIPTOR,
|
||||
):
|
||||
return (
|
||||
struct.pack("<H", attribute.handle)
|
||||
+ attribute.type.to_bytes()
|
||||
+ attribute.value
|
||||
)
|
||||
elif attribute.type in (
|
||||
gatt.GATT_CHARACTERISTIC_USER_DESCRIPTION_DESCRIPTOR,
|
||||
gatt.GATT_CLIENT_CHARACTERISTIC_CONFIGURATION_DESCRIPTOR,
|
||||
gatt.GATT_SERVER_CHARACTERISTIC_CONFIGURATION_DESCRIPTOR,
|
||||
gatt.GATT_CHARACTERISTIC_PRESENTATION_FORMAT_DESCRIPTOR,
|
||||
gatt.GATT_CHARACTERISTIC_AGGREGATE_FORMAT_DESCRIPTOR,
|
||||
):
|
||||
return struct.pack("<H", attribute.handle) + attribute.type.to_bytes()
|
||||
|
||||
return b''
|
||||
|
||||
def get_database_hash(self, connection: device.Connection | None) -> bytes:
|
||||
assert connection
|
||||
|
||||
m = b''.join(
|
||||
[
|
||||
self.get_attribute_data(attribute)
|
||||
for attribute in connection.device.gatt_server.attributes
|
||||
]
|
||||
)
|
||||
|
||||
return crypto.aes_cmac(m=m, k=bytes(16))
|
||||
|
||||
|
||||
class GenericAttributeProfileServiceProxy(gatt_client.ProfileServiceProxy):
|
||||
SERVICE_CLASS = GenericAttributeProfileService
|
||||
|
||||
client_supported_features_characteristic: gatt_client.CharacteristicProxy | None = (
|
||||
None
|
||||
)
|
||||
server_supported_features_characteristic: gatt_client.CharacteristicProxy | None = (
|
||||
None
|
||||
)
|
||||
database_hash_characteristic: gatt_client.CharacteristicProxy | None = None
|
||||
service_changed_characteristic: gatt_client.CharacteristicProxy | None = None
|
||||
|
||||
_CHARACTERISTICS = {
|
||||
gatt.GATT_CLIENT_SUPPORTED_FEATURES_CHARACTERISTIC: 'client_supported_features_characteristic',
|
||||
gatt.GATT_SERVER_SUPPORTED_FEATURES_CHARACTERISTIC: 'server_supported_features_characteristic',
|
||||
gatt.GATT_DATABASE_HASH_CHARACTERISTIC: 'database_hash_characteristic',
|
||||
gatt.GATT_SERVICE_CHANGED_CHARACTERISTIC: 'service_changed_characteristic',
|
||||
}
|
||||
|
||||
def __init__(self, service_proxy: gatt_client.ServiceProxy) -> None:
|
||||
self.service_proxy = service_proxy
|
||||
|
||||
for uuid, attribute_name in self._CHARACTERISTICS.items():
|
||||
if characteristics := self.service_proxy.get_characteristics_by_uuid(uuid):
|
||||
setattr(self, attribute_name, characteristics[0])
|
||||
+1
-1
@@ -130,7 +130,7 @@ SDP_ATTRIBUTE_ID_NAMES = {
|
||||
SDP_PUBLIC_BROWSE_ROOT = core.UUID.from_16_bits(0x1002, 'PublicBrowseRoot')
|
||||
|
||||
# To be used in searches where an attribute ID list allows a range to be specified
|
||||
SDP_ALL_ATTRIBUTES_RANGE = (0x0000FFFF, 4) # Express this as tuple so we can convey the desired encoding size
|
||||
SDP_ALL_ATTRIBUTES_RANGE = (0x0000, 0xFFFF)
|
||||
|
||||
# fmt: on
|
||||
# pylint: enable=line-too-long
|
||||
|
||||
+1
-1
@@ -1326,7 +1326,7 @@ class Session:
|
||||
self.connection.abort_on('disconnection', self.on_pairing())
|
||||
|
||||
def on_connection_encryption_change(self) -> None:
|
||||
if self.connection.is_encrypted:
|
||||
if self.connection.is_encrypted and not self.completed:
|
||||
if self.is_responder:
|
||||
# The responder distributes its keys first, the initiator later
|
||||
self.distribute_keys()
|
||||
|
||||
+2
-2
@@ -447,7 +447,7 @@ def deprecated(msg: str):
|
||||
def wrapper(function):
|
||||
@functools.wraps(function)
|
||||
def inner(*args, **kwargs):
|
||||
warnings.warn(msg, DeprecationWarning)
|
||||
warnings.warn(msg, DeprecationWarning, stacklevel=2)
|
||||
return function(*args, **kwargs)
|
||||
|
||||
return inner
|
||||
@@ -464,7 +464,7 @@ def experimental(msg: str):
|
||||
def wrapper(function):
|
||||
@functools.wraps(function)
|
||||
def inner(*args, **kwargs):
|
||||
warnings.warn(msg, FutureWarning)
|
||||
warnings.warn(msg, FutureWarning, stacklevel=2)
|
||||
return function(*args, **kwargs)
|
||||
|
||||
return inner
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"name": "Bumble CS Initiator",
|
||||
"address": "F0:F1:F2:F3:F4:F5",
|
||||
"advertising_interval": 100,
|
||||
"keystore": "JsonKeyStore",
|
||||
"irk": "865F81FF5A8B486EAAE29A27AD9F77DC",
|
||||
"identity_address_type": 1,
|
||||
"channel_sounding_enabled": true
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"name": "Bumble CS Reflector",
|
||||
"address": "F0:F1:F2:F3:F4:F6",
|
||||
"advertising_interval": 100,
|
||||
"keystore": "JsonKeyStore",
|
||||
"irk": "0c7d74db03a1c98e7be691f76141d53d",
|
||||
"identity_address_type": 1,
|
||||
"channel_sounding_enabled": true
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -0,0 +1,154 @@
|
||||
# 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.
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Imports
|
||||
# -----------------------------------------------------------------------------
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import sys
|
||||
import os
|
||||
import functools
|
||||
|
||||
from bumble import core
|
||||
from bumble import hci
|
||||
from bumble.device import Connection, Device, ChannelSoundingCapabilities
|
||||
from bumble.transport import open_transport_or_link
|
||||
|
||||
# From https://cs.android.com/android/platform/superproject/main/+/main:packages/modules/Bluetooth/system/gd/hci/distance_measurement_manager.cc.
|
||||
CS_TONE_ANTENNA_CONFIG_MAPPING_TABLE = [
|
||||
[0, 4, 5, 6],
|
||||
[1, 7, 7, 7],
|
||||
[2, 7, 7, 7],
|
||||
[3, 7, 7, 7],
|
||||
]
|
||||
CS_PREFERRED_PEER_ANTENNA_MAPPING_TABLE = [1, 1, 1, 1, 3, 7, 15, 3]
|
||||
CS_ANTENNA_PERMUTATION_ARRAY = [
|
||||
[1, 2, 3, 4],
|
||||
[2, 1, 3, 4],
|
||||
[1, 3, 2, 4],
|
||||
[3, 1, 2, 4],
|
||||
[3, 2, 1, 4],
|
||||
[2, 3, 1, 4],
|
||||
[1, 2, 4, 3],
|
||||
[2, 1, 4, 3],
|
||||
[1, 4, 2, 3],
|
||||
[4, 1, 2, 3],
|
||||
[4, 2, 1, 3],
|
||||
[2, 4, 1, 3],
|
||||
[1, 4, 3, 2],
|
||||
[4, 1, 3, 2],
|
||||
[1, 3, 4, 2],
|
||||
[3, 1, 4, 2],
|
||||
[3, 4, 1, 2],
|
||||
[4, 3, 1, 2],
|
||||
[4, 2, 3, 1],
|
||||
[2, 4, 3, 1],
|
||||
[4, 3, 2, 1],
|
||||
[3, 4, 2, 1],
|
||||
[3, 2, 4, 1],
|
||||
[2, 3, 4, 1],
|
||||
]
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
async def main() -> None:
|
||||
if len(sys.argv) < 3:
|
||||
print(
|
||||
'Usage: run_channel_sounding.py <config-file> <transport-spec-for-device>'
|
||||
'[target_address](If missing, run as reflector)'
|
||||
)
|
||||
print('example: run_channel_sounding.py cs_reflector.json usb:0')
|
||||
print(
|
||||
'example: run_channel_sounding.py cs_initiator.json usb:0 F0:F1:F2:F3:F4:F5'
|
||||
)
|
||||
return
|
||||
|
||||
print('<<< connecting to HCI...')
|
||||
async with await open_transport_or_link(sys.argv[2]) as hci_transport:
|
||||
print('<<< connected')
|
||||
|
||||
device = Device.from_config_file_with_hci(
|
||||
sys.argv[1], hci_transport.source, hci_transport.sink
|
||||
)
|
||||
await device.power_on()
|
||||
assert (local_cs_capabilities := device.cs_capabilities)
|
||||
|
||||
if len(sys.argv) == 3:
|
||||
print('<<< Start Advertising')
|
||||
await device.start_advertising(
|
||||
own_address_type=hci.OwnAddressType.RANDOM, auto_restart=True
|
||||
)
|
||||
|
||||
def on_cs_capabilities(
|
||||
connection: Connection, capabilities: ChannelSoundingCapabilities
|
||||
):
|
||||
del capabilities
|
||||
print('<<< Set CS Settings')
|
||||
asyncio.create_task(device.set_default_cs_settings(connection))
|
||||
|
||||
device.on(
|
||||
'connection',
|
||||
lambda connection: connection.on(
|
||||
'channel_sounding_capabilities',
|
||||
functools.partial(on_cs_capabilities, connection),
|
||||
),
|
||||
)
|
||||
else:
|
||||
target_address = hci.Address(sys.argv[3])
|
||||
|
||||
print(f'<<< Connecting to {target_address}')
|
||||
connection = await device.connect(
|
||||
target_address, transport=core.BT_LE_TRANSPORT
|
||||
)
|
||||
print('<<< ACL Connected')
|
||||
if not (await device.get_long_term_key(connection.handle, b'', 0)):
|
||||
print('<<< No bond, start pairing')
|
||||
await connection.pair()
|
||||
print('<<< Pairing complete')
|
||||
|
||||
print('<<< Encrypting Connection')
|
||||
await connection.encrypt()
|
||||
|
||||
print('<<< Getting remote CS Capabilities...')
|
||||
remote_capabilities = await device.get_remote_cs_capabilities(connection)
|
||||
print('<<< Set CS Settings...')
|
||||
await device.set_default_cs_settings(connection)
|
||||
print('<<< Set CS Config...')
|
||||
config = await device.create_cs_config(connection)
|
||||
print('<<< Enable CS Security...')
|
||||
await device.enable_cs_security(connection)
|
||||
tone_antenna_config_selection = CS_TONE_ANTENNA_CONFIG_MAPPING_TABLE[
|
||||
local_cs_capabilities.num_antennas_supported - 1
|
||||
][remote_capabilities.num_antennas_supported - 1]
|
||||
print('<<< Set CS Procedure Parameters...')
|
||||
await device.set_cs_procedure_parameters(
|
||||
connection=connection,
|
||||
config=config,
|
||||
tone_antenna_config_selection=tone_antenna_config_selection,
|
||||
preferred_peer_antenna=CS_PREFERRED_PEER_ANTENNA_MAPPING_TABLE[
|
||||
tone_antenna_config_selection
|
||||
],
|
||||
)
|
||||
print('<<< Enable CS Procedure...')
|
||||
await device.enable_cs_procedure(connection=connection, config=config)
|
||||
|
||||
await hci_transport.source.terminated
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
logging.basicConfig(level=os.environ.get('BUMBLE_LOGLEVEL', 'DEBUG').upper())
|
||||
asyncio.run(main())
|
||||
@@ -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" />
|
||||
|
||||
+39
@@ -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")
|
||||
}
|
||||
}
|
||||
+122
-28
@@ -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());
|
||||
}
|
||||
|
||||
|
||||
+109
@@ -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")
|
||||
+224
@@ -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()
|
||||
}
|
||||
}
|
||||
+243
@@ -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")
|
||||
}
|
||||
}
|
||||
+5
-69
@@ -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()
|
||||
}
|
||||
|
||||
+3
-22
@@ -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() }
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
+114
-36
@@ -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 = ""
|
||||
|
||||
+21
-7
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+13
-11
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+26
-15
@@ -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")
|
||||
|
||||
+1
-1
@@ -153,7 +153,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"
|
||||
|
||||
+44
-27
@@ -34,7 +34,7 @@ from bumble.device import (
|
||||
Device,
|
||||
PeriodicAdvertisingParameters,
|
||||
)
|
||||
from bumble.host import AclPacketQueue, Host
|
||||
from bumble.host import DataPacketQueue, Host
|
||||
from bumble.hci import (
|
||||
HCI_ACCEPT_CONNECTION_REQUEST_COMMAND,
|
||||
HCI_COMMAND_STATUS_PENDING,
|
||||
@@ -50,12 +50,7 @@ from bumble.hci import (
|
||||
HCI_Error,
|
||||
HCI_Packet,
|
||||
)
|
||||
from bumble.gatt import (
|
||||
GATT_GENERIC_ACCESS_SERVICE,
|
||||
GATT_CHARACTERISTIC_ATTRIBUTE_TYPE,
|
||||
GATT_DEVICE_NAME_CHARACTERISTIC,
|
||||
GATT_APPEARANCE_CHARACTERISTIC,
|
||||
)
|
||||
from bumble import gatt
|
||||
|
||||
from .test_utils import TwoDevices, async_barrier
|
||||
|
||||
@@ -90,9 +85,9 @@ async def test_device_connect_parallel():
|
||||
def _send(packet):
|
||||
pass
|
||||
|
||||
d0.host.acl_packet_queue = AclPacketQueue(0, 0, _send)
|
||||
d1.host.acl_packet_queue = AclPacketQueue(0, 0, _send)
|
||||
d2.host.acl_packet_queue = AclPacketQueue(0, 0, _send)
|
||||
d0.host.acl_packet_queue = DataPacketQueue(0, 0, _send)
|
||||
d1.host.acl_packet_queue = DataPacketQueue(0, 0, _send)
|
||||
d2.host.acl_packet_queue = DataPacketQueue(0, 0, _send)
|
||||
|
||||
# enable classic
|
||||
d0.classic_enabled = True
|
||||
@@ -592,32 +587,54 @@ async def test_power_on_default_static_address_should_not_be_any():
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
def test_gatt_services_with_gas():
|
||||
def test_gatt_services_with_gas_and_gatt():
|
||||
device = Device(host=Host(None, None))
|
||||
|
||||
# there should be one service and two chars, therefore 5 attributes
|
||||
assert len(device.gatt_server.attributes) == 5
|
||||
assert device.gatt_server.attributes[0].uuid == GATT_GENERIC_ACCESS_SERVICE
|
||||
assert device.gatt_server.attributes[1].type == GATT_CHARACTERISTIC_ATTRIBUTE_TYPE
|
||||
assert device.gatt_server.attributes[2].uuid == GATT_DEVICE_NAME_CHARACTERISTIC
|
||||
assert device.gatt_server.attributes[3].type == GATT_CHARACTERISTIC_ATTRIBUTE_TYPE
|
||||
assert device.gatt_server.attributes[4].uuid == GATT_APPEARANCE_CHARACTERISTIC
|
||||
# there should be 2 service, 5 chars, and 1 descriptors, therefore 13 attributes
|
||||
assert len(device.gatt_server.attributes) == 13
|
||||
assert device.gatt_server.attributes[0].uuid == gatt.GATT_GENERIC_ACCESS_SERVICE
|
||||
assert (
|
||||
device.gatt_server.attributes[1].type == gatt.GATT_CHARACTERISTIC_ATTRIBUTE_TYPE
|
||||
)
|
||||
assert device.gatt_server.attributes[2].uuid == gatt.GATT_DEVICE_NAME_CHARACTERISTIC
|
||||
assert (
|
||||
device.gatt_server.attributes[3].type == gatt.GATT_CHARACTERISTIC_ATTRIBUTE_TYPE
|
||||
)
|
||||
assert device.gatt_server.attributes[4].uuid == gatt.GATT_APPEARANCE_CHARACTERISTIC
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
def test_gatt_services_without_gas():
|
||||
device = Device(host=Host(None, None), generic_access_service=False)
|
||||
|
||||
# there should be no services
|
||||
assert len(device.gatt_server.attributes) == 0
|
||||
assert device.gatt_server.attributes[5].uuid == gatt.GATT_GENERIC_ATTRIBUTE_SERVICE
|
||||
assert (
|
||||
device.gatt_server.attributes[6].type == gatt.GATT_CHARACTERISTIC_ATTRIBUTE_TYPE
|
||||
)
|
||||
assert (
|
||||
device.gatt_server.attributes[7].uuid
|
||||
== gatt.GATT_SERVICE_CHANGED_CHARACTERISTIC
|
||||
)
|
||||
assert (
|
||||
device.gatt_server.attributes[8].type
|
||||
== gatt.GATT_CLIENT_CHARACTERISTIC_CONFIGURATION_DESCRIPTOR
|
||||
)
|
||||
assert (
|
||||
device.gatt_server.attributes[9].type == gatt.GATT_CHARACTERISTIC_ATTRIBUTE_TYPE
|
||||
)
|
||||
assert (
|
||||
device.gatt_server.attributes[10].uuid
|
||||
== gatt.GATT_CLIENT_SUPPORTED_FEATURES_CHARACTERISTIC
|
||||
)
|
||||
assert (
|
||||
device.gatt_server.attributes[11].type
|
||||
== gatt.GATT_CHARACTERISTIC_ATTRIBUTE_TYPE
|
||||
)
|
||||
assert (
|
||||
device.gatt_server.attributes[12].uuid == gatt.GATT_DATABASE_HASH_CHARACTERISTIC
|
||||
)
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
async def run_test_device():
|
||||
await test_device_connect_parallel()
|
||||
await test_flush()
|
||||
await test_gatt_services_with_gas()
|
||||
await test_gatt_services_without_gas()
|
||||
await test_gatt_services_with_gas_and_gatt()
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
@@ -0,0 +1,140 @@
|
||||
# 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.
|
||||
# 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
|
||||
|
||||
from . import test_utils
|
||||
|
||||
from bumble import device
|
||||
from bumble import gatt
|
||||
from bumble.profiles import gatt_service
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
async def test_database_hash():
|
||||
devices = await test_utils.TwoDevices.create_with_connection()
|
||||
devices[0].gatt_server.services.clear()
|
||||
devices[0].gatt_server.attributes.clear()
|
||||
devices[0].gatt_server.attributes_by_handle.clear()
|
||||
devices[0].add_service(
|
||||
gatt.Service(
|
||||
gatt.GATT_GENERIC_ACCESS_SERVICE,
|
||||
characteristics=[
|
||||
gatt.Characteristic(
|
||||
gatt.GATT_DEVICE_NAME_CHARACTERISTIC,
|
||||
(
|
||||
gatt.Characteristic.Properties.READ
|
||||
| gatt.Characteristic.Properties.WRITE
|
||||
),
|
||||
gatt.Characteristic.Permissions.READ_REQUIRES_AUTHENTICATION,
|
||||
),
|
||||
gatt.Characteristic(
|
||||
gatt.GATT_APPEARANCE_CHARACTERISTIC,
|
||||
gatt.Characteristic.Properties.READ,
|
||||
gatt.Characteristic.Permissions.READ_REQUIRES_AUTHENTICATION,
|
||||
),
|
||||
],
|
||||
)
|
||||
)
|
||||
devices[0].add_service(
|
||||
gatt_service.GenericAttributeProfileService(
|
||||
server_supported_features=None,
|
||||
database_hash_enabled=True,
|
||||
service_change_enabled=True,
|
||||
)
|
||||
)
|
||||
devices[0].gatt_server.add_attribute(
|
||||
gatt.Service(gatt.GATT_GLUCOSE_SERVICE, characteristics=[])
|
||||
)
|
||||
# There is a special attribute order in the spec, so we need to add attribute one by
|
||||
# one here.
|
||||
battery_service = gatt.Service(
|
||||
gatt.GATT_BATTERY_SERVICE,
|
||||
characteristics=[
|
||||
gatt.Characteristic(
|
||||
gatt.GATT_BATTERY_LEVEL_CHARACTERISTIC,
|
||||
properties=gatt.Characteristic.Properties.READ,
|
||||
permissions=gatt.Characteristic.Permissions.READ_REQUIRES_AUTHENTICATION,
|
||||
)
|
||||
],
|
||||
primary=False,
|
||||
)
|
||||
battery_service.handle = 0x0014
|
||||
battery_service.end_group_handle = 0x0016
|
||||
devices[0].gatt_server.add_attribute(
|
||||
gatt.IncludedServiceDeclaration(battery_service)
|
||||
)
|
||||
c = gatt.Characteristic(
|
||||
'2A18',
|
||||
properties=(
|
||||
gatt.Characteristic.Properties.READ
|
||||
| gatt.Characteristic.Properties.INDICATE
|
||||
| gatt.Characteristic.Properties.EXTENDED_PROPERTIES
|
||||
),
|
||||
permissions=gatt.Characteristic.Permissions.READ_REQUIRES_AUTHENTICATION,
|
||||
)
|
||||
devices[0].gatt_server.add_attribute(
|
||||
gatt.CharacteristicDeclaration(c, devices[0].gatt_server.next_handle() + 1)
|
||||
)
|
||||
devices[0].gatt_server.add_attribute(c)
|
||||
devices[0].gatt_server.add_attribute(
|
||||
gatt.Descriptor(
|
||||
gatt.GATT_CLIENT_CHARACTERISTIC_CONFIGURATION_DESCRIPTOR,
|
||||
gatt.Descriptor.Permissions.READ_REQUIRES_AUTHENTICATION,
|
||||
b'\x02\x00',
|
||||
),
|
||||
)
|
||||
devices[0].gatt_server.add_attribute(
|
||||
gatt.Descriptor(
|
||||
gatt.GATT_CHARACTERISTIC_EXTENDED_PROPERTIES_DESCRIPTOR,
|
||||
gatt.Descriptor.Permissions.READ_REQUIRES_AUTHENTICATION,
|
||||
b'\x00\x00',
|
||||
),
|
||||
)
|
||||
devices[0].add_service(battery_service)
|
||||
|
||||
peer = device.Peer(devices.connections[1])
|
||||
client = await peer.discover_service_and_create_proxy(
|
||||
gatt_service.GenericAttributeProfileServiceProxy
|
||||
)
|
||||
assert client.database_hash_characteristic
|
||||
assert await client.database_hash_characteristic.read_value() == bytes.fromhex(
|
||||
'F1CA2D48ECF58BAC8A8830BBB9FBA990'
|
||||
)
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
async def test_service_changed():
|
||||
devices = await test_utils.TwoDevices.create_with_connection()
|
||||
assert (service := devices[0].gatt_service)
|
||||
|
||||
peer = device.Peer(devices.connections[1])
|
||||
assert (
|
||||
client := await peer.discover_service_and_create_proxy(
|
||||
gatt_service.GenericAttributeProfileServiceProxy
|
||||
)
|
||||
)
|
||||
assert client.service_changed_characteristic
|
||||
indications = []
|
||||
await client.service_changed_characteristic.subscribe(
|
||||
indications.append, prefer_notify=False
|
||||
)
|
||||
await devices[0].indicate_subscribers(
|
||||
service.service_changed_characteristic, b'1234'
|
||||
)
|
||||
await test_utils.async_barrier()
|
||||
assert indications[0] == b'1234'
|
||||
+17
-8
@@ -957,11 +957,12 @@ async def test_discover_all():
|
||||
peer = Peer(connection)
|
||||
|
||||
await peer.discover_all()
|
||||
assert len(peer.gatt_client.services) == 3
|
||||
# service 1800 gets added automatically
|
||||
assert len(peer.gatt_client.services) == 4
|
||||
# service 1800 and 1801 get added automatically
|
||||
assert peer.gatt_client.services[0].uuid == UUID('1800')
|
||||
assert peer.gatt_client.services[1].uuid == service1.uuid
|
||||
assert peer.gatt_client.services[2].uuid == service2.uuid
|
||||
assert peer.gatt_client.services[1].uuid == UUID('1801')
|
||||
assert peer.gatt_client.services[2].uuid == service1.uuid
|
||||
assert peer.gatt_client.services[3].uuid == service2.uuid
|
||||
s = peer.get_services_by_uuid(service1.uuid)
|
||||
assert len(s) == 1
|
||||
assert len(s[0].characteristics) == 2
|
||||
@@ -1084,10 +1085,18 @@ CharacteristicDeclaration(handle=0x0002, value_handle=0x0003, uuid=UUID-16:2A00
|
||||
Characteristic(handle=0x0003, end=0x0003, uuid=UUID-16:2A00 (Device Name), READ)
|
||||
CharacteristicDeclaration(handle=0x0004, value_handle=0x0005, uuid=UUID-16:2A01 (Appearance), READ)
|
||||
Characteristic(handle=0x0005, end=0x0005, uuid=UUID-16:2A01 (Appearance), READ)
|
||||
Service(handle=0x0006, end=0x0009, uuid=3A657F47-D34F-46B3-B1EC-698E29B6B829)
|
||||
CharacteristicDeclaration(handle=0x0007, value_handle=0x0008, uuid=FDB159DB-036C-49E3-B3DB-6325AC750806, READ|WRITE|NOTIFY)
|
||||
Characteristic(handle=0x0008, end=0x0009, uuid=FDB159DB-036C-49E3-B3DB-6325AC750806, READ|WRITE|NOTIFY)
|
||||
Descriptor(handle=0x0009, type=UUID-16:2902 (Client Characteristic Configuration), value=0000)"""
|
||||
Service(handle=0x0006, end=0x000D, uuid=UUID-16:1801 (Generic Attribute))
|
||||
CharacteristicDeclaration(handle=0x0007, value_handle=0x0008, uuid=UUID-16:2A05 (Service Changed), INDICATE)
|
||||
Characteristic(handle=0x0008, end=0x0009, uuid=UUID-16:2A05 (Service Changed), INDICATE)
|
||||
Descriptor(handle=0x0009, type=UUID-16:2902 (Client Characteristic Configuration), value=0000)
|
||||
CharacteristicDeclaration(handle=0x000A, value_handle=0x000B, uuid=UUID-16:2B29 (Client Supported Features), READ|WRITE)
|
||||
Characteristic(handle=0x000B, end=0x000B, uuid=UUID-16:2B29 (Client Supported Features), READ|WRITE)
|
||||
CharacteristicDeclaration(handle=0x000C, value_handle=0x000D, uuid=UUID-16:2B2A (Database Hash), READ)
|
||||
Characteristic(handle=0x000D, end=0x000D, uuid=UUID-16:2B2A (Database Hash), READ)
|
||||
Service(handle=0x000E, end=0x0011, uuid=3A657F47-D34F-46B3-B1EC-698E29B6B829)
|
||||
CharacteristicDeclaration(handle=0x000F, value_handle=0x0010, uuid=FDB159DB-036C-49E3-B3DB-6325AC750806, READ|WRITE|NOTIFY)
|
||||
Characteristic(handle=0x0010, end=0x0011, uuid=FDB159DB-036C-49E3-B3DB-6325AC750806, READ|WRITE|NOTIFY)
|
||||
Descriptor(handle=0x0011, type=UUID-16:2902 (Client Characteristic Configuration), value=0000)"""
|
||||
)
|
||||
|
||||
|
||||
|
||||
+2
-2
@@ -170,8 +170,8 @@ def test_HCI_Command_Complete_Event():
|
||||
command_opcode=HCI_LE_READ_BUFFER_SIZE_COMMAND,
|
||||
return_parameters=HCI_LE_Read_Buffer_Size_Command.create_return_parameters(
|
||||
status=0,
|
||||
hc_le_acl_data_packet_length=1234,
|
||||
hc_total_num_le_acl_data_packets=56,
|
||||
le_acl_data_packet_length=1234,
|
||||
total_num_le_acl_data_packets=56,
|
||||
),
|
||||
)
|
||||
basic_check(event)
|
||||
|
||||
+91
-1
@@ -16,11 +16,14 @@
|
||||
# Imports
|
||||
# -----------------------------------------------------------------------------
|
||||
import logging
|
||||
import unittest.mock
|
||||
import pytest
|
||||
import unittest
|
||||
|
||||
from bumble.controller import Controller
|
||||
from bumble.host import Host
|
||||
from bumble.host import Host, DataPacketQueue
|
||||
from bumble.transport import AsyncPipeSink
|
||||
from bumble.hci import HCI_AclDataPacket
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Logging
|
||||
@@ -60,3 +63,90 @@ async def test_reset(supported_commands: str, lmp_features: str):
|
||||
assert host.local_lmp_features == int.from_bytes(
|
||||
bytes.fromhex(lmp_features), 'little'
|
||||
)
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
def test_data_packet_queue():
|
||||
controller = unittest.mock.Mock()
|
||||
queue = DataPacketQueue(10, 2, controller.send)
|
||||
assert queue.queued == 0
|
||||
assert queue.completed == 0
|
||||
packet = HCI_AclDataPacket(
|
||||
connection_handle=123, pb_flag=0, bc_flag=0, data_total_length=0, data=b''
|
||||
)
|
||||
|
||||
queue.enqueue(packet, packet.connection_handle)
|
||||
assert queue.queued == 1
|
||||
assert queue.completed == 0
|
||||
assert controller.send.call_count == 1
|
||||
|
||||
queue.enqueue(packet, packet.connection_handle)
|
||||
assert queue.queued == 2
|
||||
assert queue.completed == 0
|
||||
assert controller.send.call_count == 2
|
||||
|
||||
queue.enqueue(packet, packet.connection_handle)
|
||||
assert queue.queued == 3
|
||||
assert queue.completed == 0
|
||||
assert controller.send.call_count == 2
|
||||
|
||||
queue.on_packets_completed(1, 8000)
|
||||
assert queue.queued == 3
|
||||
assert queue.completed == 0
|
||||
assert controller.send.call_count == 2
|
||||
|
||||
queue.on_packets_completed(1, 123)
|
||||
assert queue.queued == 3
|
||||
assert queue.completed == 1
|
||||
assert controller.send.call_count == 3
|
||||
|
||||
queue.enqueue(packet, packet.connection_handle)
|
||||
assert queue.queued == 4
|
||||
assert queue.completed == 1
|
||||
assert controller.send.call_count == 3
|
||||
|
||||
queue.on_packets_completed(2, 123)
|
||||
assert queue.queued == 4
|
||||
assert queue.completed == 3
|
||||
assert controller.send.call_count == 4
|
||||
|
||||
queue.on_packets_completed(1, 123)
|
||||
assert queue.queued == 4
|
||||
assert queue.completed == 4
|
||||
assert controller.send.call_count == 4
|
||||
|
||||
queue.enqueue(packet, 123)
|
||||
queue.enqueue(packet, 123)
|
||||
queue.enqueue(packet, 123)
|
||||
queue.enqueue(packet, 124)
|
||||
queue.enqueue(packet, 124)
|
||||
queue.enqueue(packet, 124)
|
||||
queue.on_packets_completed(1, 123)
|
||||
assert queue.queued == 10
|
||||
assert queue.completed == 5
|
||||
queue.flush(123)
|
||||
queue.flush(124)
|
||||
assert queue.queued == 10
|
||||
assert queue.completed == 10
|
||||
|
||||
queue.enqueue(packet, 123)
|
||||
queue.on_packets_completed(1, 124)
|
||||
assert queue.queued == 11
|
||||
assert queue.completed == 10
|
||||
queue.on_packets_completed(1000, 123)
|
||||
assert queue.queued == 11
|
||||
assert queue.completed == 11
|
||||
|
||||
drain_listener = unittest.mock.Mock()
|
||||
queue.on('flow', drain_listener.on_flow)
|
||||
queue.enqueue(packet, 123)
|
||||
assert drain_listener.on_flow.call_count == 0
|
||||
queue.on_packets_completed(1, 123)
|
||||
assert drain_listener.on_flow.call_count == 1
|
||||
queue.enqueue(packet, 123)
|
||||
queue.enqueue(packet, 123)
|
||||
queue.enqueue(packet, 123)
|
||||
queue.flush(123)
|
||||
assert drain_listener.on_flow.call_count == 1
|
||||
assert queue.queued == 15
|
||||
assert queue.completed == 15
|
||||
|
||||
Reference in New Issue
Block a user